pytrilogy 0.0.2.17__py3-none-any.whl → 0.0.2.18__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 (40) hide show
  1. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/METADATA +12 -8
  2. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/RECORD +40 -39
  3. trilogy/__init__.py +1 -1
  4. trilogy/constants.py +1 -1
  5. trilogy/core/enums.py +1 -0
  6. trilogy/core/functions.py +11 -0
  7. trilogy/core/models.py +89 -47
  8. trilogy/core/optimization.py +15 -9
  9. trilogy/core/processing/concept_strategies_v3.py +372 -145
  10. trilogy/core/processing/node_generators/basic_node.py +27 -55
  11. trilogy/core/processing/node_generators/common.py +6 -7
  12. trilogy/core/processing/node_generators/filter_node.py +28 -31
  13. trilogy/core/processing/node_generators/group_node.py +14 -2
  14. trilogy/core/processing/node_generators/group_to_node.py +3 -1
  15. trilogy/core/processing/node_generators/multiselect_node.py +3 -0
  16. trilogy/core/processing/node_generators/node_merge_node.py +14 -9
  17. trilogy/core/processing/node_generators/rowset_node.py +12 -12
  18. trilogy/core/processing/node_generators/select_merge_node.py +302 -0
  19. trilogy/core/processing/node_generators/select_node.py +7 -511
  20. trilogy/core/processing/node_generators/unnest_node.py +4 -3
  21. trilogy/core/processing/node_generators/window_node.py +12 -37
  22. trilogy/core/processing/nodes/__init__.py +0 -2
  23. trilogy/core/processing/nodes/base_node.py +69 -20
  24. trilogy/core/processing/nodes/filter_node.py +3 -0
  25. trilogy/core/processing/nodes/group_node.py +18 -17
  26. trilogy/core/processing/nodes/merge_node.py +4 -10
  27. trilogy/core/processing/nodes/select_node_v2.py +28 -14
  28. trilogy/core/processing/nodes/window_node.py +1 -2
  29. trilogy/core/processing/utility.py +51 -3
  30. trilogy/core/query_processor.py +17 -73
  31. trilogy/dialect/base.py +7 -3
  32. trilogy/dialect/duckdb.py +4 -1
  33. trilogy/dialect/sql_server.py +3 -3
  34. trilogy/hooks/query_debugger.py +5 -3
  35. trilogy/parsing/parse_engine.py +66 -38
  36. trilogy/parsing/trilogy.lark +2 -1
  37. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/LICENSE.md +0 -0
  38. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/WHEEL +0 -0
  39. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/entry_points.txt +0 -0
  40. {pytrilogy-0.0.2.17.dist-info → pytrilogy-0.0.2.18.dist-info}/top_level.txt +0 -0
@@ -79,11 +79,12 @@ def resolve_concept_map(
79
79
  # second loop, include partials
80
80
  for input in inputs:
81
81
  for concept in input.output_concepts:
82
- if concept.address not in [t.address for t in inherited_inputs]:
82
+ if concept.address not in [t for t in inherited_inputs]:
83
83
  continue
84
- if isinstance(input, QueryDatasource) and concept.address in [
85
- x.address for x in input.hidden_concepts
86
- ]:
84
+ if (
85
+ isinstance(input, QueryDatasource)
86
+ and concept.address in input.hidden_concepts
87
+ ):
87
88
  continue
88
89
  if len(concept_map.get(concept.address, [])) == 0:
89
90
  concept_map[concept.address].add(input)
@@ -158,6 +159,7 @@ class StrategyNode:
158
159
  nullable_concepts: List[Concept] | None = None,
159
160
  depth: int = 0,
160
161
  conditions: Conditional | Comparison | Parenthetical | None = None,
162
+ preexisting_conditions: Conditional | Comparison | Parenthetical | None = None,
161
163
  force_group: bool | None = None,
162
164
  grain: Optional[Grain] = None,
163
165
  hidden_concepts: List[Concept] | None = None,
@@ -191,11 +193,32 @@ class StrategyNode:
191
193
  self.hidden_concepts = hidden_concepts or []
192
194
  self.existence_concepts = existence_concepts or []
193
195
  self.virtual_output_concepts = virtual_output_concepts or []
196
+ self.preexisting_conditions = preexisting_conditions
197
+ if self.conditions and not self.preexisting_conditions:
198
+ self.preexisting_conditions = self.conditions
199
+ elif (
200
+ self.conditions
201
+ and self.preexisting_conditions
202
+ and self.conditions != self.preexisting_conditions
203
+ ):
204
+ self.preexisting_conditions = Conditional(
205
+ left=self.conditions,
206
+ right=self.preexisting_conditions,
207
+ operator=BooleanOperator.AND,
208
+ )
194
209
  self.validate_parents()
210
+ self.log = True
195
211
 
196
212
  def add_parents(self, parents: list["StrategyNode"]):
197
213
  self.parents += parents
198
214
  self.validate_parents()
215
+ return self
216
+
217
+ def set_preexisting_conditions(
218
+ self, conditions: Conditional | Comparison | Parenthetical
219
+ ):
220
+ self.preexisting_conditions = conditions
221
+ return self
199
222
 
200
223
  def add_condition(self, condition: Conditional | Comparison | Parenthetical):
201
224
  if self.conditions:
@@ -204,6 +227,9 @@ class StrategyNode:
204
227
  )
205
228
  else:
206
229
  self.conditions = condition
230
+ self.set_preexisting_conditions(condition)
231
+ self.rebuild_cache()
232
+ return self
207
233
 
208
234
  def validate_parents(self):
209
235
  # validate parents exist
@@ -220,40 +246,54 @@ class StrategyNode:
220
246
 
221
247
  self.partial_lcl = LooseConceptList(concepts=self.partial_concepts)
222
248
 
223
- def add_output_concepts(self, concepts: List[Concept]):
249
+ def add_output_concepts(self, concepts: List[Concept], rebuild: bool = True):
224
250
  for concept in concepts:
225
251
  if concept.address not in self.output_lcl.addresses:
226
252
  self.output_concepts.append(concept)
227
253
  self.output_lcl = LooseConceptList(concepts=self.output_concepts)
228
- self.rebuild_cache()
254
+ if rebuild:
255
+ self.rebuild_cache()
256
+ return self
229
257
 
230
- def add_existence_concepts(self, concepts: List[Concept]):
258
+ def add_existence_concepts(self, concepts: List[Concept], rebuild: bool = True):
231
259
  for concept in concepts:
232
- if concept.address not in [x.address for x in self.output_concepts]:
260
+ if concept.address not in self.output_concepts:
233
261
  self.existence_concepts.append(concept)
234
- self.rebuild_cache()
235
-
236
- def set_output_concepts(self, concepts: List[Concept]):
262
+ if rebuild:
263
+ self.rebuild_cache()
264
+ return self
265
+
266
+ def set_output_concepts(self, concepts: List[Concept], rebuild: bool = True):
267
+ # exit if no changes
268
+ if self.output_concepts == concepts:
269
+ return self
237
270
  self.output_concepts = concepts
238
271
  self.output_lcl = LooseConceptList(concepts=self.output_concepts)
239
- self.rebuild_cache()
240
272
 
241
- def add_output_concept(self, concept: Concept):
242
- self.add_output_concepts([concept])
273
+ if rebuild:
274
+ self.rebuild_cache()
275
+ return self
243
276
 
244
- def hide_output_concepts(self, concepts: List[Concept]):
277
+ def add_output_concept(self, concept: Concept, rebuild: bool = True):
278
+ return self.add_output_concepts([concept], rebuild)
279
+
280
+ def hide_output_concepts(self, concepts: List[Concept], rebuild: bool = True):
245
281
  for x in concepts:
246
282
  self.hidden_concepts.append(x)
247
- self.rebuild_cache()
283
+ if rebuild:
284
+ self.rebuild_cache()
285
+ return self
248
286
 
249
- def remove_output_concepts(self, concepts: List[Concept]):
287
+ def remove_output_concepts(self, concepts: List[Concept], rebuild: bool = True):
250
288
  for x in concepts:
251
289
  self.hidden_concepts.append(x)
252
290
  addresses = [x.address for x in concepts]
253
291
  self.output_concepts = [
254
292
  x for x in self.output_concepts if x.address not in addresses
255
293
  ]
256
- self.rebuild_cache()
294
+ if rebuild:
295
+ self.rebuild_cache()
296
+ return self
257
297
 
258
298
  @property
259
299
  def logging_prefix(self) -> str:
@@ -269,7 +309,11 @@ class StrategyNode:
269
309
 
270
310
  def __repr__(self):
271
311
  concepts = self.all_concepts
272
- contents = ",".join(sorted([c.address for c in concepts]))
312
+ addresses = [c.address for c in concepts]
313
+ contents = ",".join(sorted(addresses[:3]))
314
+ if len(addresses) > 3:
315
+ extra = len(addresses) - 3
316
+ contents += f"...{extra} more"
273
317
  return f"{self.__class__.__name__}<{contents}>"
274
318
 
275
319
  def _resolve(self) -> QueryDatasource:
@@ -277,7 +321,11 @@ class StrategyNode:
277
321
  p.resolve() for p in self.parents
278
322
  ]
279
323
 
280
- grain = self.grain if self.grain else Grain(components=self.output_concepts)
324
+ grain = (
325
+ self.grain
326
+ if self.grain
327
+ else concept_list_to_grain(self.output_concepts, [])
328
+ )
281
329
  source_map = resolve_concept_map(
282
330
  parent_sources,
283
331
  targets=self.output_concepts,
@@ -326,6 +374,7 @@ class StrategyNode:
326
374
  nullable_concepts=list(self.nullable_concepts),
327
375
  depth=self.depth,
328
376
  conditions=self.conditions,
377
+ preexisting_conditions=self.preexisting_conditions,
329
378
  force_group=self.force_group,
330
379
  grain=self.grain,
331
380
  hidden_concepts=list(self.hidden_concepts),
@@ -33,6 +33,7 @@ class FilterNode(StrategyNode):
33
33
  parents: List["StrategyNode"] | None = None,
34
34
  depth: int = 0,
35
35
  conditions: Conditional | Comparison | Parenthetical | None = None,
36
+ preexisting_conditions: Conditional | Comparison | Parenthetical | None = None,
36
37
  partial_concepts: List[Concept] | None = None,
37
38
  force_group: bool | None = False,
38
39
  grain: Grain | None = None,
@@ -47,6 +48,7 @@ class FilterNode(StrategyNode):
47
48
  depth=depth,
48
49
  input_concepts=input_concepts,
49
50
  conditions=conditions,
51
+ preexisting_conditions=preexisting_conditions,
50
52
  partial_concepts=partial_concepts,
51
53
  force_group=force_group,
52
54
  grain=grain,
@@ -63,6 +65,7 @@ class FilterNode(StrategyNode):
63
65
  parents=self.parents,
64
66
  depth=self.depth,
65
67
  conditions=self.conditions,
68
+ preexisting_conditions=self.preexisting_conditions,
66
69
  partial_concepts=list(self.partial_concepts),
67
70
  force_group=self.force_group,
68
71
  grain=self.grain,
@@ -41,7 +41,9 @@ class GroupNode(StrategyNode):
41
41
  nullable_concepts: Optional[List[Concept]] = None,
42
42
  force_group: bool | None = None,
43
43
  conditions: Conditional | Comparison | Parenthetical | None = None,
44
+ preexisting_conditions: Conditional | Comparison | Parenthetical | None = None,
44
45
  existence_concepts: List[Concept] | None = None,
46
+ hidden_concepts: List[Concept] | None = None,
45
47
  ):
46
48
  super().__init__(
47
49
  input_concepts=input_concepts,
@@ -56,6 +58,8 @@ class GroupNode(StrategyNode):
56
58
  force_group=force_group,
57
59
  conditions=conditions,
58
60
  existence_concepts=existence_concepts,
61
+ preexisting_conditions=preexisting_conditions,
62
+ hidden_concepts=hidden_concepts,
59
63
  )
60
64
 
61
65
  def _resolve(self) -> QueryDatasource:
@@ -63,23 +67,18 @@ class GroupNode(StrategyNode):
63
67
  p.resolve() for p in self.parents
64
68
  ]
65
69
 
66
- grain = concept_list_to_grain(self.output_concepts, [])
70
+ grain = self.grain or concept_list_to_grain(self.output_concepts, [])
67
71
  comp_grain = Grain()
68
72
  for source in parent_sources:
69
73
  comp_grain += source.grain
70
74
 
71
75
  # dynamically select if we need to group
72
76
  # because sometimes, we are already at required grain
73
- if (
74
- comp_grain == grain
75
- and self.output_lcl == self.input_lcl
76
- and self.force_group is not True
77
- ):
77
+ if comp_grain == grain and self.force_group is not True:
78
78
  # if there is no group by, and inputs equal outputs
79
79
  # return the parent
80
80
  logger.info(
81
- f"{self.logging_prefix}{LOGGER_PREFIX} Output of group by node equals input of group by node"
82
- f" {self.output_lcl}"
81
+ f"{self.logging_prefix}{LOGGER_PREFIX} Grain of group by equals output"
83
82
  f" grains {comp_grain} and {grain}"
84
83
  )
85
84
  if (
@@ -88,7 +87,7 @@ class GroupNode(StrategyNode):
88
87
  == self.output_lcl
89
88
  ) and isinstance(parent_sources[0], QueryDatasource):
90
89
  logger.info(
91
- f"{self.logging_prefix}{LOGGER_PREFIX} No group by required, returning parent node"
90
+ f"{self.logging_prefix}{LOGGER_PREFIX} No group by required as inputs match outputs of parent; returning parent node"
92
91
  )
93
92
  will_return: QueryDatasource = parent_sources[0]
94
93
  if self.conditions:
@@ -99,21 +98,19 @@ class GroupNode(StrategyNode):
99
98
  else:
100
99
 
101
100
  logger.info(
102
- f"{self.logging_prefix}{LOGGER_PREFIX} Group node has different output than input, forcing group"
103
- f" {self.input_lcl}"
104
- " vs"
105
- f" {self.output_lcl}"
106
- " and"
101
+ f"{self.logging_prefix}{LOGGER_PREFIX} Group node has different grain than parents; forcing group"
107
102
  f" upstream grains {[str(source.grain) for source in parent_sources]}"
108
- " vs"
103
+ f" with final grain {comp_grain} vs"
109
104
  f" target grain {grain}"
110
105
  )
111
- for parent in parent_sources:
106
+ for parent in self.parents:
112
107
  logger.info(
113
108
  f"{self.logging_prefix}{LOGGER_PREFIX} Parent node"
114
- f" {[c.address for c in parent.output_concepts]}"
109
+ f" {[c.address for c in parent.output_concepts[:2]]}... has"
115
110
  " grain"
116
111
  f" {parent.grain}"
112
+ f" resolved grain {parent.resolve().grain}"
113
+ f" {type(parent)}"
117
114
  )
118
115
  source_type = SourceType.GROUP
119
116
  source_map = resolve_concept_map(
@@ -144,6 +141,7 @@ class GroupNode(StrategyNode):
144
141
  grain=grain,
145
142
  partial_concepts=self.partial_concepts,
146
143
  nullable_concepts=nullable_concepts,
144
+ hidden_concepts=self.hidden_concepts,
147
145
  condition=self.conditions,
148
146
  )
149
147
  # if there is a condition on a group node and it's not scalar
@@ -167,6 +165,7 @@ class GroupNode(StrategyNode):
167
165
  nullable_concepts=base.nullable_concepts,
168
166
  partial_concepts=self.partial_concepts,
169
167
  condition=self.conditions,
168
+ hidden_concepts=self.hidden_concepts,
170
169
  )
171
170
  return base
172
171
 
@@ -183,5 +182,7 @@ class GroupNode(StrategyNode):
183
182
  nullable_concepts=list(self.nullable_concepts),
184
183
  force_group=self.force_group,
185
184
  conditions=self.conditions,
185
+ preexisting_conditions=self.preexisting_conditions,
186
186
  existence_concepts=list(self.existence_concepts),
187
+ hidden_concepts=list(self.hidden_concepts),
187
188
  )
@@ -115,6 +115,7 @@ class MergeNode(StrategyNode):
115
115
  depth: int = 0,
116
116
  grain: Grain | None = None,
117
117
  conditions: Conditional | Comparison | Parenthetical | None = None,
118
+ preexisting_conditions: Conditional | Comparison | Parenthetical | None = None,
118
119
  hidden_concepts: List[Concept] | None = None,
119
120
  virtual_output_concepts: List[Concept] | None = None,
120
121
  existence_concepts: List[Concept] | None = None,
@@ -132,6 +133,7 @@ class MergeNode(StrategyNode):
132
133
  force_group=force_group,
133
134
  grain=grain,
134
135
  conditions=conditions,
136
+ preexisting_conditions=preexisting_conditions,
135
137
  hidden_concepts=hidden_concepts,
136
138
  virtual_output_concepts=virtual_output_concepts,
137
139
  existence_concepts=existence_concepts,
@@ -203,14 +205,6 @@ class MergeNode(StrategyNode):
203
205
  logger.info(
204
206
  f"{self.logging_prefix}{LOGGER_PREFIX} Merge node has {len(dataset_list)} parents, starting merge"
205
207
  )
206
- for item in dataset_list:
207
- logger.info(
208
- f"{self.logging_prefix}{LOGGER_PREFIX} for {item.full_name} partial concepts {[x.address for x in item.partial_concepts]}"
209
- )
210
- logger.info(
211
- 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]}"
212
- )
213
-
214
208
  if final_joins is None:
215
209
  if not pregrain.components:
216
210
  logger.info(
@@ -246,7 +240,7 @@ class MergeNode(StrategyNode):
246
240
  for source in parent_sources:
247
241
  if source.full_name in merged:
248
242
  logger.info(
249
- f"{self.logging_prefix}{LOGGER_PREFIX} parent node with {source.full_name} into existing"
243
+ f"{self.logging_prefix}{LOGGER_PREFIX} merging parent node with {source.full_name} into existing"
250
244
  )
251
245
  merged[source.full_name] = merged[source.full_name] + source
252
246
  else:
@@ -304,7 +298,6 @@ class MergeNode(StrategyNode):
304
298
  pregrain += source.grain
305
299
 
306
300
  grain = self.grain if self.grain else pregrain
307
-
308
301
  logger.info(
309
302
  f"{self.logging_prefix}{LOGGER_PREFIX} has pre grain {pregrain} and final merge node grain {grain}"
310
303
  )
@@ -377,6 +370,7 @@ class MergeNode(StrategyNode):
377
370
  force_group=self.force_group,
378
371
  grain=self.grain,
379
372
  conditions=self.conditions,
373
+ preexisting_conditions=self.preexisting_conditions,
380
374
  nullable_concepts=list(self.nullable_concepts),
381
375
  hidden_concepts=list(self.hidden_concepts),
382
376
  virtual_output_concepts=list(self.virtual_output_concepts),
@@ -18,8 +18,7 @@ from trilogy.core.models import (
18
18
  Parenthetical,
19
19
  )
20
20
  from trilogy.utility import unique
21
- from trilogy.core.processing.nodes.base_node import StrategyNode
22
- from trilogy.core.exceptions import NoDatasourceException
21
+ from trilogy.core.processing.nodes.base_node import StrategyNode, resolve_concept_map
23
22
 
24
23
 
25
24
  LOGGER_PREFIX = "[CONCEPT DETAIL - SELECT NODE]"
@@ -48,6 +47,7 @@ class SelectNode(StrategyNode):
48
47
  grain: Optional[Grain] = None,
49
48
  force_group: bool | None = False,
50
49
  conditions: Conditional | Comparison | Parenthetical | None = None,
50
+ preexisting_conditions: Conditional | Comparison | Parenthetical | None = None,
51
51
  hidden_concepts: List[Concept] | None = None,
52
52
  ):
53
53
  super().__init__(
@@ -63,6 +63,7 @@ class SelectNode(StrategyNode):
63
63
  force_group=force_group,
64
64
  grain=grain,
65
65
  conditions=conditions,
66
+ preexisting_conditions=preexisting_conditions,
66
67
  hidden_concepts=hidden_concepts,
67
68
  )
68
69
  self.accept_partial = accept_partial
@@ -144,9 +145,7 @@ class SelectNode(StrategyNode):
144
145
 
145
146
  def _resolve(self) -> QueryDatasource:
146
147
  # if we have parent nodes, we do not need to go to a datasource
147
- if self.parents:
148
- return super()._resolve()
149
- resolution: QueryDatasource | None
148
+ resolution: QueryDatasource | None = None
150
149
  if all(
151
150
  [
152
151
  (
@@ -163,17 +162,30 @@ class SelectNode(StrategyNode):
163
162
  f"{self.logging_prefix}{LOGGER_PREFIX} have a constant datasource"
164
163
  )
165
164
  resolution = self.resolve_from_constant_datasources()
166
- if resolution:
167
- return resolution
168
- if self.datasource:
165
+ if self.datasource and not resolution:
169
166
  resolution = self.resolve_from_provided_datasource()
170
- if resolution:
171
- return resolution
172
167
 
173
- required = [c.address for c in self.all_concepts]
174
- raise NoDatasourceException(
175
- f"Could not find any way to resolve datasources for required concepts {required} with derivation {[x.derivation for x in self.all_concepts]}"
176
- )
168
+ if self.parents:
169
+ if not resolution:
170
+ return super()._resolve()
171
+ # zip in our parent source map
172
+ parent_sources: List[QueryDatasource | Datasource] = [
173
+ p.resolve() for p in self.parents
174
+ ]
175
+
176
+ resolution.datasources += parent_sources
177
+
178
+ source_map = resolve_concept_map(
179
+ parent_sources,
180
+ targets=self.output_concepts,
181
+ inherited_inputs=self.input_concepts + self.existence_concepts,
182
+ )
183
+ for k, v in source_map.items():
184
+ if v and k not in resolution.source_map:
185
+ resolution.source_map[k] = v
186
+ if not resolution:
187
+ raise ValueError("No select node could be generated")
188
+ return resolution
177
189
 
178
190
  def copy(self) -> "SelectNode":
179
191
  return SelectNode(
@@ -191,6 +203,7 @@ class SelectNode(StrategyNode):
191
203
  grain=self.grain,
192
204
  force_group=self.force_group,
193
205
  conditions=self.conditions,
206
+ preexisting_conditions=self.preexisting_conditions,
194
207
  hidden_concepts=self.hidden_concepts,
195
208
  )
196
209
 
@@ -208,5 +221,6 @@ class ConstantNode(SelectNode):
208
221
  depth=self.depth,
209
222
  partial_concepts=list(self.partial_concepts),
210
223
  conditions=self.conditions,
224
+ preexisting_conditions=self.preexisting_conditions,
211
225
  hidden_concepts=self.hidden_concepts,
212
226
  )
@@ -1,7 +1,7 @@
1
1
  from typing import List
2
2
 
3
3
 
4
- from trilogy.core.models import SourceType, Concept, Grain
4
+ from trilogy.core.models import SourceType, Concept
5
5
  from trilogy.core.processing.nodes.base_node import StrategyNode, QueryDatasource
6
6
 
7
7
 
@@ -30,7 +30,6 @@ class WindowNode(StrategyNode):
30
30
 
31
31
  def _resolve(self) -> QueryDatasource:
32
32
  base = super()._resolve()
33
- base.grain = Grain(components=self.input_concepts)
34
33
  return base
35
34
 
36
35
  def copy(self) -> "WindowNode":
@@ -1,4 +1,4 @@
1
- from typing import List, Tuple, Dict, Set
1
+ from typing import List, Tuple, Dict, Set, Any
2
2
  import networkx as nx
3
3
  from trilogy.core.models import (
4
4
  Datasource,
@@ -20,6 +20,14 @@ from trilogy.core.models import (
20
20
  DataType,
21
21
  ConceptPair,
22
22
  UnnestJoin,
23
+ CaseWhen,
24
+ CaseElse,
25
+ MapWrapper,
26
+ ListWrapper,
27
+ MapType,
28
+ DatePart,
29
+ NumericType,
30
+ ListType,
23
31
  )
24
32
 
25
33
  from trilogy.core.enums import Purpose, Granularity, BooleanOperator, Modifier
@@ -107,9 +115,19 @@ def resolve_join_order(joins: List[BaseJoin]) -> List[BaseJoin]:
107
115
  available_aliases: set[str] = set()
108
116
  final_joins_pre = [*joins]
109
117
  final_joins = []
118
+ partial = set()
110
119
  while final_joins_pre:
111
120
  new_final_joins_pre: List[BaseJoin] = []
112
121
  for join in final_joins_pre:
122
+ if join.join_type != JoinType.INNER:
123
+ partial.add(join.right_datasource.identifier)
124
+ # an inner join after a left outer implicitly makes that outer an inner
125
+ # so fix that
126
+ if (
127
+ join.left_datasource.identifier in partial
128
+ and join.join_type == JoinType.INNER
129
+ ):
130
+ join.join_type = JoinType.LEFT_OUTER
113
131
  if not available_aliases:
114
132
  final_joins.append(join)
115
133
  available_aliases.add(join.left_datasource.identifier)
@@ -270,7 +288,7 @@ def get_node_joins(
270
288
  relevant = concept_to_relevant_joins(local_concepts)
271
289
  left_datasource = identifier_map[left]
272
290
  right_datasource = identifier_map[right]
273
- join_tuples = []
291
+ join_tuples: list[ConceptPair] = []
274
292
  for joinc in relevant:
275
293
  left_arg = joinc
276
294
  right_arg = joinc
@@ -316,6 +334,19 @@ def get_node_joins(
316
334
  left=left_arg, right=right_arg, modifiers=list(modifiers)
317
335
  )
318
336
  )
337
+
338
+ # deduplication
339
+ all_right = []
340
+ for tuple in join_tuples:
341
+ all_right.append(tuple.right.address)
342
+ right_grain = identifier_map[right].grain
343
+ # if the join includes all the right grain components
344
+ # we only need to join on those, not everything
345
+ if all([x.address in all_right for x in right_grain.components]):
346
+ join_tuples = [
347
+ x for x in join_tuples if x.right.address in right_grain.components
348
+ ]
349
+
319
350
  final_joins_pre.append(
320
351
  BaseJoin(
321
352
  left_datasource=identifier_map[left],
@@ -373,7 +404,7 @@ def is_scalar_condition(
373
404
  int
374
405
  | str
375
406
  | float
376
- | list
407
+ | list[Any]
377
408
  | WindowItem
378
409
  | FilterItem
379
410
  | Concept
@@ -384,6 +415,14 @@ def is_scalar_condition(
384
415
  | AggregateWrapper
385
416
  | MagicConstants
386
417
  | DataType
418
+ | CaseWhen
419
+ | CaseElse
420
+ | MapWrapper[Any, Any]
421
+ | ListType
422
+ | MapType
423
+ | NumericType
424
+ | DatePart
425
+ | ListWrapper[Any]
387
426
  ),
388
427
  materialized: set[str] | None = None,
389
428
  ) -> bool:
@@ -398,6 +437,7 @@ def is_scalar_condition(
398
437
  elif isinstance(element, Function):
399
438
  if element.operator in FunctionClass.AGGREGATE_FUNCTIONS.value:
400
439
  return False
440
+ return all([is_scalar_condition(x, materialized) for x in element.arguments])
401
441
  elif isinstance(element, Concept):
402
442
  if materialized and element.address in materialized:
403
443
  return True
@@ -410,6 +450,14 @@ def is_scalar_condition(
410
450
  return is_scalar_condition(element.left, materialized) and is_scalar_condition(
411
451
  element.right, materialized
412
452
  )
453
+ elif isinstance(element, CaseWhen):
454
+ return is_scalar_condition(
455
+ element.comparison, materialized
456
+ ) and is_scalar_condition(element.expr, materialized)
457
+ elif isinstance(element, CaseElse):
458
+ return is_scalar_condition(element.expr, materialized)
459
+ elif isinstance(element, MagicConstants):
460
+ return True
413
461
  return True
414
462
 
415
463