pytrilogy 0.0.1.109__py3-none-any.whl → 0.0.1.111__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 (34) hide show
  1. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/METADATA +1 -1
  2. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/RECORD +34 -34
  3. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/WHEEL +1 -1
  4. trilogy/__init__.py +1 -1
  5. trilogy/constants.py +11 -3
  6. trilogy/core/enums.py +1 -0
  7. trilogy/core/models.py +94 -67
  8. trilogy/core/optimization.py +134 -12
  9. trilogy/core/processing/concept_strategies_v3.py +44 -19
  10. trilogy/core/processing/node_generators/basic_node.py +2 -0
  11. trilogy/core/processing/node_generators/common.py +3 -1
  12. trilogy/core/processing/node_generators/concept_merge_node.py +24 -8
  13. trilogy/core/processing/node_generators/filter_node.py +36 -6
  14. trilogy/core/processing/node_generators/node_merge_node.py +34 -23
  15. trilogy/core/processing/node_generators/rowset_node.py +37 -8
  16. trilogy/core/processing/node_generators/select_node.py +23 -9
  17. trilogy/core/processing/node_generators/unnest_node.py +24 -3
  18. trilogy/core/processing/node_generators/window_node.py +4 -2
  19. trilogy/core/processing/nodes/__init__.py +7 -6
  20. trilogy/core/processing/nodes/base_node.py +40 -6
  21. trilogy/core/processing/nodes/filter_node.py +15 -1
  22. trilogy/core/processing/nodes/group_node.py +20 -1
  23. trilogy/core/processing/nodes/merge_node.py +37 -10
  24. trilogy/core/processing/nodes/select_node_v2.py +34 -39
  25. trilogy/core/processing/nodes/unnest_node.py +12 -0
  26. trilogy/core/processing/nodes/window_node.py +11 -0
  27. trilogy/core/processing/utility.py +0 -14
  28. trilogy/core/query_processor.py +125 -29
  29. trilogy/dialect/base.py +45 -40
  30. trilogy/executor.py +31 -3
  31. trilogy/parsing/parse_engine.py +49 -17
  32. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/LICENSE.md +0 -0
  33. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/entry_points.txt +0 -0
  34. {pytrilogy-0.0.1.109.dist-info → pytrilogy-0.0.1.111.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,7 @@ from trilogy.utility import unique
10
10
  from trilogy.core.exceptions import AmbiguousRelationshipResolutionException
11
11
  from trilogy.core.processing.utility import padding
12
12
  from trilogy.core.processing.graph_utils import extract_mandatory_subgraphs
13
+ from trilogy.core.enums import PurposeLineage
13
14
 
14
15
  LOGGER_PREFIX = "[GEN_MERGE_NODE]"
15
16
 
@@ -65,13 +66,13 @@ def identify_ds_join_paths(
65
66
  ]
66
67
  if partial and not accept_partial:
67
68
  return None
68
- # join_candidates.append({"paths": paths, "datasource": datasource})
69
+
69
70
  return PathInfo(
70
71
  paths=paths,
71
72
  datasource=datasource,
72
73
  reduced_concepts=reduce_path_concepts(paths, g),
73
74
  concept_subgraphs=extract_mandatory_subgraphs(paths, g),
74
- ) # {"paths": paths, "datasource": datasource}
75
+ )
75
76
  return None
76
77
 
77
78
 
@@ -88,14 +89,7 @@ def gen_merge_node(
88
89
  join_candidates: List[PathInfo] = []
89
90
  # anchor on datasources
90
91
  final_all_concepts = []
91
- # implicit_upstream = {}
92
92
  for x in all_concepts:
93
- # if x.derivation in (PurposeLineage.AGGREGATE, PurposeLineage.BASIC):
94
- # final_all_concepts +=resolve_function_parent_concepts(x)
95
- # elif x.derivation == PurposeLineage.FILTER:
96
- # final_all_concepts +=resolve_filter_parent_concepts(x)
97
- # else:
98
- # final_all_concepts.append(x)
99
93
  final_all_concepts.append(x)
100
94
  for datasource in environment.datasources.values():
101
95
  path = identify_ds_join_paths(final_all_concepts, g, datasource, accept_partial)
@@ -104,18 +98,25 @@ def gen_merge_node(
104
98
  join_candidates.sort(key=lambda x: sum([len(v) for v in x.paths.values()]))
105
99
  if not join_candidates:
106
100
  return None
107
- for join_candidate in join_candidates:
108
- logger.info(
109
- f"{padding(depth)}{LOGGER_PREFIX} Join candidate: {join_candidate.paths}"
110
- )
111
- join_additions: List[set[str]] = []
101
+ join_additions: list[set[str]] = []
112
102
  for candidate in join_candidates:
113
103
  join_additions.append(candidate.reduced_concepts)
114
- if not all(
115
- [x.issubset(y) or y.issubset(x) for x in join_additions for y in join_additions]
116
- ):
104
+
105
+ common: set[str] = set()
106
+ final_candidates: list[set[str]] = []
107
+ # find all values that show up in every join_additions
108
+ for ja in join_additions:
109
+ if not common:
110
+ common = ja
111
+ else:
112
+ common = common.intersection(ja)
113
+ if all(ja.issubset(y) for y in join_additions):
114
+ final_candidates.append(ja)
115
+
116
+ if not final_candidates:
117
+ filtered_paths = [x.difference(common) for x in join_additions]
117
118
  raise AmbiguousRelationshipResolutionException(
118
- f"Ambiguous concept join resolution - possible paths = {join_additions}. Include an additional concept to disambiguate",
119
+ f"Ambiguous concept join resolution fetching {[x.address for x in all_concepts]} - unique values in possible paths = {filtered_paths}. Include an additional concept to disambiguate",
119
120
  join_additions,
120
121
  )
121
122
  if not join_candidates:
@@ -123,9 +124,10 @@ def gen_merge_node(
123
124
  f"{padding(depth)}{LOGGER_PREFIX} No additional join candidates could be found"
124
125
  )
125
126
  return None
126
- shortest: PathInfo = sorted(join_candidates, key=lambda x: len(x.reduced_concepts))[
127
- 0
128
- ]
127
+ shortest: PathInfo = sorted(
128
+ [x for x in join_candidates if x.reduced_concepts in final_candidates],
129
+ key=lambda x: len(x.reduced_concepts),
130
+ )[0]
129
131
  logger.info(f"{padding(depth)}{LOGGER_PREFIX} final path is {shortest.paths}")
130
132
  # logger.info(f'{padding(depth)}{LOGGER_PREFIX} final reduced concepts are {shortest.concs}')
131
133
  parents = []
@@ -145,11 +147,20 @@ def gen_merge_node(
145
147
  f"{padding(depth)}{LOGGER_PREFIX} Unable to instantiate target subgraph"
146
148
  )
147
149
  return None
150
+ logger.info(
151
+ f"{padding(depth)}{LOGGER_PREFIX} finished subgraph fetch for {[c.address for c in graph]}, have parent {type(parent)}"
152
+ )
148
153
  parents.append(parent)
149
154
 
150
155
  return MergeNode(
151
- input_concepts=[environment.concepts[x] for x in shortest.reduced_concepts],
152
- output_concepts=all_concepts,
156
+ input_concepts=[
157
+ environment.concepts[x]
158
+ for x in shortest.reduced_concepts
159
+ if environment.concepts[x].derivation != PurposeLineage.MERGE
160
+ ],
161
+ output_concepts=[
162
+ x for x in all_concepts if x.derivation != PurposeLineage.MERGE
163
+ ],
153
164
  environment=environment,
154
165
  g=g,
155
166
  parents=parents,
@@ -12,7 +12,7 @@ from typing import List
12
12
 
13
13
  from trilogy.core.enums import JoinType, PurposeLineage
14
14
  from trilogy.constants import logger
15
- from trilogy.core.processing.utility import padding
15
+ from trilogy.core.processing.utility import padding, unique
16
16
  from trilogy.core.processing.node_generators.common import concept_to_relevant_joins
17
17
 
18
18
 
@@ -35,30 +35,57 @@ def gen_rowset_node(
35
35
  lineage: RowsetItem = concept.lineage
36
36
  rowset: RowsetDerivationStatement = lineage.rowset
37
37
  select: SelectStatement | MultiSelectStatement = lineage.rowset.select
38
+ parents: List[StrategyNode] = []
38
39
  if where := select.where_clause:
39
- targets = select.output_components + where.conditional.concept_arguments
40
+ targets = select.output_components + where.conditional.row_arguments
41
+ for sub_select in where.conditional.existence_arguments:
42
+ logger.info(
43
+ f"{padding(depth)}{LOGGER_PREFIX} generating parent existence node with {[x.address for x in sub_select]}"
44
+ )
45
+ parent_check = source_concepts(
46
+ mandatory_list=sub_select,
47
+ environment=environment,
48
+ g=g,
49
+ depth=depth + 1,
50
+ history=history,
51
+ )
52
+ if not parent_check:
53
+ logger.info(
54
+ f"{padding(depth)}{LOGGER_PREFIX} Cannot generate parent existence node for rowset node for {concept}"
55
+ )
56
+ return None
57
+ parents.append(parent_check)
40
58
  else:
41
59
  targets = select.output_components
42
60
  node: StrategyNode = source_concepts(
43
- mandatory_list=targets,
61
+ mandatory_list=unique(targets, "address"),
44
62
  environment=environment,
45
63
  g=g,
46
64
  depth=depth + 1,
47
65
  history=history,
48
66
  )
67
+
68
+ # add our existence concepts in
69
+ if parents:
70
+ node.parents += parents
71
+ for parent in parents:
72
+ for x in parent.output_concepts:
73
+ if x.address not in node.output_lcl:
74
+ node.existence_concepts.append(x)
49
75
  if not node:
50
76
  logger.info(
51
77
  f"{padding(depth)}{LOGGER_PREFIX} Cannot generate rowset node for {concept}"
52
78
  )
53
79
  return None
54
80
  node.conditions = select.where_clause.conditional if select.where_clause else None
55
- # rebuild any cached info with the new condition clause
56
-
57
81
  enrichment = set([x.address for x in local_optional])
58
- rowset_relevant = [
82
+ rowset_relevant = [x for x in rowset.derived_concepts]
83
+ select_hidden = set([x.address for x in select.hidden_components])
84
+ rowset_hidden = [
59
85
  x
60
86
  for x in rowset.derived_concepts
61
- # if x.address == concept.address or x.address in enrichment
87
+ if isinstance(x.lineage, RowsetItem)
88
+ and x.lineage.content.address in select_hidden
62
89
  ]
63
90
  additional_relevant = [
64
91
  x for x in select.output_components if x.address in enrichment
@@ -71,7 +98,7 @@ def gen_rowset_node(
71
98
  if select.where_clause:
72
99
  for item in additional_relevant:
73
100
  node.partial_concepts.append(item)
74
- node.hidden_concepts = [
101
+ node.hidden_concepts = rowset_hidden + [
75
102
  x
76
103
  for x in node.output_concepts
77
104
  if x.address not in [y.address for y in local_optional + [concept]]
@@ -81,9 +108,11 @@ def gen_rowset_node(
81
108
  # but don't include anything aggregate at this point
82
109
  node.rebuild_cache()
83
110
  assert node.resolution_cache
111
+
84
112
  node.resolution_cache.grain = concept_list_to_grain(
85
113
  node.output_concepts, parent_sources=node.resolution_cache.datasources
86
114
  )
115
+
87
116
  possible_joins = concept_to_relevant_joins(additional_relevant)
88
117
  if not local_optional:
89
118
  logger.info(
@@ -53,7 +53,7 @@ def dm_to_strategy_node(
53
53
  # we have to group
54
54
  else:
55
55
  logger.info(
56
- f"{padding(depth)}{LOGGER_PREFIX} not all grain components are in output {str(dm.matched)}, group to actual grain"
56
+ f"{padding(depth)}{LOGGER_PREFIX} not all grain components {target_grain} are in output {str(dm.matched)}, group to actual grain"
57
57
  )
58
58
  force_group = True
59
59
  elif all([x in dm.matched for x in datasource.grain.components]):
@@ -76,7 +76,7 @@ def dm_to_strategy_node(
76
76
  partial_concepts=dm.partial.concepts,
77
77
  accept_partial=accept_partial,
78
78
  datasource=datasource,
79
- grain=Grain(components=dm.matched.concepts),
79
+ grain=datasource.grain,
80
80
  )
81
81
  # we need to nest the group node one further
82
82
  if force_group is True:
@@ -317,13 +317,19 @@ def gen_select_node_from_table(
317
317
  )
318
318
  if target_grain and target_grain.issubset(datasource.grain):
319
319
 
320
- if all([x in all_lcl for x in target_grain.components]):
320
+ if (
321
+ all([x in all_lcl for x in target_grain.components])
322
+ and target_grain == datasource.grain
323
+ ):
324
+ logger.info(
325
+ f"{padding(depth)}{LOGGER_PREFIX} target grain components match all lcl, group to false"
326
+ )
321
327
  force_group = False
322
328
  # if we are not returning the grain
323
329
  # we have to group
324
330
  else:
325
331
  logger.info(
326
- f"{padding(depth)}{LOGGER_PREFIX} not all grain components are in output {str(all_lcl)}, group to actual grain"
332
+ f"{padding(depth)}{LOGGER_PREFIX} not all grain components {target_grain} are in output {str(all_lcl)}, group to actual grain"
327
333
  )
328
334
  force_group = True
329
335
  elif all([x in all_lcl for x in datasource.grain.components]):
@@ -363,7 +369,7 @@ def gen_select_node_from_table(
363
369
  else:
364
370
  candidate = bcandidate
365
371
  logger.info(
366
- f"{padding(depth)}{LOGGER_PREFIX} found select node with {datasource.identifier}, returning {candidate.output_lcl}"
372
+ f"{padding(depth)}{LOGGER_PREFIX} found select node with {datasource.identifier}, force group is {force_group}, returning {candidate.output_lcl}"
367
373
  )
368
374
  candidates[datasource.identifier] = candidate
369
375
  scores[datasource.identifier] = -len(partial_concepts)
@@ -467,6 +473,8 @@ def gen_select_node(
467
473
  target_grain = Grain()
468
474
  for ac in all_concepts:
469
475
  target_grain += ac.grain
476
+ if target_grain.abstract:
477
+ target_grain = Grain(components=all_concepts)
470
478
  if materialized_lcl != all_lcl:
471
479
  logger.info(
472
480
  f"{padding(depth)}{LOGGER_PREFIX} Skipping select node generation for {concept.address} "
@@ -513,13 +521,15 @@ def gen_select_node(
513
521
  [c.address in [x.address for x in p.partial_concepts] for p in parents]
514
522
  )
515
523
  ]
516
- force_group = False
524
+ force_group = None
525
+ inferred_grain = sum([x.grain for x in parents if x.grain], Grain())
517
526
  for candidate in parents:
518
527
  if candidate.grain and not candidate.grain.issubset(target_grain):
519
528
  force_group = True
520
529
  if len(parents) == 1:
521
530
  candidate = parents[0]
522
531
  else:
532
+
523
533
  candidate = MergeNode(
524
534
  output_concepts=[concept] + found,
525
535
  input_concepts=[concept] + found,
@@ -528,13 +538,13 @@ def gen_select_node(
528
538
  parents=parents,
529
539
  depth=depth,
530
540
  partial_concepts=all_partial,
531
- grain=sum([x.grain for x in parents if x.grain], Grain()),
541
+ grain=inferred_grain,
532
542
  )
533
543
  candidate.depth += 1
534
- source_grain = candidate.grain
544
+ # source_grain = candidate.grain
535
545
  if force_group:
536
546
  logger.info(
537
- f"{padding(depth)}{LOGGER_PREFIX} datasource grain {source_grain} does not match target grain {target_grain} for select, adding group node"
547
+ f"{padding(depth)}{LOGGER_PREFIX} datasource grain {inferred_grain} does not match target grain {target_grain} for select, adding group node"
538
548
  )
539
549
  return GroupNode(
540
550
  output_concepts=candidate.output_concepts,
@@ -545,6 +555,10 @@ def gen_select_node(
545
555
  depth=depth,
546
556
  partial_concepts=candidate.partial_concepts,
547
557
  )
558
+ else:
559
+ logger.info(
560
+ f"{padding(depth)}{LOGGER_PREFIX} datasource grain {inferred_grain} matches target grain {target_grain} for select, returning without group"
561
+ )
548
562
  return candidate
549
563
 
550
564
  if not accept_partial_optional:
@@ -2,7 +2,11 @@ from typing import List
2
2
 
3
3
 
4
4
  from trilogy.core.models import Concept, Function
5
- from trilogy.core.processing.nodes import UnnestNode, History
5
+ from trilogy.core.processing.nodes import SelectNode, UnnestNode, History, StrategyNode
6
+ from trilogy.core.processing.utility import padding
7
+ from trilogy.constants import logger
8
+
9
+ LOGGER_PREFIX = "[GEN_ROWSET_NODE]"
6
10
 
7
11
 
8
12
  def gen_unnest_node(
@@ -13,7 +17,7 @@ def gen_unnest_node(
13
17
  depth: int,
14
18
  source_concepts,
15
19
  history: History | None = None,
16
- ) -> UnnestNode | None:
20
+ ) -> StrategyNode | None:
17
21
  arguments = []
18
22
  if isinstance(concept.lineage, Function):
19
23
  arguments = concept.lineage.concept_arguments
@@ -26,8 +30,12 @@ def gen_unnest_node(
26
30
  history=history,
27
31
  )
28
32
  if not parent:
33
+ logger.info(
34
+ f"{padding(depth)}{LOGGER_PREFIX} could not find unnest node parents"
35
+ )
29
36
  return None
30
- return UnnestNode(
37
+
38
+ base = UnnestNode(
31
39
  unnest_concept=concept,
32
40
  input_concepts=arguments + local_optional,
33
41
  output_concepts=[concept] + local_optional,
@@ -35,3 +43,16 @@ def gen_unnest_node(
35
43
  g=g,
36
44
  parents=([parent] if (arguments or local_optional) else []),
37
45
  )
46
+ # we need to sometimes nest an unnest node,
47
+ # as unnest operations are not valid in all situations
48
+ # TODO: inline this node when we can detect it's safe
49
+ new = SelectNode(
50
+ input_concepts=[concept] + local_optional,
51
+ output_concepts=[concept] + local_optional,
52
+ environment=environment,
53
+ g=g,
54
+ parents=[base],
55
+ )
56
+ qds = new.resolve()
57
+ assert qds.source_map[concept.address] == {base.resolve()}
58
+ return new
@@ -59,19 +59,21 @@ def gen_window_node(
59
59
  parents=[
60
60
  parent_node,
61
61
  ],
62
+ depth=depth,
62
63
  )
63
64
  window_node = MergeNode(
64
65
  parents=[_window_node],
65
66
  environment=environment,
66
67
  g=g,
67
- input_concepts=_window_node.input_concepts,
68
+ input_concepts=[concept] + _window_node.input_concepts,
68
69
  output_concepts=_window_node.output_concepts,
69
70
  grain=_window_node.grain,
70
71
  force_group=False,
72
+ depth=depth,
71
73
  )
72
74
  if not local_optional:
73
75
  return window_node
74
- logger.info(f"{padding(depth)}{LOGGER_PREFIX} group node requires enrichment")
76
+ logger.info(f"{padding(depth)}{LOGGER_PREFIX} window node requires enrichment")
75
77
  return gen_enrichment_node(
76
78
  window_node,
77
79
  join_keys=concept_to_relevant_joins(parent_concepts),
@@ -1,7 +1,7 @@
1
1
  from .filter_node import FilterNode
2
2
  from .group_node import GroupNode
3
3
  from .merge_node import MergeNode
4
- from .select_node_v2 import SelectNode, StaticSelectNode, ConstantNode
4
+ from .select_node_v2 import SelectNode, ConstantNode
5
5
  from .window_node import WindowNode
6
6
  from .base_node import StrategyNode, NodeJoin
7
7
  from .unnest_node import UnnestNode
@@ -37,10 +37,12 @@ class History(BaseModel):
37
37
  raise ValueError(
38
38
  f"Parent key {parent_key} is the same as the current key {key}"
39
39
  )
40
- return self.history.get(
41
- key,
42
- False,
43
- )
40
+ if key in self.history:
41
+ node = self.history[key]
42
+ if node:
43
+ return node.copy()
44
+ return node
45
+ return False
44
46
 
45
47
  def log_start(
46
48
  self,
@@ -125,7 +127,6 @@ __all__ = [
125
127
  "GroupNode",
126
128
  "MergeNode",
127
129
  "SelectNode",
128
- "StaticSelectNode",
129
130
  "WindowNode",
130
131
  "StrategyNode",
131
132
  "NodeJoin",
@@ -17,6 +17,7 @@ from trilogy.core.models import (
17
17
  from trilogy.core.enums import Purpose, JoinType, PurposeLineage, Granularity
18
18
  from trilogy.utility import unique
19
19
  from dataclasses import dataclass
20
+ from trilogy.constants import logger
20
21
 
21
22
 
22
23
  def concept_list_to_grain(
@@ -55,11 +56,18 @@ def resolve_concept_map(
55
56
  defaultdict(set)
56
57
  )
57
58
  full_addresses = {c.address for c in full_joins} if full_joins else set()
59
+ inherited = set([t.address for t in inherited_inputs])
58
60
  for input in inputs:
59
61
  for concept in input.output_concepts:
62
+ logger.info(concept.address)
60
63
  if concept.address not in input.non_partial_concept_addresses:
61
64
  continue
62
- if concept.address not in [t.address for t in inherited_inputs]:
65
+ if concept.address not in inherited:
66
+ continue
67
+ if (
68
+ isinstance(input, QueryDatasource)
69
+ and concept.address in input.hidden_concepts
70
+ ):
63
71
  continue
64
72
  if concept.address in full_addresses:
65
73
  concept_map[concept.address].add(input)
@@ -71,11 +79,16 @@ def resolve_concept_map(
71
79
  for concept in input.output_concepts:
72
80
  if concept.address not in [t.address for t in inherited_inputs]:
73
81
  continue
82
+ if (
83
+ isinstance(input, QueryDatasource)
84
+ and concept.address in input.hidden_concepts
85
+ ):
86
+ continue
74
87
  if len(concept_map.get(concept.address, [])) == 0:
75
88
  concept_map[concept.address].add(input)
76
89
  # this adds our new derived metrics, which are not created in this CTE
77
90
  for target in targets:
78
- if target not in inherited_inputs:
91
+ if target.address not in inherited:
79
92
  # an empty source means it is defined in this CTE
80
93
  concept_map[target.address] = set()
81
94
  return concept_map
@@ -108,6 +121,8 @@ class StrategyNode:
108
121
  force_group: bool | None = None,
109
122
  grain: Optional[Grain] = None,
110
123
  hidden_concepts: List[Concept] | None = None,
124
+ existence_concepts: List[Concept] | None = None,
125
+ virtual_output_concepts: List[Concept] | None = None,
111
126
  ):
112
127
  self.input_concepts: List[Concept] = (
113
128
  unique(input_concepts, "address") if input_concepts else []
@@ -131,6 +146,8 @@ class StrategyNode:
131
146
  self.force_group = force_group
132
147
  self.tainted = False
133
148
  self.hidden_concepts = hidden_concepts or []
149
+ self.existence_concepts = existence_concepts or []
150
+ self.virtual_output_concepts = virtual_output_concepts or []
134
151
  for parent in self.parents:
135
152
  if not parent:
136
153
  raise SyntaxError("Unresolvable parent")
@@ -162,12 +179,11 @@ class StrategyNode:
162
179
  p.resolve() for p in self.parents
163
180
  ]
164
181
 
165
- # if conditional:
166
- # for condition in conditions[1:]:
167
- # conditional += condition
168
182
  grain = Grain(components=self.output_concepts)
169
183
  source_map = resolve_concept_map(
170
- parent_sources, self.output_concepts, self.input_concepts
184
+ parent_sources,
185
+ self.output_concepts,
186
+ self.input_concepts + self.existence_concepts,
171
187
  )
172
188
  return QueryDatasource(
173
189
  input_concepts=self.input_concepts,
@@ -197,6 +213,24 @@ class StrategyNode:
197
213
  self.resolution_cache = qds
198
214
  return qds
199
215
 
216
+ def copy(self) -> "StrategyNode":
217
+ return self.__class__(
218
+ input_concepts=list(self.input_concepts),
219
+ output_concepts=list(self.output_concepts),
220
+ environment=self.environment,
221
+ g=self.g,
222
+ whole_grain=self.whole_grain,
223
+ parents=list(self.parents),
224
+ partial_concepts=list(self.partial_concepts),
225
+ depth=self.depth,
226
+ conditions=self.conditions,
227
+ force_group=self.force_group,
228
+ grain=self.grain,
229
+ hidden_concepts=list(self.hidden_concepts),
230
+ existence_concepts=list(self.existence_concepts),
231
+ virtual_output_concepts=list(self.virtual_output_concepts),
232
+ )
233
+
200
234
 
201
235
  @dataclass
202
236
  class NodeJoin:
@@ -33,7 +33,7 @@ class FilterNode(StrategyNode):
33
33
  depth: int = 0,
34
34
  conditions: Conditional | Comparison | Parenthetical | None = None,
35
35
  partial_concepts: List[Concept] | None = None,
36
- force_group: bool = False,
36
+ force_group: bool | None = False,
37
37
  ):
38
38
  super().__init__(
39
39
  output_concepts=output_concepts,
@@ -47,3 +47,17 @@ class FilterNode(StrategyNode):
47
47
  partial_concepts=partial_concepts,
48
48
  force_group=force_group,
49
49
  )
50
+
51
+ def copy(self) -> "FilterNode":
52
+ return FilterNode(
53
+ input_concepts=list(self.input_concepts),
54
+ output_concepts=list(self.output_concepts),
55
+ environment=self.environment,
56
+ g=self.g,
57
+ whole_grain=self.whole_grain,
58
+ parents=self.parents,
59
+ depth=self.depth,
60
+ conditions=self.conditions,
61
+ partial_concepts=list(self.partial_concepts),
62
+ force_group=self.force_group,
63
+ )
@@ -33,6 +33,7 @@ class GroupNode(StrategyNode):
33
33
  parents: List["StrategyNode"] | None = None,
34
34
  depth: int = 0,
35
35
  partial_concepts: Optional[List[Concept]] = None,
36
+ force_group: bool | None = None,
36
37
  ):
37
38
  super().__init__(
38
39
  input_concepts=input_concepts,
@@ -43,6 +44,7 @@ class GroupNode(StrategyNode):
43
44
  parents=parents,
44
45
  depth=depth,
45
46
  partial_concepts=partial_concepts,
47
+ force_group=force_group,
46
48
  )
47
49
 
48
50
  def _resolve(self) -> QueryDatasource:
@@ -57,7 +59,11 @@ class GroupNode(StrategyNode):
57
59
 
58
60
  # dynamically select if we need to group
59
61
  # because sometimes, we are already at required grain
60
- if comp_grain == grain and self.output_lcl == self.input_lcl:
62
+ if (
63
+ comp_grain == grain
64
+ and self.output_lcl == self.input_lcl
65
+ and self.force_group is not True
66
+ ):
61
67
  # if there is no group by, and inputs equal outputs
62
68
  # return the parent
63
69
  logger.info(
@@ -111,3 +117,16 @@ class GroupNode(StrategyNode):
111
117
  partial_concepts=self.partial_concepts,
112
118
  condition=self.conditions,
113
119
  )
120
+
121
+ def copy(self) -> "GroupNode":
122
+ return GroupNode(
123
+ input_concepts=list(self.input_concepts),
124
+ output_concepts=list(self.output_concepts),
125
+ environment=self.environment,
126
+ g=self.g,
127
+ whole_grain=self.whole_grain,
128
+ parents=self.parents,
129
+ depth=self.depth,
130
+ partial_concepts=list(self.partial_concepts),
131
+ force_group=self.force_group,
132
+ )