pytrilogy 0.0.3.113__py3-none-any.whl → 0.0.3.116__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 (30) hide show
  1. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/METADATA +1 -1
  2. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/RECORD +30 -30
  3. trilogy/__init__.py +1 -1
  4. trilogy/constants.py +29 -0
  5. trilogy/core/enums.py +6 -1
  6. trilogy/core/functions.py +33 -0
  7. trilogy/core/models/author.py +126 -2
  8. trilogy/core/models/build.py +70 -7
  9. trilogy/core/models/environment.py +2 -1
  10. trilogy/core/optimization.py +3 -2
  11. trilogy/core/optimizations/hide_unused_concept.py +1 -5
  12. trilogy/core/processing/concept_strategies_v3.py +26 -5
  13. trilogy/core/processing/discovery_node_factory.py +2 -2
  14. trilogy/core/processing/discovery_utility.py +11 -4
  15. trilogy/core/processing/node_generators/basic_node.py +26 -15
  16. trilogy/core/processing/node_generators/common.py +4 -1
  17. trilogy/core/processing/node_generators/filter_node.py +7 -0
  18. trilogy/core/processing/node_generators/multiselect_node.py +3 -3
  19. trilogy/core/processing/node_generators/unnest_node.py +77 -6
  20. trilogy/core/statements/author.py +4 -1
  21. trilogy/dialect/base.py +42 -2
  22. trilogy/executor.py +1 -1
  23. trilogy/parsing/common.py +117 -20
  24. trilogy/parsing/parse_engine.py +115 -5
  25. trilogy/parsing/render.py +2 -1
  26. trilogy/parsing/trilogy.lark +20 -7
  27. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/WHEEL +0 -0
  28. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/entry_points.txt +0 -0
  29. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/licenses/LICENSE.md +0 -0
  30. {pytrilogy-0.0.3.113.dist-info → pytrilogy-0.0.3.116.dist-info}/top_level.txt +0 -0
@@ -48,6 +48,8 @@ from trilogy.core.models.author import (
48
48
  Concept,
49
49
  ConceptRef,
50
50
  Conditional,
51
+ DeriveClause,
52
+ DeriveItem,
51
53
  FilterItem,
52
54
  FuncArgs,
53
55
  Function,
@@ -134,8 +136,9 @@ def concept_is_relevant(
134
136
  if concept.purpose in (Purpose.METRIC,):
135
137
  if all([c in others for c in concept.grain.components]):
136
138
  return False
139
+ if concept.derivation in (Derivation.UNNEST,):
140
+ return True
137
141
  if concept.derivation in (Derivation.BASIC,):
138
-
139
142
  return any(concept_is_relevant(c, others) for c in concept.concept_arguments)
140
143
  if concept.granularity == Granularity.SINGLE_ROW:
141
144
  return False
@@ -246,7 +249,7 @@ class BuildParamaterizedConceptReference(DataTyped):
246
249
  concept: BuildConcept
247
250
 
248
251
  def __str__(self):
249
- return f":{self.concept.address}"
252
+ return f":{self.concept.address.replace('.', '_')}"
250
253
 
251
254
  @property
252
255
  def safe_address(self) -> str:
@@ -1267,6 +1270,22 @@ class BuildAlignClause:
1267
1270
  items: List[BuildAlignItem]
1268
1271
 
1269
1272
 
1273
+ @dataclass
1274
+ class BuildDeriveClause:
1275
+ items: List[BuildDeriveItem]
1276
+
1277
+
1278
+ @dataclass
1279
+ class BuildDeriveItem:
1280
+ expr: BuildExpr
1281
+ name: str
1282
+ namespace: str = field(default=DEFAULT_NAMESPACE)
1283
+
1284
+ @property
1285
+ def address(self) -> str:
1286
+ return f"{self.namespace}.{self.name}"
1287
+
1288
+
1270
1289
  @dataclass
1271
1290
  class BuildSelectLineage:
1272
1291
  selection: List[BuildConcept]
@@ -1298,12 +1317,16 @@ class BuildMultiSelectLineage(BuildConceptArgs):
1298
1317
  limit: Optional[int] = None
1299
1318
  where_clause: Union["BuildWhereClause", None] = field(default=None)
1300
1319
  having_clause: Union["BuildHavingClause", None] = field(default=None)
1320
+ derive: BuildDeriveClause | None = None
1301
1321
 
1302
1322
  @property
1303
1323
  def derived_concepts(self) -> set[str]:
1304
1324
  output = set()
1305
1325
  for item in self.align.items:
1306
1326
  output.add(item.aligned_concept)
1327
+ if self.derive:
1328
+ for ditem in self.derive.items:
1329
+ output.add(ditem.address)
1307
1330
  return output
1308
1331
 
1309
1332
  @property
@@ -1311,10 +1334,12 @@ class BuildMultiSelectLineage(BuildConceptArgs):
1311
1334
  return self.build_output_components
1312
1335
 
1313
1336
  @property
1314
- def derived_concept(self) -> set[str]:
1315
- output = set()
1316
- for item in self.align.items:
1317
- output.add(item.aligned_concept)
1337
+ def calculated_derivations(self) -> set[str]:
1338
+ output: set[str] = set()
1339
+ if not self.derive:
1340
+ return output
1341
+ for item in self.derive.items:
1342
+ output.add(item.address)
1318
1343
  return output
1319
1344
 
1320
1345
  @property
@@ -1334,6 +1359,7 @@ class BuildMultiSelectLineage(BuildConceptArgs):
1334
1359
  for c in x.concepts:
1335
1360
  if c.address in cte.output_lcl:
1336
1361
  return c
1362
+
1337
1363
  raise SyntaxError(
1338
1364
  f"Could not find upstream map for multiselect {str(concept)} on cte ({cte})"
1339
1365
  )
@@ -1668,7 +1694,6 @@ class Factory:
1668
1694
  valid_inputs=base.valid_inputs,
1669
1695
  arg_count=base.arg_count,
1670
1696
  )
1671
-
1672
1697
  new = BuildFunction(
1673
1698
  operator=base.operator,
1674
1699
  arguments=[self.handle_constant(self.build(c)) for c in raw_args],
@@ -1724,6 +1749,14 @@ class Factory:
1724
1749
  return self._build_concept(base)
1725
1750
 
1726
1751
  def _build_concept(self, base: Concept) -> BuildConcept:
1752
+ try:
1753
+ return self.__build_concept(base)
1754
+ except RecursionError as e:
1755
+ raise RecursionError(
1756
+ f"Recursion error building concept {base.address}. This is likely due to a circular reference."
1757
+ ) from e
1758
+
1759
+ def __build_concept(self, base: Concept) -> BuildConcept:
1727
1760
  # TODO: if we are using parameters, wrap it in a new model and use that in rendering
1728
1761
  if base.address in self.local_concepts:
1729
1762
  return self.local_concepts[base.address]
@@ -1953,6 +1986,28 @@ class Factory:
1953
1986
  def _build_align_clause(self, base: AlignClause) -> BuildAlignClause:
1954
1987
  return BuildAlignClause(items=[self._build_align_item(x) for x in base.items])
1955
1988
 
1989
+ @build.register
1990
+ def _(self, base: DeriveItem) -> BuildDeriveItem:
1991
+ return self._build_derive_item(base)
1992
+
1993
+ def _build_derive_item(self, base: DeriveItem) -> BuildDeriveItem:
1994
+ expr: Concept | FuncArgs = base.expr
1995
+ validation = requires_concept_nesting(expr)
1996
+ if validation:
1997
+ expr, _ = self.instantiate_concept(validation)
1998
+ return BuildDeriveItem(
1999
+ expr=self.build(expr),
2000
+ name=base.name,
2001
+ namespace=base.namespace,
2002
+ )
2003
+
2004
+ @build.register
2005
+ def _(self, base: DeriveClause) -> BuildDeriveClause:
2006
+ return self._build_derive_clause(base)
2007
+
2008
+ def _build_derive_clause(self, base: DeriveClause) -> BuildDeriveClause:
2009
+ return BuildDeriveClause(items=[self.build(x) for x in base.items])
2010
+
1956
2011
  @build.register
1957
2012
  def _(self, base: RowsetItem) -> BuildRowsetItem:
1958
2013
  return self._build_rowset_item(base)
@@ -2002,6 +2057,13 @@ class Factory:
2002
2057
  def _build_tuple_wrapper(self, base: TupleWrapper) -> TupleWrapper:
2003
2058
  return TupleWrapper(val=[self.build(x) for x in base.val], type=base.type)
2004
2059
 
2060
+ @build.register
2061
+ def _(self, base: ListWrapper) -> ListWrapper:
2062
+ return self._build_list_wrapper(base)
2063
+
2064
+ def _build_list_wrapper(self, base: ListWrapper) -> ListWrapper:
2065
+ return ListWrapper([self.build(x) for x in base], type=base.type)
2066
+
2005
2067
  @build.register
2006
2068
  def _(self, base: FilterItem) -> BuildFilterItem:
2007
2069
  return self._build_filter_item(base)
@@ -2147,6 +2209,7 @@ class Factory:
2147
2209
  selects=base.selects,
2148
2210
  grain=final_grain,
2149
2211
  align=factory.build(base.align),
2212
+ derive=factory.build(base.derive) if base.derive else None,
2150
2213
  # self.align.with_select_context(
2151
2214
  # local_build_cache, self.grain, environment
2152
2215
  # ),
@@ -413,7 +413,8 @@ class Environment(BaseModel):
413
413
  self.imports[alias].append(imp_stm)
414
414
  # we can't exit early
415
415
  # as there may be new concepts
416
- for k, concept in source.concepts.items():
416
+ iteration: list[tuple[str, Concept]] = list(source.concepts.items())
417
+ for k, concept in iteration:
417
418
  # skip internal namespace
418
419
  if INTERNAL_NAMESPACE in concept.address:
419
420
  continue
@@ -228,7 +228,8 @@ def optimize_ctes(
228
228
  REGISTERED_RULES.append(PredicatePushdown())
229
229
  if CONFIG.optimizations.predicate_pushdown:
230
230
  REGISTERED_RULES.append(PredicatePushdownRemove())
231
- REGISTERED_RULES.append(HideUnusedConcepts())
231
+ if CONFIG.optimizations.hide_unused_concepts:
232
+ REGISTERED_RULES.append(HideUnusedConcepts())
232
233
  for rule in REGISTERED_RULES:
233
234
  loops = 0
234
235
  complete = False
@@ -242,7 +243,7 @@ def optimize_ctes(
242
243
  actions_taken = actions_taken or opt
243
244
  complete = not actions_taken
244
245
  loops += 1
245
- input = reorder_ctes(filter_irrelevant_ctes(input, root_cte))
246
+ input = reorder_ctes(filter_irrelevant_ctes(input, root_cte))
246
247
  logger.info(
247
248
  f"[Optimization] Finished checking for {type(rule).__name__} after {loops} loop(s)"
248
249
  )
@@ -39,11 +39,7 @@ class HideUnusedConcepts(OptimizationRule):
39
39
  self.log(
40
40
  f"Hiding unused concepts {[x.address for x in add_to_hidden]} from {cte.name} (used: {used}, all: {[x.address for x in cte.output_columns]})"
41
41
  )
42
- candidates = [
43
- x.address
44
- for x in cte.output_columns
45
- if x.address not in used and x.address not in cte.hidden_concepts
46
- ]
42
+ candidates = [x.address for x in cte.output_columns if x.address not in used]
47
43
  if len(candidates) == len(set([x.address for x in cte.output_columns])):
48
44
  # pop one out
49
45
  candidates.pop()
@@ -306,7 +306,12 @@ def evaluate_loop_conditions(
306
306
 
307
307
 
308
308
  def check_for_early_exit(
309
- complete, partial, missing, context: LoopContext, priority_concept: BuildConcept
309
+ complete: ValidationResult,
310
+ found: set[str],
311
+ partial: set[str],
312
+ missing: set[str],
313
+ context: LoopContext,
314
+ priority_concept: BuildConcept,
310
315
  ) -> bool:
311
316
  if complete == ValidationResult.INCOMPLETE_CONDITION:
312
317
  cond_dict = {str(node): node.preexisting_conditions for node in context.stack}
@@ -331,8 +336,18 @@ def check_for_early_exit(
331
336
  f"{depth_to_prefix(context.depth)}{LOGGER_PREFIX} Breaking as we have attempted all nodes"
332
337
  )
333
338
  return True
339
+ elif all(
340
+ [
341
+ x.address in found and x.address not in partial
342
+ for x in context.mandatory_list
343
+ ]
344
+ ):
345
+ logger.info(
346
+ f"{depth_to_prefix(context.depth)}{LOGGER_PREFIX} Breaking as we have found all mandatory nodes without partials"
347
+ )
348
+ return True
334
349
  logger.info(
335
- f"{depth_to_prefix(context.depth)}{LOGGER_PREFIX} Found complete stack with partials {partial}, continuing search, attempted {context.attempted} all {len(context.mandatory_list)}"
350
+ f"{depth_to_prefix(context.depth)}{LOGGER_PREFIX} Found complete stack with partials {partial}, continuing search, attempted {context.attempted} of total {len(context.mandatory_list)}."
336
351
  )
337
352
  else:
338
353
  logger.info(
@@ -436,6 +451,7 @@ def generate_loop_completion(context: LoopContext, virtual: set[str]) -> Strateg
436
451
  context.original_mandatory,
437
452
  context.environment,
438
453
  non_virtual_difference_values,
454
+ depth=context.depth,
439
455
  )
440
456
 
441
457
  return group_if_required_v2(
@@ -443,6 +459,7 @@ def generate_loop_completion(context: LoopContext, virtual: set[str]) -> Strateg
443
459
  context.original_mandatory,
444
460
  context.environment,
445
461
  non_virtual_difference_values,
462
+ depth=context.depth,
446
463
  )
447
464
 
448
465
 
@@ -466,6 +483,7 @@ def _search_concepts(
466
483
  conditions=conditions,
467
484
  )
468
485
 
486
+ # if we get a can
469
487
  if candidate:
470
488
  return candidate
471
489
  context = initialize_loop_context(
@@ -477,13 +495,16 @@ def _search_concepts(
477
495
  accept_partial=accept_partial,
478
496
  conditions=conditions,
479
497
  )
480
-
498
+ partial: set[str] = set()
499
+ virtual: set[str] = set()
500
+ complete = ValidationResult.INCOMPLETE
481
501
  while context.incomplete:
482
502
 
483
503
  priority_concept = get_priority_concept(
484
504
  context.mandatory_list,
485
505
  context.attempted,
486
506
  found_concepts=context.found,
507
+ partial_concepts=partial,
487
508
  depth=depth,
488
509
  )
489
510
 
@@ -538,7 +559,7 @@ def _search_concepts(
538
559
  # assign
539
560
  context.found = found_c
540
561
  early_exit = check_for_early_exit(
541
- complete, partial, missing_c, context, priority_concept
562
+ complete, found_c, partial, missing_c, context, priority_concept
542
563
  )
543
564
  if early_exit:
544
565
  break
@@ -608,4 +629,4 @@ def source_query_concepts(
608
629
  logger.info(
609
630
  f"{depth_to_prefix(0)}{LOGGER_PREFIX} final concepts are {[x.address for x in final]}"
610
631
  )
611
- return group_if_required_v2(root, output_concepts, environment)
632
+ return group_if_required_v2(root, output_concepts, environment, depth=0)
@@ -187,7 +187,7 @@ def _generate_aggregate_node(ctx: NodeGenerationContext) -> StrategyNode | None:
187
187
 
188
188
  logger.info(
189
189
  f"{depth_to_prefix(ctx.depth)}{LOGGER_PREFIX} "
190
- f"for {ctx.concept.address}, generating aggregate node with {agg_optional}"
190
+ f"for {ctx.concept.address}, generating aggregate node with optional {agg_optional}"
191
191
  )
192
192
 
193
193
  return gen_group_node(
@@ -441,7 +441,7 @@ def generate_node(
441
441
  depth: int,
442
442
  source_concepts: SearchConceptsType,
443
443
  history: History,
444
- accept_partial: bool = False,
444
+ accept_partial: bool,
445
445
  conditions: BuildWhereClause | None = None,
446
446
  ) -> StrategyNode | None:
447
447
 
@@ -184,10 +184,14 @@ def group_if_required_v2(
184
184
  final: List[BuildConcept],
185
185
  environment: BuildEnvironment,
186
186
  where_injected: set[str] | None = None,
187
+ depth: int = 0,
187
188
  ):
188
189
  where_injected = where_injected or set()
189
190
  required = check_if_group_required(
190
- downstream_concepts=final, parents=[root.resolve()], environment=environment
191
+ downstream_concepts=final,
192
+ parents=[root.resolve()],
193
+ environment=environment,
194
+ depth=depth,
191
195
  )
192
196
  targets = [
193
197
  x
@@ -258,6 +262,7 @@ def get_priority_concept(
258
262
  all_concepts: List[BuildConcept],
259
263
  attempted_addresses: set[str],
260
264
  found_concepts: set[str],
265
+ partial_concepts: set[str],
261
266
  depth: int,
262
267
  ) -> BuildConcept:
263
268
  # optimized search for missing concepts
@@ -265,13 +270,15 @@ def get_priority_concept(
265
270
  [
266
271
  c
267
272
  for c in all_concepts
268
- if c.address not in attempted_addresses and c.address not in found_concepts
273
+ if c.address not in attempted_addresses
274
+ and (c.address not in found_concepts or c.address in partial_concepts)
269
275
  ],
270
276
  key=lambda x: x.address,
271
277
  )
272
278
  # sometimes we need to scan intermediate concepts to get merge keys or filter keys,
273
279
  # so do an exhaustive search
274
- # pass_two = [c for c in all_concepts+filter_only if c.address not in attempted_addresses]
280
+ # pass_two = [c for c in all_concepts if c.address not in attempted_addresses]
281
+
275
282
  for remaining_concept in (pass_one,):
276
283
  priority = (
277
284
  # then multiselects to remove them from scope
@@ -333,5 +340,5 @@ def get_priority_concept(
333
340
  if final:
334
341
  return final[0]
335
342
  raise ValueError(
336
- f"Cannot resolve query. No remaining priority concepts, have attempted {attempted_addresses}"
343
+ f"Cannot resolve query. No remaining priority concepts, have attempted {attempted_addresses} out of {all_concepts} with found {found_concepts}"
337
344
  )
@@ -7,7 +7,7 @@ from trilogy.core.models.build_environment import BuildEnvironment
7
7
  from trilogy.core.processing.node_generators.common import (
8
8
  resolve_function_parent_concepts,
9
9
  )
10
- from trilogy.core.processing.nodes import History, StrategyNode
10
+ from trilogy.core.processing.nodes import ConstantNode, History, StrategyNode
11
11
  from trilogy.utility import unique
12
12
 
13
13
  LOGGER_PREFIX = "[GEN_BASIC_NODE]"
@@ -51,11 +51,14 @@ def gen_basic_node(
51
51
  )
52
52
  synonyms: list[BuildConcept] = []
53
53
  ignored_optional: set[str] = set()
54
- assert isinstance(concept.lineage, BuildFunction)
54
+
55
55
  # when we are getting an attribute, if there is anything else
56
56
  # that is an attribute of the same struct in local optional
57
57
  # select that value for discovery as well
58
- if concept.lineage.operator == FunctionType.ATTR_ACCESS:
58
+ if (
59
+ isinstance(concept.lineage, BuildFunction)
60
+ and concept.lineage.operator == FunctionType.ATTR_ACCESS
61
+ ):
59
62
  logger.info(
60
63
  f"{depth_prefix}{LOGGER_PREFIX} checking for synonyms for attribute access"
61
64
  )
@@ -106,20 +109,28 @@ def gen_basic_node(
106
109
  logger.info(
107
110
  f"{depth_prefix}{LOGGER_PREFIX} Fetching parents {[x.address for x in all_parents]}"
108
111
  )
109
- parent_node: StrategyNode | None = source_concepts(
110
- mandatory_list=all_parents,
111
- environment=environment,
112
- g=g,
113
- depth=depth + 1,
114
- history=history,
115
- conditions=conditions,
116
- )
112
+ if all_parents:
113
+ parent_node: StrategyNode | None = source_concepts(
114
+ mandatory_list=all_parents,
115
+ environment=environment,
116
+ g=g,
117
+ depth=depth + 1,
118
+ history=history,
119
+ conditions=conditions,
120
+ )
117
121
 
118
- if not parent_node:
119
- logger.info(
120
- f"{depth_prefix}{LOGGER_PREFIX} No basic node could be generated for {concept}"
122
+ if not parent_node:
123
+ logger.info(
124
+ f"{depth_prefix}{LOGGER_PREFIX} No basic node could be generated for {concept}"
125
+ )
126
+ return None
127
+ else:
128
+ return ConstantNode(
129
+ input_concepts=[],
130
+ output_concepts=[concept],
131
+ environment=environment,
132
+ depth=depth,
121
133
  )
122
- return None
123
134
  if parent_node.source_type != SourceType.CONSTANT:
124
135
  parent_node.source_type = SourceType.BASIC
125
136
  parent_node.add_output_concept(concept)
@@ -4,6 +4,7 @@ from typing import Callable, List, Tuple
4
4
  from trilogy.core.enums import Derivation, Purpose
5
5
  from trilogy.core.models.build import (
6
6
  BuildAggregateWrapper,
7
+ BuildComparison,
7
8
  BuildConcept,
8
9
  BuildFilterItem,
9
10
  BuildFunction,
@@ -26,7 +27,9 @@ FUNCTION_TYPES = (BuildFunction,)
26
27
  def resolve_function_parent_concepts(
27
28
  concept: BuildConcept, environment: BuildEnvironment
28
29
  ) -> List[BuildConcept]:
29
- if not isinstance(concept.lineage, (*FUNCTION_TYPES, *AGGREGATE_TYPES)):
30
+ if not isinstance(
31
+ concept.lineage, (*FUNCTION_TYPES, *AGGREGATE_TYPES, BuildComparison)
32
+ ):
30
33
  raise ValueError(
31
34
  f"Concept {concept} lineage is not function or aggregate, is {type(concept.lineage)}"
32
35
  )
@@ -96,6 +96,8 @@ def build_parent_concepts(
96
96
  continue
97
97
  elif global_filter_is_local_filter:
98
98
  same_filter_optional.append(x)
99
+ # also append it to the parent row concepts
100
+ parent_row_concepts.append(x)
99
101
 
100
102
  # sometimes, it's okay to include other local optional above the filter
101
103
  # in case it is, prep our list
@@ -204,11 +206,16 @@ def gen_filter_node(
204
206
  f"{padding(depth)}{LOGGER_PREFIX} filter node row parents {[x.address for x in parent_row_concepts]} could not be found"
205
207
  )
206
208
  return None
209
+ else:
210
+ logger.info(
211
+ f"{padding(depth)}{LOGGER_PREFIX} filter node has row parents {[x.address for x in parent_row_concepts]} from node with output [{[x.address for x in row_parent.output_concepts]}] partial {row_parent.partial_concepts}"
212
+ )
207
213
  if global_filter_is_local_filter:
208
214
  logger.info(
209
215
  f"{padding(depth)}{LOGGER_PREFIX} filter node conditions match global conditions adding row parent {row_parent.output_concepts} with condition {where.conditional}"
210
216
  )
211
217
  row_parent.add_parents(core_parent_nodes)
218
+ # all local optional will be in the parent already, so we can set outputs
212
219
  row_parent.set_output_concepts([concept] + local_optional)
213
220
  return row_parent
214
221
  if optimized_pushdown:
@@ -157,19 +157,19 @@ def gen_multiselect_node(
157
157
  possible_joins = concept_to_relevant_joins(additional_relevant)
158
158
  if not local_optional:
159
159
  logger.info(
160
- f"{padding(depth)}{LOGGER_PREFIX} no enrichment required for rowset node; exiting early"
160
+ f"{padding(depth)}{LOGGER_PREFIX} no enrichment required for multiselect node; exiting early"
161
161
  )
162
162
  return node
163
163
  if not possible_joins:
164
164
  logger.info(
165
- f"{padding(depth)}{LOGGER_PREFIX} no possible joins for rowset node; exiting early"
165
+ f"{padding(depth)}{LOGGER_PREFIX} no possible joins for multiselect node; exiting early"
166
166
  )
167
167
  return node
168
168
  if all(
169
169
  [x.address in [y.address for y in node.output_concepts] for x in local_optional]
170
170
  ):
171
171
  logger.info(
172
- f"{padding(depth)}{LOGGER_PREFIX} all enriched concepts returned from base rowset node; exiting early"
172
+ f"{padding(depth)}{LOGGER_PREFIX} all enriched concepts returned from base multiselect node; exiting early"
173
173
  )
174
174
  return node
175
175
  logger.info(
@@ -9,6 +9,7 @@ from trilogy.core.models.build import (
9
9
  from trilogy.core.models.build_environment import BuildEnvironment
10
10
  from trilogy.core.processing.nodes import (
11
11
  History,
12
+ MergeNode,
12
13
  StrategyNode,
13
14
  UnnestNode,
14
15
  WhereSafetyNode,
@@ -18,6 +19,32 @@ from trilogy.core.processing.utility import padding
18
19
  LOGGER_PREFIX = "[GEN_UNNEST_NODE]"
19
20
 
20
21
 
22
+ def get_pseudonym_parents(
23
+ concept: BuildConcept,
24
+ local_optional: List[BuildConcept],
25
+ source_concepts,
26
+ environment: BuildEnvironment,
27
+ g,
28
+ depth,
29
+ history,
30
+ conditions,
31
+ ) -> List[StrategyNode]:
32
+ for x in concept.pseudonyms:
33
+ attempt = source_concepts(
34
+ mandatory_list=[environment.alias_origin_lookup[x]] + local_optional,
35
+ environment=environment,
36
+ g=g,
37
+ depth=depth + 1,
38
+ history=history,
39
+ conditions=conditions,
40
+ accept_partial=True,
41
+ )
42
+ if not attempt:
43
+ continue
44
+ return [attempt]
45
+ return []
46
+
47
+
21
48
  def gen_unnest_node(
22
49
  concept: BuildConcept,
23
50
  local_optional: List[BuildConcept],
@@ -29,14 +56,34 @@ def gen_unnest_node(
29
56
  conditions: BuildWhereClause | None = None,
30
57
  ) -> StrategyNode | None:
31
58
  arguments = []
59
+ join_nodes: list[StrategyNode] = []
32
60
  depth_prefix = "\t" * depth
33
61
  if isinstance(concept.lineage, BuildFunction):
34
62
  arguments = concept.lineage.concept_arguments
63
+ search_optional = local_optional
64
+ if (not arguments) and (local_optional and concept.pseudonyms):
65
+ logger.info(
66
+ f"{padding(depth)}{LOGGER_PREFIX} unnest node for {concept} has no parents; creating solo unnest node"
67
+ )
68
+ join_nodes += get_pseudonym_parents(
69
+ concept,
70
+ local_optional,
71
+ source_concepts,
72
+ environment,
73
+ g,
74
+ depth,
75
+ history,
76
+ conditions,
77
+ )
78
+ logger.info(
79
+ f"{padding(depth)}{LOGGER_PREFIX} unnest node for {concept} got join nodes {join_nodes}"
80
+ )
81
+ search_optional = []
35
82
 
36
- equivalent_optional = [x for x in local_optional if x.lineage == concept.lineage]
83
+ equivalent_optional = [x for x in search_optional if x.lineage == concept.lineage]
37
84
 
38
85
  non_equivalent_optional = [
39
- x for x in local_optional if x not in equivalent_optional
86
+ x for x in search_optional if x not in equivalent_optional
40
87
  ]
41
88
  all_parents = arguments + non_equivalent_optional
42
89
  logger.info(
@@ -44,7 +91,8 @@ def gen_unnest_node(
44
91
  )
45
92
  local_conditions = False
46
93
  expected_outputs = [concept] + local_optional
47
- if arguments or local_optional:
94
+ parent: StrategyNode | None = None
95
+ if arguments or search_optional:
48
96
  parent = source_concepts(
49
97
  mandatory_list=all_parents,
50
98
  environment=environment,
@@ -86,14 +134,37 @@ def gen_unnest_node(
86
134
  base = UnnestNode(
87
135
  unnest_concepts=[concept] + equivalent_optional,
88
136
  input_concepts=arguments + non_equivalent_optional,
89
- output_concepts=[concept] + local_optional,
137
+ output_concepts=[concept] + search_optional,
90
138
  environment=environment,
91
139
  parents=([parent] if parent else []),
92
140
  )
141
+
142
+ conditional = conditions.conditional if conditions else None
143
+ if join_nodes:
144
+ logger.info(
145
+ f"{depth_prefix}{LOGGER_PREFIX} unnest node for {concept} needs to merge with join nodes {join_nodes}"
146
+ )
147
+ for x in join_nodes:
148
+ logger.info(
149
+ f"{depth_prefix}{LOGGER_PREFIX} join node {x} with partial {x.partial_concepts}"
150
+ )
151
+ pseudonyms = [
152
+ environment.alias_origin_lookup[p] for p in concept.pseudonyms
153
+ ]
154
+ x.add_partial_concepts(pseudonyms)
155
+ return MergeNode(
156
+ input_concepts=base.output_concepts
157
+ + [j for n in join_nodes for j in n.output_concepts],
158
+ output_concepts=[concept] + local_optional,
159
+ environment=environment,
160
+ parents=[base] + join_nodes,
161
+ conditions=conditional if local_conditions is True else None,
162
+ preexisting_conditions=(
163
+ conditional if conditional and local_conditions is False else None
164
+ ),
165
+ )
93
166
  # we need to sometimes nest an unnest node,
94
167
  # as unnest operations are not valid in all situations
95
- # TODO: inline this node when we can detect it's safe
96
- conditional = conditions.conditional if conditions else None
97
168
  new = WhereSafetyNode(
98
169
  input_concepts=base.output_concepts,
99
170
  output_concepts=base.output_concepts,
@@ -21,6 +21,7 @@ from trilogy.core.models.author import (
21
21
  Concept,
22
22
  ConceptRef,
23
23
  CustomType,
24
+ DeriveClause,
24
25
  Expr,
25
26
  FilterItem,
26
27
  Function,
@@ -197,7 +198,7 @@ class SelectStatement(HasUUID, SelectTypeMixin, BaseModel):
197
198
  for x in self.where_clause.concept_arguments:
198
199
  if isinstance(x, UndefinedConcept):
199
200
  validate = environment.concepts.get(x.address)
200
- if validate:
201
+ if validate and self.where_clause:
201
202
  self.where_clause = (
202
203
  self.where_clause.with_reference_replacement(
203
204
  x.address, validate.reference
@@ -352,11 +353,13 @@ class MultiSelectStatement(HasUUID, SelectTypeMixin, BaseModel):
352
353
  local_concepts: Annotated[
353
354
  EnvironmentConceptDict, PlainValidator(validate_concepts)
354
355
  ] = Field(default_factory=EnvironmentConceptDict)
356
+ derive: DeriveClause | None = None
355
357
 
356
358
  def as_lineage(self, environment: Environment):
357
359
  return MultiSelectLineage(
358
360
  selects=[x.as_lineage(environment) for x in self.selects],
359
361
  align=self.align,
362
+ derive=self.derive,
360
363
  namespace=self.namespace,
361
364
  # derived_concepts = self.derived_concepts,
362
365
  limit=self.limit,