pytrilogy 0.0.2.12__py3-none-any.whl → 0.0.2.14__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 (31) hide show
  1. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/METADATA +1 -1
  2. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/RECORD +31 -31
  3. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/WHEEL +1 -1
  4. trilogy/__init__.py +1 -1
  5. trilogy/constants.py +16 -1
  6. trilogy/core/enums.py +3 -0
  7. trilogy/core/models.py +150 -17
  8. trilogy/core/optimizations/predicate_pushdown.py +1 -1
  9. trilogy/core/processing/node_generators/basic_node.py +8 -1
  10. trilogy/core/processing/node_generators/common.py +13 -36
  11. trilogy/core/processing/node_generators/filter_node.py +1 -15
  12. trilogy/core/processing/node_generators/group_node.py +19 -1
  13. trilogy/core/processing/node_generators/group_to_node.py +0 -12
  14. trilogy/core/processing/node_generators/multiselect_node.py +1 -10
  15. trilogy/core/processing/node_generators/rowset_node.py +3 -14
  16. trilogy/core/processing/node_generators/select_node.py +26 -0
  17. trilogy/core/processing/node_generators/window_node.py +1 -1
  18. trilogy/core/processing/nodes/base_node.py +40 -11
  19. trilogy/core/processing/nodes/group_node.py +31 -18
  20. trilogy/core/processing/nodes/merge_node.py +14 -5
  21. trilogy/core/processing/nodes/select_node_v2.py +4 -0
  22. trilogy/core/processing/utility.py +91 -3
  23. trilogy/core/query_processor.py +6 -12
  24. trilogy/dialect/common.py +10 -8
  25. trilogy/executor.py +8 -2
  26. trilogy/parsing/common.py +34 -4
  27. trilogy/parsing/parse_engine.py +31 -19
  28. trilogy/parsing/trilogy.lark +5 -5
  29. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/LICENSE.md +0 -0
  30. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/entry_points.txt +0 -0
  31. {pytrilogy-0.0.2.12.dist-info → pytrilogy-0.0.2.14.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- from typing import List, Tuple
1
+ from typing import List, Tuple, Callable
2
2
 
3
3
 
4
4
  from trilogy.core.enums import PurposeLineage, Purpose
@@ -15,12 +15,10 @@ from trilogy.utility import unique
15
15
  from trilogy.core.processing.nodes.base_node import StrategyNode
16
16
  from trilogy.core.processing.nodes.merge_node import MergeNode
17
17
  from trilogy.core.processing.nodes import History
18
- from trilogy.core.enums import JoinType
19
18
  from trilogy.core.processing.nodes import (
20
19
  NodeJoin,
21
20
  )
22
21
  from collections import defaultdict
23
- from trilogy.core.processing.utility import concept_to_relevant_joins
24
22
 
25
23
 
26
24
  def resolve_function_parent_concepts(concept: Concept) -> List[Concept]:
@@ -96,6 +94,7 @@ def gen_property_enrichment_node(
96
94
  g,
97
95
  depth: int,
98
96
  source_concepts,
97
+ log_lambda: Callable,
99
98
  history: History | None = None,
100
99
  conditions: WhereClause | None = None,
101
100
  ):
@@ -106,8 +105,8 @@ def gen_property_enrichment_node(
106
105
  keys = "-".join([y.address for y in x.keys])
107
106
  required_keys[keys].add(x.address)
108
107
  final_nodes = []
109
- node_joins = []
110
108
  for _k, vs in required_keys.items():
109
+ log_lambda(f"Generating enrichment node for {_k} with {vs}")
111
110
  ks = _k.split("-")
112
111
  enrich_node: StrategyNode = source_concepts(
113
112
  mandatory_list=[environment.concepts[k] for k in ks]
@@ -119,17 +118,6 @@ def gen_property_enrichment_node(
119
118
  conditions=conditions,
120
119
  )
121
120
  final_nodes.append(enrich_node)
122
- node_joins.append(
123
- NodeJoin(
124
- left_node=enrich_node,
125
- right_node=base_node,
126
- concepts=concept_to_relevant_joins(
127
- [environment.concepts[k] for k in ks]
128
- ),
129
- filter_to_mutual=False,
130
- join_type=JoinType.LEFT_OUTER,
131
- )
132
- )
133
121
  return MergeNode(
134
122
  input_concepts=unique(
135
123
  base_node.output_concepts
@@ -146,9 +134,8 @@ def gen_property_enrichment_node(
146
134
  g=g,
147
135
  parents=[
148
136
  base_node,
149
- enrich_node,
150
- ],
151
- node_joins=node_joins,
137
+ ]
138
+ + final_nodes,
152
139
  )
153
140
 
154
141
 
@@ -197,6 +184,7 @@ def gen_enrichment_node(
197
184
  source_concepts,
198
185
  history=history,
199
186
  conditions=conditions,
187
+ log_lambda=log_lambda,
200
188
  )
201
189
 
202
190
  enrich_node: StrategyNode = source_concepts( # this fetches the parent + join keys
@@ -216,29 +204,18 @@ def gen_enrichment_node(
216
204
  log_lambda(
217
205
  f"{str(type(base_node).__name__)} returning merge node with group node + enrichment node"
218
206
  )
219
-
207
+ non_hidden = [
208
+ x
209
+ for x in base_node.output_concepts
210
+ if x.address not in [y.address for y in base_node.hidden_concepts]
211
+ ]
220
212
  return MergeNode(
221
- input_concepts=unique(
222
- join_keys + extra_required + base_node.output_concepts, "address"
223
- ),
224
- output_concepts=unique(
225
- join_keys + extra_required + base_node.output_concepts, "address"
226
- ),
213
+ input_concepts=unique(join_keys + extra_required + non_hidden, "address"),
214
+ output_concepts=unique(join_keys + extra_required + non_hidden, "address"),
227
215
  environment=environment,
228
216
  g=g,
229
217
  parents=[enrich_node, base_node],
230
218
  force_group=False,
231
- node_joins=[
232
- NodeJoin(
233
- left_node=enrich_node,
234
- right_node=base_node,
235
- concepts=concept_to_relevant_joins(
236
- [x for x in join_keys if x in enrich_node.output_lcl]
237
- ),
238
- filter_to_mutual=False,
239
- join_type=JoinType.LEFT_OUTER,
240
- )
241
- ],
242
219
  )
243
220
 
244
221
 
@@ -1,12 +1,10 @@
1
1
  from typing import List
2
2
 
3
3
 
4
- from trilogy.core.enums import JoinType
5
4
  from trilogy.core.models import Concept, Environment, FilterItem, Grain, WhereClause
6
5
  from trilogy.core.processing.nodes import (
7
6
  FilterNode,
8
7
  MergeNode,
9
- NodeJoin,
10
8
  History,
11
9
  StrategyNode,
12
10
  SelectNode,
@@ -16,7 +14,6 @@ from trilogy.core.processing.node_generators.common import (
16
14
  )
17
15
  from trilogy.constants import logger
18
16
  from trilogy.core.processing.utility import padding, unique
19
- from trilogy.core.processing.node_generators.common import concept_to_relevant_joins
20
17
  from trilogy.core.processing.utility import is_scalar_condition
21
18
 
22
19
  LOGGER_PREFIX = "[GEN_FILTER_NODE]"
@@ -215,16 +212,5 @@ def gen_filter_node(
215
212
  # this node fetches only what we need to filter
216
213
  filter_node,
217
214
  enrich_node,
218
- ],
219
- node_joins=[
220
- NodeJoin(
221
- left_node=enrich_node,
222
- right_node=filter_node,
223
- concepts=concept_to_relevant_joins(
224
- [immediate_parent] + parent_row_concepts
225
- ),
226
- join_type=JoinType.LEFT_OUTER,
227
- filter_to_mutual=True,
228
- )
229
- ],
215
+ ]
230
216
  )
@@ -1,4 +1,11 @@
1
- from trilogy.core.models import Concept, Environment, LooseConceptList, WhereClause
1
+ from trilogy.core.models import (
2
+ Concept,
3
+ Environment,
4
+ LooseConceptList,
5
+ WhereClause,
6
+ Function,
7
+ AggregateWrapper,
8
+ )
2
9
  from trilogy.utility import unique
3
10
  from trilogy.core.processing.nodes import GroupNode, StrategyNode, History
4
11
  from typing import List
@@ -42,6 +49,17 @@ def gen_group_node(
42
49
  )
43
50
  parent_concepts += grain_components
44
51
  output_concepts += grain_components
52
+ for possible_agg in local_optional:
53
+ if possible_agg.grain and possible_agg.grain == concept.grain:
54
+ if not isinstance(possible_agg.lineage, (AggregateWrapper, Function)):
55
+ continue
56
+ agg_parents: List[Concept] = resolve_function_parent_concepts(
57
+ possible_agg
58
+ )
59
+ if set([x.address for x in agg_parents]).issubset(
60
+ set([x.address for x in parent_concepts])
61
+ ):
62
+ output_concepts.append(possible_agg)
45
63
 
46
64
  if parent_concepts:
47
65
  logger.info(
@@ -3,15 +3,12 @@ from trilogy.core.processing.nodes import (
3
3
  GroupNode,
4
4
  StrategyNode,
5
5
  MergeNode,
6
- NodeJoin,
7
6
  History,
8
7
  )
9
8
  from typing import List
10
- from trilogy.core.enums import JoinType
11
9
 
12
10
  from trilogy.constants import logger
13
11
  from trilogy.core.processing.utility import padding
14
- from trilogy.core.processing.node_generators.common import concept_to_relevant_joins
15
12
 
16
13
  LOGGER_PREFIX = "[GEN_GROUP_TO_NODE]"
17
14
 
@@ -84,15 +81,6 @@ def gen_group_to_node(
84
81
  # this node gets enrichment
85
82
  enrich_node,
86
83
  ],
87
- node_joins=[
88
- NodeJoin(
89
- left_node=group_node,
90
- right_node=enrich_node,
91
- concepts=concept_to_relevant_joins(parent_concepts),
92
- filter_to_mutual=False,
93
- join_type=JoinType.LEFT_OUTER,
94
- )
95
- ],
96
84
  whole_grain=True,
97
85
  depth=depth,
98
86
  )
@@ -10,7 +10,7 @@ from typing import List
10
10
  from trilogy.core.enums import JoinType
11
11
  from trilogy.constants import logger
12
12
  from trilogy.core.processing.utility import padding
13
- from trilogy.core.processing.node_generators.common import concept_to_relevant_joins
13
+ from trilogy.core.processing.utility import concept_to_relevant_joins
14
14
  from collections import defaultdict
15
15
  from itertools import combinations
16
16
  from trilogy.core.enums import Purpose
@@ -176,14 +176,5 @@ def gen_multiselect_node(
176
176
  # this node gets enrichment
177
177
  enrich_node,
178
178
  ],
179
- node_joins=[
180
- NodeJoin(
181
- left_node=enrich_node,
182
- right_node=node,
183
- concepts=possible_joins,
184
- filter_to_mutual=False,
185
- join_type=JoinType.LEFT_OUTER,
186
- )
187
- ],
188
179
  partial_concepts=node.partial_concepts,
189
180
  )
@@ -6,14 +6,14 @@ from trilogy.core.models import (
6
6
  RowsetItem,
7
7
  MultiSelectStatement,
8
8
  )
9
- from trilogy.core.processing.nodes import MergeNode, NodeJoin, History, StrategyNode
9
+ from trilogy.core.processing.nodes import MergeNode, History, StrategyNode
10
10
  from trilogy.core.processing.nodes.base_node import concept_list_to_grain
11
11
  from typing import List
12
12
 
13
- from trilogy.core.enums import JoinType, PurposeLineage
13
+ from trilogy.core.enums import PurposeLineage
14
14
  from trilogy.constants import logger
15
15
  from trilogy.core.processing.utility import padding
16
- from trilogy.core.processing.node_generators.common import concept_to_relevant_joins
16
+ from trilogy.core.processing.utility import concept_to_relevant_joins
17
17
 
18
18
 
19
19
  LOGGER_PREFIX = "[GEN_ROWSET_NODE]"
@@ -113,19 +113,8 @@ def gen_rowset_node(
113
113
  g=g,
114
114
  depth=depth,
115
115
  parents=[
116
- # this node gets the window
117
116
  node,
118
- # this node gets enrichment
119
117
  enrich_node,
120
118
  ],
121
- node_joins=[
122
- NodeJoin(
123
- left_node=enrich_node,
124
- right_node=node,
125
- concepts=concept_to_relevant_joins(additional_relevant),
126
- filter_to_mutual=False,
127
- join_type=JoinType.LEFT_OUTER,
128
- )
129
- ],
130
119
  partial_concepts=node.partial_concepts,
131
120
  )
@@ -32,6 +32,7 @@ class DatasourceMatch:
32
32
  datasource: Datasource
33
33
  matched: LooseConceptList
34
34
  partial: LooseConceptList
35
+ nullable: LooseConceptList
35
36
 
36
37
  def __repr__(self):
37
38
  return f"DatasourceMatch({self.key}, {self.datasource.identifier}, {str(self.matched)}, {str(self.partial)})"
@@ -76,6 +77,7 @@ def dm_to_strategy_node(
76
77
  parents=[],
77
78
  depth=depth,
78
79
  partial_concepts=dm.partial.concepts,
80
+ nullable_concepts=dm.nullable.concepts,
79
81
  accept_partial=accept_partial,
80
82
  datasource=datasource,
81
83
  grain=datasource.grain,
@@ -189,6 +191,13 @@ def gen_select_nodes_from_tables_v2(
189
191
  if not c.is_complete and c.concept.address in all_lcl
190
192
  ]
191
193
  ),
194
+ nullable=LooseConceptList(
195
+ concepts=[
196
+ c.concept
197
+ for c in datasource.columns
198
+ if c.is_nullable and c.concept in all_lcl
199
+ ]
200
+ ),
192
201
  )
193
202
  if not matched:
194
203
  continue
@@ -318,6 +327,12 @@ def gen_select_node_from_table(
318
327
  if not c.is_complete and c.concept in all_lcl
319
328
  ]
320
329
  partial_lcl = LooseConceptList(concepts=partial_concepts)
330
+ nullable_concepts = [
331
+ c.concept
332
+ for c in datasource.columns
333
+ if c.is_nullable and c.concept in all_lcl
334
+ ]
335
+ nullable_lcl = LooseConceptList(concepts=nullable_concepts)
321
336
  if not accept_partial and target_concept in partial_lcl:
322
337
  continue
323
338
  logger.info(
@@ -359,6 +374,7 @@ def gen_select_node_from_table(
359
374
  parents=[],
360
375
  depth=depth,
361
376
  partial_concepts=[c for c in all_concepts if c in partial_lcl],
377
+ nullable_concepts=[c for c in all_concepts if c in nullable_lcl],
362
378
  accept_partial=accept_partial,
363
379
  datasource=datasource,
364
380
  grain=Grain(components=all_concepts),
@@ -377,6 +393,7 @@ def gen_select_node_from_table(
377
393
  parents=[bcandidate],
378
394
  depth=depth,
379
395
  partial_concepts=bcandidate.partial_concepts,
396
+ nullable_concepts=bcandidate.nullable_concepts,
380
397
  )
381
398
  else:
382
399
  candidate = bcandidate
@@ -469,6 +486,14 @@ def gen_select_node(
469
486
  )
470
487
  ]
471
488
 
489
+ all_nullable = [
490
+ c
491
+ for c in all_concepts
492
+ if any(
493
+ [c.address in [x.address for x in p.nullable_concepts] for p in parents]
494
+ )
495
+ ]
496
+
472
497
  if all_found:
473
498
  logger.info(
474
499
  f"{padding(depth)}{LOGGER_PREFIX} found all optional {[c.address for c in local_optional]} via joins"
@@ -494,6 +519,7 @@ def gen_select_node(
494
519
  parents=parents,
495
520
  depth=depth,
496
521
  partial_concepts=all_partial,
522
+ nullable_concepts=all_nullable,
497
523
  grain=inferred_grain,
498
524
  )
499
525
 
@@ -12,8 +12,8 @@ from trilogy.constants import logger
12
12
  from trilogy.core.processing.utility import padding, create_log_lambda
13
13
  from trilogy.core.processing.node_generators.common import (
14
14
  gen_enrichment_node,
15
- concept_to_relevant_joins,
16
15
  )
16
+ from trilogy.core.processing.utility import concept_to_relevant_joins
17
17
 
18
18
  LOGGER_PREFIX = "[GEN_WINDOW_NODE]"
19
19
 
@@ -13,6 +13,7 @@ from trilogy.core.models import (
13
13
  Comparison,
14
14
  Parenthetical,
15
15
  LooseConceptList,
16
+ ConceptPair,
16
17
  )
17
18
  from trilogy.core.enums import Purpose, JoinType, PurposeLineage, Granularity
18
19
  from trilogy.utility import unique
@@ -61,11 +62,9 @@ def resolve_concept_map(
61
62
  for concept in input.output_concepts:
62
63
  if concept.address not in input.non_partial_concept_addresses:
63
64
  continue
64
-
65
- if (
66
- isinstance(input, QueryDatasource)
67
- and concept.address in input.hidden_concepts
68
- ):
65
+ if isinstance(input, QueryDatasource) and concept.address in [
66
+ x.address for x in input.hidden_concepts
67
+ ]:
69
68
  continue
70
69
  if concept.address in full_addresses:
71
70
 
@@ -82,10 +81,9 @@ def resolve_concept_map(
82
81
  for concept in input.output_concepts:
83
82
  if concept.address not in [t.address for t in inherited_inputs]:
84
83
  continue
85
- if (
86
- isinstance(input, QueryDatasource)
87
- and concept.address in input.hidden_concepts
88
- ):
84
+ if isinstance(input, QueryDatasource) and concept.address in [
85
+ x.address for x in input.hidden_concepts
86
+ ]:
89
87
  continue
90
88
  if len(concept_map.get(concept.address, [])) == 0:
91
89
  concept_map[concept.address].add(input)
@@ -94,6 +92,7 @@ def resolve_concept_map(
94
92
  if target.address not in inherited:
95
93
  # an empty source means it is defined in this CTE
96
94
  concept_map[target.address] = set()
95
+
97
96
  return concept_map
98
97
 
99
98
 
@@ -124,6 +123,26 @@ def get_all_parent_partial(
124
123
  )
125
124
 
126
125
 
126
+ def get_all_parent_nullable(
127
+ all_concepts: List[Concept], parents: List["StrategyNode"]
128
+ ) -> List[Concept]:
129
+ return unique(
130
+ [
131
+ c
132
+ for c in all_concepts
133
+ if len(
134
+ [
135
+ p
136
+ for p in parents
137
+ if c.address in [x.address for x in p.nullable_concepts]
138
+ ]
139
+ )
140
+ >= 1
141
+ ],
142
+ "address",
143
+ )
144
+
145
+
127
146
  class StrategyNode:
128
147
  source_type = SourceType.ABSTRACT
129
148
 
@@ -136,6 +155,7 @@ class StrategyNode:
136
155
  whole_grain: bool = False,
137
156
  parents: List["StrategyNode"] | None = None,
138
157
  partial_concepts: List[Concept] | None = None,
158
+ nullable_concepts: List[Concept] | None = None,
139
159
  depth: int = 0,
140
160
  conditions: Conditional | Comparison | Parenthetical | None = None,
141
161
  force_group: bool | None = None,
@@ -159,6 +179,9 @@ class StrategyNode:
159
179
  self.partial_concepts = partial_concepts or get_all_parent_partial(
160
180
  self.output_concepts, self.parents
161
181
  )
182
+ self.nullable_concepts = nullable_concepts or get_all_parent_nullable(
183
+ self.output_concepts, self.parents
184
+ )
162
185
 
163
186
  self.depth = depth
164
187
  self.conditions = conditions
@@ -226,7 +249,10 @@ class StrategyNode:
226
249
  def remove_output_concepts(self, concepts: List[Concept]):
227
250
  for x in concepts:
228
251
  self.hidden_concepts.append(x)
229
- self.output_concepts = [x for x in self.output_concepts if x not in concepts]
252
+ addresses = [x.address for x in concepts]
253
+ self.output_concepts = [
254
+ x for x in self.output_concepts if x.address not in addresses
255
+ ]
230
256
  self.rebuild_cache()
231
257
 
232
258
  @property
@@ -257,6 +283,7 @@ class StrategyNode:
257
283
  targets=self.output_concepts,
258
284
  inherited_inputs=self.input_concepts + self.existence_concepts,
259
285
  )
286
+
260
287
  return QueryDatasource(
261
288
  input_concepts=self.input_concepts,
262
289
  output_concepts=self.output_concepts,
@@ -267,6 +294,7 @@ class StrategyNode:
267
294
  grain=grain,
268
295
  condition=self.conditions,
269
296
  partial_concepts=self.partial_concepts,
297
+ nullable_concepts=self.nullable_concepts,
270
298
  force_group=self.force_group,
271
299
  hidden_concepts=self.hidden_concepts,
272
300
  )
@@ -295,6 +323,7 @@ class StrategyNode:
295
323
  whole_grain=self.whole_grain,
296
324
  parents=list(self.parents),
297
325
  partial_concepts=list(self.partial_concepts),
326
+ nullable_concepts=list(self.nullable_concepts),
298
327
  depth=self.depth,
299
328
  conditions=self.conditions,
300
329
  force_group=self.force_group,
@@ -312,7 +341,7 @@ class NodeJoin:
312
341
  concepts: List[Concept]
313
342
  join_type: JoinType
314
343
  filter_to_mutual: bool = False
315
- concept_pairs: list[tuple[Concept, Concept]] | None = None
344
+ concept_pairs: list[ConceptPair] | None = None
316
345
 
317
346
  def __post_init__(self):
318
347
  if self.concept_pairs:
@@ -20,6 +20,7 @@ from trilogy.core.processing.nodes.base_node import (
20
20
  )
21
21
  from trilogy.utility import unique
22
22
  from trilogy.core.processing.utility import is_scalar_condition
23
+ from trilogy.core.processing.utility import find_nullable_concepts
23
24
 
24
25
  LOGGER_PREFIX = "[CONCEPT DETAIL - GROUP NODE]"
25
26
 
@@ -37,6 +38,7 @@ class GroupNode(StrategyNode):
37
38
  parents: List["StrategyNode"] | None = None,
38
39
  depth: int = 0,
39
40
  partial_concepts: Optional[List[Concept]] = None,
41
+ nullable_concepts: Optional[List[Concept]] = None,
40
42
  force_group: bool | None = None,
41
43
  conditions: Conditional | Comparison | Parenthetical | None = None,
42
44
  existence_concepts: List[Concept] | None = None,
@@ -50,6 +52,7 @@ class GroupNode(StrategyNode):
50
52
  parents=parents,
51
53
  depth=depth,
52
54
  partial_concepts=partial_concepts,
55
+ nullable_concepts=nullable_concepts,
53
56
  force_group=force_group,
54
57
  conditions=conditions,
55
58
  existence_concepts=existence_concepts,
@@ -113,27 +116,34 @@ class GroupNode(StrategyNode):
113
116
  f" {parent.grain}"
114
117
  )
115
118
  source_type = SourceType.GROUP
116
-
119
+ source_map = resolve_concept_map(
120
+ parent_sources,
121
+ targets=(
122
+ unique(
123
+ self.output_concepts + self.conditions.concept_arguments,
124
+ "address",
125
+ )
126
+ if self.conditions
127
+ else self.output_concepts
128
+ ),
129
+ inherited_inputs=self.input_concepts + self.existence_concepts,
130
+ )
131
+ nullable_addresses = find_nullable_concepts(
132
+ source_map=source_map, joins=[], datasources=parent_sources
133
+ )
134
+ nullable_concepts = [
135
+ x for x in self.output_concepts if x.address in nullable_addresses
136
+ ]
117
137
  base = QueryDatasource(
118
138
  input_concepts=self.input_concepts,
119
139
  output_concepts=self.output_concepts,
120
140
  datasources=parent_sources,
121
141
  source_type=source_type,
122
- source_map=resolve_concept_map(
123
- parent_sources,
124
- targets=(
125
- unique(
126
- self.output_concepts + self.conditions.concept_arguments,
127
- "address",
128
- )
129
- if self.conditions
130
- else self.output_concepts
131
- ),
132
- inherited_inputs=self.input_concepts + self.existence_concepts,
133
- ),
142
+ source_map=source_map,
134
143
  joins=[],
135
144
  grain=grain,
136
145
  partial_concepts=self.partial_concepts,
146
+ nullable_concepts=nullable_concepts,
137
147
  condition=self.conditions,
138
148
  )
139
149
  # if there is a condition on a group node and it's not scalar
@@ -141,18 +151,20 @@ class GroupNode(StrategyNode):
141
151
  if self.conditions and not is_scalar_condition(self.conditions):
142
152
  base.condition = None
143
153
  base.output_concepts = self.output_concepts + self.conditions.row_arguments
154
+ source_map = resolve_concept_map(
155
+ [base],
156
+ targets=self.output_concepts,
157
+ inherited_inputs=base.output_concepts,
158
+ )
144
159
  return QueryDatasource(
145
160
  input_concepts=base.output_concepts,
146
161
  output_concepts=self.output_concepts,
147
162
  datasources=[base],
148
163
  source_type=SourceType.SELECT,
149
- source_map=resolve_concept_map(
150
- [base],
151
- targets=self.output_concepts,
152
- inherited_inputs=base.output_concepts,
153
- ),
164
+ source_map=source_map,
154
165
  joins=[],
155
166
  grain=grain,
167
+ nullable_concepts=base.nullable_concepts,
156
168
  partial_concepts=self.partial_concepts,
157
169
  condition=self.conditions,
158
170
  )
@@ -168,6 +180,7 @@ class GroupNode(StrategyNode):
168
180
  parents=self.parents,
169
181
  depth=self.depth,
170
182
  partial_concepts=list(self.partial_concepts),
183
+ nullable_concepts=list(self.nullable_concepts),
171
184
  force_group=self.force_group,
172
185
  conditions=self.conditions,
173
186
  existence_concepts=list(self.existence_concepts),
@@ -22,7 +22,7 @@ from trilogy.core.processing.nodes.base_node import (
22
22
  resolve_concept_map,
23
23
  NodeJoin,
24
24
  )
25
- from trilogy.core.processing.utility import get_node_joins
25
+ from trilogy.core.processing.utility import get_node_joins, find_nullable_concepts
26
26
 
27
27
  LOGGER_PREFIX = "[CONCEPT DETAIL - MERGE NODE]"
28
28
 
@@ -110,6 +110,7 @@ class MergeNode(StrategyNode):
110
110
  join_concepts: Optional[List] = None,
111
111
  force_join_type: Optional[JoinType] = None,
112
112
  partial_concepts: Optional[List[Concept]] = None,
113
+ nullable_concepts: Optional[List[Concept]] = None,
113
114
  force_group: bool | None = None,
114
115
  depth: int = 0,
115
116
  grain: Grain | None = None,
@@ -127,6 +128,7 @@ class MergeNode(StrategyNode):
127
128
  parents=parents,
128
129
  depth=depth,
129
130
  partial_concepts=partial_concepts,
131
+ nullable_concepts=nullable_concepts,
130
132
  force_group=force_group,
131
133
  grain=grain,
132
134
  conditions=conditions,
@@ -192,7 +194,7 @@ class MergeNode(StrategyNode):
192
194
  pregrain: Grain,
193
195
  grain: Grain,
194
196
  environment: Environment,
195
- ) -> List[BaseJoin]:
197
+ ) -> List[BaseJoin | UnnestJoin]:
196
198
  # only finally, join between them for unique values
197
199
  dataset_list: List[QueryDatasource] = sorted(
198
200
  final_datasets, key=lambda x: -len(x.grain.components_copy)
@@ -308,7 +310,7 @@ class MergeNode(StrategyNode):
308
310
  )
309
311
  join_candidates = [x for x in final_datasets if x not in existence_final]
310
312
  if len(join_candidates) > 1:
311
- joins = self.generate_joins(
313
+ joins: List[BaseJoin | UnnestJoin] = self.generate_joins(
312
314
  join_candidates, final_joins, pregrain, grain, self.environment
313
315
  )
314
316
  else:
@@ -318,7 +320,7 @@ class MergeNode(StrategyNode):
318
320
  )
319
321
  full_join_concepts = []
320
322
  for join in joins:
321
- if join.join_type == JoinType.FULL:
323
+ if isinstance(join, BaseJoin) and join.join_type == JoinType.FULL:
322
324
  full_join_concepts += join.concepts
323
325
  if self.whole_grain:
324
326
  force_group = False
@@ -341,6 +343,9 @@ class MergeNode(StrategyNode):
341
343
  inherited_inputs=self.input_concepts + self.existence_concepts,
342
344
  full_joins=full_join_concepts,
343
345
  )
346
+ nullable_concepts = find_nullable_concepts(
347
+ source_map=source_map, joins=joins, datasources=final_datasets
348
+ )
344
349
  qds = QueryDatasource(
345
350
  input_concepts=unique(self.input_concepts, "address"),
346
351
  output_concepts=unique(self.output_concepts, "address"),
@@ -349,6 +354,9 @@ class MergeNode(StrategyNode):
349
354
  source_map=source_map,
350
355
  joins=qd_joins,
351
356
  grain=grain,
357
+ nullable_concepts=[
358
+ x for x in self.output_concepts if x.address in nullable_concepts
359
+ ],
352
360
  partial_concepts=self.partial_concepts,
353
361
  force_group=force_group,
354
362
  condition=self.conditions,
@@ -369,9 +377,10 @@ class MergeNode(StrategyNode):
369
377
  force_group=self.force_group,
370
378
  grain=self.grain,
371
379
  conditions=self.conditions,
380
+ nullable_concepts=list(self.nullable_concepts),
372
381
  hidden_concepts=list(self.hidden_concepts),
373
382
  virtual_output_concepts=list(self.virtual_output_concepts),
374
- node_joins=self.node_joins,
383
+ node_joins=list(self.node_joins) if self.node_joins else None,
375
384
  join_concepts=list(self.join_concepts) if self.join_concepts else None,
376
385
  force_join_type=self.force_join_type,
377
386
  existence_concepts=list(self.existence_concepts),