pytrilogy 0.0.1.102__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pytrilogy might be problematic. Click here for more details.

Files changed (77) hide show
  1. pytrilogy-0.0.1.102.dist-info/LICENSE.md +19 -0
  2. pytrilogy-0.0.1.102.dist-info/METADATA +277 -0
  3. pytrilogy-0.0.1.102.dist-info/RECORD +77 -0
  4. pytrilogy-0.0.1.102.dist-info/WHEEL +5 -0
  5. pytrilogy-0.0.1.102.dist-info/entry_points.txt +2 -0
  6. pytrilogy-0.0.1.102.dist-info/top_level.txt +1 -0
  7. trilogy/__init__.py +8 -0
  8. trilogy/compiler.py +0 -0
  9. trilogy/constants.py +30 -0
  10. trilogy/core/__init__.py +0 -0
  11. trilogy/core/constants.py +3 -0
  12. trilogy/core/enums.py +270 -0
  13. trilogy/core/env_processor.py +33 -0
  14. trilogy/core/environment_helpers.py +156 -0
  15. trilogy/core/ergonomics.py +187 -0
  16. trilogy/core/exceptions.py +23 -0
  17. trilogy/core/functions.py +320 -0
  18. trilogy/core/graph_models.py +55 -0
  19. trilogy/core/internal.py +37 -0
  20. trilogy/core/models.py +3145 -0
  21. trilogy/core/processing/__init__.py +0 -0
  22. trilogy/core/processing/concept_strategies_v3.py +603 -0
  23. trilogy/core/processing/graph_utils.py +44 -0
  24. trilogy/core/processing/node_generators/__init__.py +25 -0
  25. trilogy/core/processing/node_generators/basic_node.py +71 -0
  26. trilogy/core/processing/node_generators/common.py +239 -0
  27. trilogy/core/processing/node_generators/concept_merge.py +152 -0
  28. trilogy/core/processing/node_generators/filter_node.py +83 -0
  29. trilogy/core/processing/node_generators/group_node.py +92 -0
  30. trilogy/core/processing/node_generators/group_to_node.py +99 -0
  31. trilogy/core/processing/node_generators/merge_node.py +148 -0
  32. trilogy/core/processing/node_generators/multiselect_node.py +189 -0
  33. trilogy/core/processing/node_generators/rowset_node.py +130 -0
  34. trilogy/core/processing/node_generators/select_node.py +328 -0
  35. trilogy/core/processing/node_generators/unnest_node.py +37 -0
  36. trilogy/core/processing/node_generators/window_node.py +85 -0
  37. trilogy/core/processing/nodes/__init__.py +76 -0
  38. trilogy/core/processing/nodes/base_node.py +251 -0
  39. trilogy/core/processing/nodes/filter_node.py +49 -0
  40. trilogy/core/processing/nodes/group_node.py +110 -0
  41. trilogy/core/processing/nodes/merge_node.py +326 -0
  42. trilogy/core/processing/nodes/select_node_v2.py +198 -0
  43. trilogy/core/processing/nodes/unnest_node.py +54 -0
  44. trilogy/core/processing/nodes/window_node.py +34 -0
  45. trilogy/core/processing/utility.py +278 -0
  46. trilogy/core/query_processor.py +331 -0
  47. trilogy/dialect/__init__.py +0 -0
  48. trilogy/dialect/base.py +679 -0
  49. trilogy/dialect/bigquery.py +80 -0
  50. trilogy/dialect/common.py +43 -0
  51. trilogy/dialect/config.py +55 -0
  52. trilogy/dialect/duckdb.py +83 -0
  53. trilogy/dialect/enums.py +95 -0
  54. trilogy/dialect/postgres.py +86 -0
  55. trilogy/dialect/presto.py +82 -0
  56. trilogy/dialect/snowflake.py +82 -0
  57. trilogy/dialect/sql_server.py +89 -0
  58. trilogy/docs/__init__.py +0 -0
  59. trilogy/engine.py +48 -0
  60. trilogy/executor.py +242 -0
  61. trilogy/hooks/__init__.py +0 -0
  62. trilogy/hooks/base_hook.py +37 -0
  63. trilogy/hooks/graph_hook.py +24 -0
  64. trilogy/hooks/query_debugger.py +133 -0
  65. trilogy/metadata/__init__.py +0 -0
  66. trilogy/parser.py +10 -0
  67. trilogy/parsing/__init__.py +0 -0
  68. trilogy/parsing/common.py +176 -0
  69. trilogy/parsing/config.py +5 -0
  70. trilogy/parsing/exceptions.py +2 -0
  71. trilogy/parsing/helpers.py +1 -0
  72. trilogy/parsing/parse_engine.py +1951 -0
  73. trilogy/parsing/render.py +483 -0
  74. trilogy/py.typed +0 -0
  75. trilogy/scripts/__init__.py +0 -0
  76. trilogy/scripts/trilogy.py +127 -0
  77. trilogy/utility.py +31 -0
@@ -0,0 +1,326 @@
1
+ from typing import List, Optional, Tuple
2
+
3
+
4
+ from trilogy.constants import logger
5
+ from trilogy.core.models import (
6
+ BaseJoin,
7
+ Grain,
8
+ JoinType,
9
+ QueryDatasource,
10
+ SourceType,
11
+ Concept,
12
+ UnnestJoin,
13
+ Conditional,
14
+ )
15
+ from trilogy.utility import unique
16
+ from trilogy.core.processing.nodes.base_node import (
17
+ StrategyNode,
18
+ resolve_concept_map,
19
+ NodeJoin,
20
+ )
21
+ from trilogy.core.processing.utility import get_node_joins
22
+
23
+ LOGGER_PREFIX = "[CONCEPT DETAIL - MERGE NODE]"
24
+
25
+
26
+ def deduplicate_nodes(
27
+ merged: dict[str, QueryDatasource], logging_prefix: str
28
+ ) -> tuple[bool, dict[str, QueryDatasource], set[str]]:
29
+ duplicates = False
30
+ removed: set[str] = set()
31
+ set_map: dict[str, set[str]] = {}
32
+ for k, v in merged.items():
33
+ unique_outputs = [
34
+ x.address for x in v.output_concepts if x not in v.partial_concepts
35
+ ]
36
+ set_map[k] = set(unique_outputs)
37
+ for k1, v1 in set_map.items():
38
+ found = False
39
+ for k2, v2 in set_map.items():
40
+ if k1 == k2:
41
+ continue
42
+ if (
43
+ v1.issubset(v2)
44
+ and merged[k1].grain.issubset(merged[k2].grain)
45
+ and not merged[k2].partial_concepts
46
+ and not merged[k1].partial_concepts
47
+ and not merged[k2].condition
48
+ and not merged[k1].condition
49
+ ):
50
+ og = merged[k1]
51
+ subset_to = merged[k2]
52
+ logger.info(
53
+ f"{logging_prefix}{LOGGER_PREFIX} extraneous parent node that is subset of another parent node {og.grain.issubset(subset_to.grain)} {og.grain.set} {subset_to.grain.set}"
54
+ )
55
+ merged = {k: v for k, v in merged.items() if k != k1}
56
+ removed.add(k1)
57
+ duplicates = True
58
+ found = True
59
+ break
60
+ if found:
61
+ break
62
+
63
+ return duplicates, merged, removed
64
+
65
+
66
+ def deduplicate_nodes_and_joins(
67
+ joins: List[NodeJoin] | None,
68
+ merged: dict[str, QueryDatasource],
69
+ logging_prefix: str,
70
+ ) -> Tuple[List[NodeJoin] | None, dict[str, QueryDatasource]]:
71
+ # it's possible that we have more sources than we need
72
+ duplicates = True
73
+ while duplicates:
74
+ duplicates = False
75
+ duplicates, merged, removed = deduplicate_nodes(merged, logging_prefix)
76
+ # filter out any removed joins
77
+ if joins:
78
+ joins = [
79
+ j
80
+ for j in joins
81
+ if j.left_node.resolve().full_name not in removed
82
+ and j.right_node.resolve().full_name not in removed
83
+ ]
84
+ return joins, merged
85
+
86
+
87
+ class MergeNode(StrategyNode):
88
+ source_type = SourceType.MERGE
89
+
90
+ def __init__(
91
+ self,
92
+ input_concepts: List[Concept],
93
+ output_concepts: List[Concept],
94
+ environment,
95
+ g,
96
+ whole_grain: bool = False,
97
+ parents: List["StrategyNode"] | None = None,
98
+ node_joins: List[NodeJoin] | None = None,
99
+ join_concepts: Optional[List] = None,
100
+ force_join_type: Optional[JoinType] = None,
101
+ partial_concepts: Optional[List[Concept]] = None,
102
+ force_group: bool | None = None,
103
+ depth: int = 0,
104
+ grain: Grain | None = None,
105
+ conditions: Conditional | None = None,
106
+ ):
107
+ super().__init__(
108
+ input_concepts=input_concepts,
109
+ output_concepts=output_concepts,
110
+ environment=environment,
111
+ g=g,
112
+ whole_grain=whole_grain,
113
+ parents=parents,
114
+ depth=depth,
115
+ partial_concepts=partial_concepts,
116
+ force_group=force_group,
117
+ grain=grain,
118
+ conditions=conditions,
119
+ )
120
+ self.join_concepts = join_concepts
121
+ self.force_join_type = force_join_type
122
+ self.node_joins = node_joins
123
+ final_joins = []
124
+ if self.node_joins:
125
+ for join in self.node_joins:
126
+ if join.left_node.resolve().name == join.right_node.resolve().name:
127
+ continue
128
+ final_joins.append(join)
129
+ self.node_joins = final_joins
130
+
131
+ def translate_node_joins(self, node_joins: List[NodeJoin]) -> List[BaseJoin]:
132
+ joins = []
133
+ for join in node_joins:
134
+ left = join.left_node.resolve()
135
+ right = join.right_node.resolve()
136
+ if left.full_name == right.full_name:
137
+ raise SyntaxError(f"Cannot join node {left.full_name} to itself")
138
+ joins.append(
139
+ BaseJoin(
140
+ left_datasource=left,
141
+ right_datasource=right,
142
+ join_type=join.join_type,
143
+ concepts=join.concepts,
144
+ )
145
+ )
146
+ return joins
147
+
148
+ def create_full_joins(self, dataset_list: List[QueryDatasource]):
149
+ joins = []
150
+ seen = set()
151
+ for left_value in dataset_list:
152
+ for right_value in dataset_list:
153
+ if left_value.identifier == right_value.identifier:
154
+ continue
155
+ if left_value.identifier in seen and right_value.identifier in seen:
156
+ continue
157
+ joins.append(
158
+ BaseJoin(
159
+ left_datasource=left_value,
160
+ right_datasource=right_value,
161
+ join_type=JoinType.FULL,
162
+ concepts=[],
163
+ )
164
+ )
165
+ seen.add(left_value.identifier)
166
+ seen.add(right_value.identifier)
167
+ return joins
168
+
169
+ def generate_joins(
170
+ self, final_datasets, final_joins, pregrain: Grain, grain: Grain
171
+ ) -> List[BaseJoin]:
172
+ # only finally, join between them for unique values
173
+ dataset_list: List[QueryDatasource] = sorted(
174
+ final_datasets, key=lambda x: -len(x.grain.components_copy)
175
+ )
176
+
177
+ logger.info(
178
+ f"{self.logging_prefix}{LOGGER_PREFIX} Merge node has {len(dataset_list)} parents, starting merge"
179
+ )
180
+ for item in dataset_list:
181
+ logger.info(f"{self.logging_prefix}{LOGGER_PREFIX} for {item.full_name}")
182
+ logger.info(
183
+ f"{self.logging_prefix}{LOGGER_PREFIX} partial concepts {[x.address for x in item.partial_concepts]}"
184
+ )
185
+ logger.info(
186
+ f"{self.logging_prefix}{LOGGER_PREFIX} potential merge keys {[x.address+str(x.purpose) for x in item.output_concepts]} partial {[x.address for x in item.partial_concepts]}"
187
+ )
188
+
189
+ if not final_joins:
190
+ if not pregrain.components:
191
+ logger.info(
192
+ f"{self.logging_prefix}{LOGGER_PREFIX} no grain components, doing full join"
193
+ )
194
+ joins = self.create_full_joins(dataset_list)
195
+ else:
196
+ logger.info(
197
+ f"{self.logging_prefix}{LOGGER_PREFIX} inferring node joins to target grain {str(grain)}"
198
+ )
199
+ joins = get_node_joins(dataset_list, grain.components)
200
+ elif final_joins:
201
+ logger.info(
202
+ f"{self.logging_prefix}{LOGGER_PREFIX} translating provided node joins {len(final_joins)}"
203
+ )
204
+ joins = self.translate_node_joins(final_joins)
205
+ else:
206
+ return []
207
+ for join in joins:
208
+ logger.info(
209
+ f"{self.logging_prefix}{LOGGER_PREFIX} final join {join.join_type} {[str(c) for c in join.concepts]}"
210
+ )
211
+ return joins
212
+
213
+ def _resolve(self) -> QueryDatasource:
214
+ parent_sources = [p.resolve() for p in self.parents]
215
+ merged: dict[str, QueryDatasource] = {}
216
+ final_joins = self.node_joins
217
+ for source in parent_sources:
218
+ if source.full_name in merged:
219
+ logger.info(
220
+ f"{self.logging_prefix}{LOGGER_PREFIX} parent node with {source.full_name} into existing"
221
+ )
222
+ merged[source.full_name] = merged[source.full_name] + source
223
+ else:
224
+ merged[source.full_name] = source
225
+
226
+ # it's possible that we have more sources than we need
227
+ final_joins, merged = deduplicate_nodes_and_joins(
228
+ final_joins, merged, self.logging_prefix
229
+ )
230
+ # early exit if we can just return the parent
231
+ final_datasets: List[QueryDatasource] = list(merged.values())
232
+
233
+ if len(merged.keys()) == 1:
234
+ final: QueryDatasource = list(merged.values())[0]
235
+ if (
236
+ set([c.address for c in final.output_concepts])
237
+ == set([c.address for c in self.output_concepts])
238
+ and not self.conditions
239
+ ):
240
+ logger.info(
241
+ f"{self.logging_prefix}{LOGGER_PREFIX} Merge node has only one parent with the same"
242
+ " outputs as this merge node, dropping merge node "
243
+ )
244
+ return final
245
+
246
+ # if we have multiple candidates, see if one is good enough
247
+ for dataset in final_datasets:
248
+ output_set = set(
249
+ [
250
+ c.address
251
+ for c in dataset.output_concepts
252
+ if c.address not in [x.address for x in dataset.partial_concepts]
253
+ ]
254
+ )
255
+ if (
256
+ all([c.address in output_set for c in self.all_concepts])
257
+ and not self.conditions
258
+ ):
259
+ logger.info(
260
+ f"{self.logging_prefix}{LOGGER_PREFIX} Merge node not required as parent node {dataset.source_type}"
261
+ f" has all required output properties with partial {[c.address for c in dataset.partial_concepts]}"
262
+ f" and self has no conditions ({self.conditions})"
263
+ )
264
+ return dataset
265
+
266
+ pregrain = Grain()
267
+ for source in final_datasets:
268
+ pregrain += source.grain
269
+
270
+ grain = Grain(
271
+ components=[
272
+ c
273
+ for c in pregrain.components
274
+ if c.address in [x.address for x in self.output_concepts]
275
+ ]
276
+ )
277
+ logger.info(
278
+ f"{self.logging_prefix}{LOGGER_PREFIX} has pre grain {pregrain} and final merge node grain {grain}"
279
+ )
280
+
281
+ if len(final_datasets) > 1:
282
+ joins = self.generate_joins(final_datasets, final_joins, pregrain, grain)
283
+ else:
284
+ joins = []
285
+
286
+ full_join_concepts = []
287
+ for join in joins:
288
+ if join.join_type == JoinType.FULL:
289
+ full_join_concepts += join.concepts
290
+
291
+ if self.whole_grain:
292
+ force_group = False
293
+ elif self.force_group is False:
294
+ force_group = False
295
+ elif not any(
296
+ [d.grain.issubset(grain) for d in final_datasets]
297
+ ) and not pregrain.issubset(grain):
298
+ logger.info(
299
+ f"{self.logging_prefix}{LOGGER_PREFIX} no parents include full grain {grain} and pregrain {pregrain} does not match, assume must group to grain. Have {[str(d.grain) for d in final_datasets]}"
300
+ )
301
+ force_group = True
302
+ # Grain<returns.customer.id,returns.store.id,returns.item.id,returns.store_sales.ticket_number>
303
+ # Grain<returns.customer.id,returns.store.id,returns.return_date.id,returns.item.id,returns.store_sales.ticket_number>
304
+ # Grain<returns.customer.id,returns.store.id,returns.item.id,returns.store_sales.ticket_number>
305
+ else:
306
+ force_group = None
307
+
308
+ qd_joins: List[BaseJoin | UnnestJoin] = [*joins]
309
+ qds = QueryDatasource(
310
+ input_concepts=unique(self.input_concepts, "address"),
311
+ output_concepts=unique(self.output_concepts, "address"),
312
+ datasources=final_datasets,
313
+ source_type=self.source_type,
314
+ source_map=resolve_concept_map(
315
+ parent_sources,
316
+ self.output_concepts,
317
+ self.input_concepts,
318
+ full_joins=full_join_concepts,
319
+ ),
320
+ joins=qd_joins,
321
+ grain=grain,
322
+ partial_concepts=self.partial_concepts,
323
+ force_group=force_group,
324
+ condition=self.conditions,
325
+ )
326
+ return qds
@@ -0,0 +1,198 @@
1
+ from typing import List, Optional
2
+
3
+
4
+ from trilogy.constants import logger
5
+ from trilogy.core.constants import CONSTANT_DATASET
6
+ from trilogy.core.enums import Purpose, PurposeLineage
7
+ from trilogy.core.models import (
8
+ Datasource,
9
+ QueryDatasource,
10
+ SourceType,
11
+ Environment,
12
+ Concept,
13
+ Grain,
14
+ Function,
15
+ UnnestJoin,
16
+ )
17
+ from trilogy.utility import unique
18
+ from trilogy.core.processing.nodes.base_node import StrategyNode
19
+ from trilogy.core.exceptions import NoDatasourceException
20
+
21
+
22
+ LOGGER_PREFIX = "[CONCEPT DETAIL - SELECT NODE]"
23
+
24
+
25
+ class StaticSelectNode(StrategyNode):
26
+ """Static select nodes."""
27
+
28
+ source_type = SourceType.SELECT
29
+
30
+ def __init__(
31
+ self,
32
+ input_concepts: List[Concept],
33
+ output_concepts: List[Concept],
34
+ environment: Environment,
35
+ g,
36
+ datasource: QueryDatasource,
37
+ depth: int = 0,
38
+ partial_concepts: List[Concept] | None = None,
39
+ ):
40
+ super().__init__(
41
+ input_concepts=input_concepts,
42
+ output_concepts=output_concepts,
43
+ environment=environment,
44
+ g=g,
45
+ whole_grain=True,
46
+ parents=[],
47
+ depth=depth,
48
+ partial_concepts=partial_concepts,
49
+ )
50
+ self.datasource = datasource
51
+
52
+ def _resolve(self):
53
+ if self.datasource.grain == Grain():
54
+ raise NotImplementedError
55
+ return self.datasource
56
+
57
+
58
+ class SelectNode(StrategyNode):
59
+ """Select nodes actually fetch raw data from a table
60
+ Responsible for selecting the cheapest option from which to select.
61
+ """
62
+
63
+ source_type = SourceType.SELECT
64
+
65
+ def __init__(
66
+ self,
67
+ input_concepts: List[Concept],
68
+ output_concepts: List[Concept],
69
+ environment: Environment,
70
+ g,
71
+ datasource: Datasource | None = None,
72
+ whole_grain: bool = False,
73
+ parents: List["StrategyNode"] | None = None,
74
+ depth: int = 0,
75
+ partial_concepts: List[Concept] | None = None,
76
+ accept_partial: bool = False,
77
+ grain: Optional[Grain] = None,
78
+ force_group: bool = False,
79
+ ):
80
+ super().__init__(
81
+ input_concepts=input_concepts,
82
+ output_concepts=output_concepts,
83
+ environment=environment,
84
+ g=g,
85
+ whole_grain=whole_grain,
86
+ parents=parents,
87
+ depth=depth,
88
+ partial_concepts=partial_concepts,
89
+ force_group=force_group,
90
+ grain=grain,
91
+ )
92
+ self.accept_partial = accept_partial
93
+ self.datasource = datasource
94
+
95
+ def resolve_from_provided_datasource(
96
+ self,
97
+ ) -> QueryDatasource:
98
+ if not self.datasource:
99
+ raise ValueError("Datasource not provided")
100
+ datasource: Datasource = self.datasource
101
+
102
+ all_concepts_final: List[Concept] = unique(self.all_concepts, "address")
103
+ source_map: dict[str, set[Datasource | QueryDatasource | UnnestJoin]] = {
104
+ concept.address: {datasource} for concept in self.input_concepts
105
+ }
106
+
107
+ derived_concepts = [
108
+ c
109
+ for c in datasource.columns
110
+ if isinstance(c.alias, Function) and c.concept.address in source_map
111
+ ]
112
+ for c in derived_concepts:
113
+ if not isinstance(c.alias, Function):
114
+ continue
115
+ for x in c.alias.concept_arguments:
116
+ source_map[x.address] = {datasource}
117
+ for x in all_concepts_final:
118
+ # add in any derived concepts to support a merge node
119
+ if x.address not in source_map and x.derivation in (
120
+ PurposeLineage.MULTISELECT,
121
+ PurposeLineage.MERGE,
122
+ ):
123
+ source_map[x.address] = set()
124
+
125
+ # if we're not grouping
126
+ # force grain to datasource grain
127
+ # so that we merge on the same grain
128
+ if self.force_group is False:
129
+ grain = datasource.grain
130
+ else:
131
+ grain = self.grain or Grain()
132
+ return QueryDatasource(
133
+ input_concepts=self.input_concepts,
134
+ output_concepts=all_concepts_final,
135
+ source_map=source_map,
136
+ datasources=[datasource],
137
+ grain=grain,
138
+ joins=[],
139
+ partial_concepts=[
140
+ c.concept for c in datasource.columns if not c.is_complete
141
+ ],
142
+ source_type=SourceType.DIRECT_SELECT,
143
+ # select nodes should never group
144
+ force_group=self.force_group,
145
+ )
146
+
147
+ def resolve_from_constant_datasources(self) -> QueryDatasource:
148
+ datasource = Datasource(
149
+ identifier=CONSTANT_DATASET, address=CONSTANT_DATASET, columns=[]
150
+ )
151
+ return QueryDatasource(
152
+ input_concepts=[],
153
+ output_concepts=unique(self.all_concepts, "address"),
154
+ source_map={concept.address: set() for concept in self.all_concepts},
155
+ datasources=[datasource],
156
+ grain=datasource.grain,
157
+ joins=[],
158
+ partial_concepts=[],
159
+ source_type=SourceType.CONSTANT,
160
+ )
161
+
162
+ def _resolve(self) -> QueryDatasource:
163
+ # if we have parent nodes, we do not need to go to a datasource
164
+ if self.parents:
165
+ return super()._resolve()
166
+ resolution: QueryDatasource | None
167
+ if all(
168
+ [
169
+ (
170
+ c.derivation == PurposeLineage.CONSTANT
171
+ or (
172
+ c.purpose == Purpose.CONSTANT
173
+ and c.derivation == PurposeLineage.MULTISELECT
174
+ )
175
+ )
176
+ for c in self.all_concepts
177
+ ]
178
+ ):
179
+ logger.info(
180
+ f"{self.logging_prefix}{LOGGER_PREFIX} have a constant datasource"
181
+ )
182
+ resolution = self.resolve_from_constant_datasources()
183
+ if resolution:
184
+ return resolution
185
+ if self.datasource:
186
+ resolution = self.resolve_from_provided_datasource()
187
+ if resolution:
188
+ return resolution
189
+ required = [c.address for c in self.all_concepts]
190
+ raise NoDatasourceException(
191
+ f"Could not find any way to associate required concepts {required}"
192
+ )
193
+
194
+
195
+ class ConstantNode(SelectNode):
196
+ """Represents a constant value."""
197
+
198
+ pass
@@ -0,0 +1,54 @@
1
+ from typing import List
2
+
3
+
4
+ from trilogy.core.models import (
5
+ QueryDatasource,
6
+ SourceType,
7
+ Concept,
8
+ UnnestJoin,
9
+ )
10
+ from trilogy.core.processing.nodes.base_node import StrategyNode
11
+
12
+
13
+ class UnnestNode(StrategyNode):
14
+ """Unnest nodes represent an expansion of an array or other
15
+ column into rows.
16
+ """
17
+
18
+ source_type = SourceType.UNNEST
19
+
20
+ def __init__(
21
+ self,
22
+ unnest_concept: Concept,
23
+ input_concepts: List[Concept],
24
+ output_concepts: List[Concept],
25
+ environment,
26
+ g,
27
+ whole_grain: bool = False,
28
+ parents: List["StrategyNode"] | None = None,
29
+ depth: int = 0,
30
+ ):
31
+ super().__init__(
32
+ input_concepts=input_concepts,
33
+ output_concepts=output_concepts,
34
+ environment=environment,
35
+ g=g,
36
+ whole_grain=whole_grain,
37
+ parents=parents,
38
+ depth=depth,
39
+ )
40
+ self.unnest_concept = unnest_concept
41
+
42
+ def _resolve(self) -> QueryDatasource:
43
+ """We need to ensure that any filtered values are removed from the output to avoid inappropriate references"""
44
+ base = super()._resolve()
45
+
46
+ unnest = UnnestJoin(
47
+ concept=self.unnest_concept,
48
+ alias=f'unnest_{self.unnest_concept.address.replace(".", "_")}',
49
+ )
50
+ base.joins.append(unnest)
51
+
52
+ base.source_map[self.unnest_concept.address] = {unnest}
53
+ base.join_derived_concepts = [self.unnest_concept]
54
+ return base
@@ -0,0 +1,34 @@
1
+ from typing import List
2
+
3
+
4
+ from trilogy.core.models import SourceType, Concept, Grain
5
+ from trilogy.core.processing.nodes.base_node import StrategyNode, QueryDatasource
6
+
7
+
8
+ class WindowNode(StrategyNode):
9
+ source_type = SourceType.WINDOW
10
+
11
+ def __init__(
12
+ self,
13
+ input_concepts: List[Concept],
14
+ output_concepts: List[Concept],
15
+ environment,
16
+ g,
17
+ whole_grain: bool = False,
18
+ parents: List["StrategyNode"] | None = None,
19
+ depth: int = 0,
20
+ ):
21
+ super().__init__(
22
+ input_concepts=input_concepts,
23
+ output_concepts=output_concepts,
24
+ environment=environment,
25
+ g=g,
26
+ whole_grain=whole_grain,
27
+ parents=parents,
28
+ depth=depth,
29
+ )
30
+
31
+ def _resolve(self) -> QueryDatasource:
32
+ base = super()._resolve()
33
+ base.grain = Grain(components=self.input_concepts)
34
+ return base