pytrilogy 0.0.3.93__py3-none-any.whl → 0.0.3.95__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 (39) hide show
  1. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/METADATA +170 -145
  2. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/RECORD +38 -34
  3. trilogy/__init__.py +1 -1
  4. trilogy/authoring/__init__.py +4 -0
  5. trilogy/core/enums.py +13 -0
  6. trilogy/core/env_processor.py +21 -10
  7. trilogy/core/environment_helpers.py +111 -0
  8. trilogy/core/exceptions.py +21 -1
  9. trilogy/core/functions.py +6 -1
  10. trilogy/core/graph_models.py +60 -67
  11. trilogy/core/internal.py +18 -0
  12. trilogy/core/models/author.py +16 -25
  13. trilogy/core/models/build.py +5 -4
  14. trilogy/core/models/core.py +3 -0
  15. trilogy/core/models/environment.py +28 -0
  16. trilogy/core/models/execute.py +7 -0
  17. trilogy/core/processing/node_generators/node_merge_node.py +30 -28
  18. trilogy/core/processing/node_generators/select_helpers/datasource_injection.py +25 -11
  19. trilogy/core/processing/node_generators/select_merge_node.py +68 -82
  20. trilogy/core/query_processor.py +2 -1
  21. trilogy/core/statements/author.py +18 -3
  22. trilogy/core/statements/common.py +0 -10
  23. trilogy/core/statements/execute.py +71 -16
  24. trilogy/core/validation/__init__.py +0 -0
  25. trilogy/core/validation/common.py +109 -0
  26. trilogy/core/validation/concept.py +122 -0
  27. trilogy/core/validation/datasource.py +192 -0
  28. trilogy/core/validation/environment.py +71 -0
  29. trilogy/dialect/base.py +40 -21
  30. trilogy/dialect/sql_server.py +3 -1
  31. trilogy/engine.py +25 -7
  32. trilogy/executor.py +145 -83
  33. trilogy/parsing/parse_engine.py +35 -4
  34. trilogy/parsing/trilogy.lark +11 -5
  35. trilogy/core/processing/node_generators/select_merge_node_v2.py +0 -792
  36. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/WHEEL +0 -0
  37. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/entry_points.txt +0 -0
  38. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/licenses/LICENSE.md +0 -0
  39. {pytrilogy-0.0.3.93.dist-info → pytrilogy-0.0.3.95.dist-info}/top_level.txt +0 -0
@@ -20,7 +20,8 @@ def add_concept(
20
20
  if node_name in seen:
21
21
  return
22
22
  seen.add(node_name)
23
- g.add_node(concept)
23
+ g.concepts[node_name] = concept
24
+ g.add_node(node_name)
24
25
  if concept.concept_arguments:
25
26
  for source in concept.concept_arguments:
26
27
  if not isinstance(source, BuildConcept):
@@ -28,9 +29,10 @@ def add_concept(
28
29
  f"Invalid non-build concept {source} passed into graph generation from {concept}"
29
30
  )
30
31
  generic = get_default_grain_concept(source, default_concept_graph)
32
+ generic_node = concept_to_node(generic)
31
33
  add_concept(generic, g, concept_mapping, default_concept_graph, seen)
32
34
 
33
- g.add_edge(generic, node_name)
35
+ g.add_edge(generic_node, node_name, fast=True)
34
36
  for ps_address in concept.pseudonyms:
35
37
  if ps_address not in concept_mapping:
36
38
  raise SyntaxError(f"Concept {concept} has invalid pseudonym {ps_address}")
@@ -44,8 +46,10 @@ def add_concept(
44
46
  continue
45
47
  if pseudonym_node.split("@")[0] == node_name.split("@")[0]:
46
48
  continue
47
- g.add_edge(pseudonym_node, node_name, pseudonym=True)
48
- g.add_edge(node_name, pseudonym_node, pseudonym=True)
49
+ g.add_edge(pseudonym_node, node_name, fast=True)
50
+ g.add_edge(node_name, pseudonym_node, fast=True)
51
+ g.pseudonyms.add((pseudonym_node, node_name))
52
+ g.pseudonyms.add((node_name, pseudonym_node))
49
53
  add_concept(pseudonym, g, concept_mapping, default_concept_graph, seen)
50
54
 
51
55
 
@@ -80,20 +84,27 @@ def generate_adhoc_graph(
80
84
 
81
85
  for dataset in datasources:
82
86
  node = datasource_to_node(dataset)
83
- g.add_node(dataset, type="datasource", datasource=dataset)
87
+ g.add_datasource_node(node, dataset)
84
88
  for concept in dataset.concepts:
89
+ cnode = concept_to_node(concept)
90
+ g.concepts[cnode] = concept
91
+ g.add_node(cnode)
85
92
  if restrict_to_listed:
86
- if concept_to_node(concept) not in g.nodes:
93
+ if cnode not in g.nodes:
87
94
  continue
88
- g.add_edge(node, concept)
89
- g.add_edge(concept, node)
95
+ g.add_edge(node, cnode, fast=True)
96
+ g.add_edge(cnode, node, fast=True)
90
97
  # if there is a key on a table at a different grain
91
98
  # add an FK edge to the canonical source, if it exists
92
99
  # for example, order ID on order product table
93
100
  default = get_default_grain_concept(concept, default_concept_graph)
101
+
94
102
  if concept != default:
95
- g.add_edge(concept, default)
96
- g.add_edge(default, concept)
103
+ dcnode = concept_to_node(default)
104
+ g.concepts[dcnode] = default
105
+ g.add_node(dcnode)
106
+ g.add_edge(cnode, dcnode, fast=True)
107
+ g.add_edge(dcnode, cnode, fast=True)
97
108
  return g
98
109
 
99
110
 
@@ -169,6 +169,112 @@ def generate_key_concepts(concept: Concept, environment: Environment):
169
169
  environment.add_concept(new_concept, add_derived=False)
170
170
 
171
171
 
172
+ def remove_date_concepts(concept: Concept, environment: Environment):
173
+ """Remove auto-generated date-related concepts for the given concept"""
174
+ date_suffixes = ["month", "year", "quarter", "day", "day_of_week"]
175
+ grain_suffixes = ["month_start", "year_start"]
176
+
177
+ for suffix in date_suffixes + grain_suffixes:
178
+ address = concept.address + f".{suffix}"
179
+ if address in environment.concepts:
180
+ derived_concept = environment.concepts[address]
181
+ # Only remove if it was auto-derived from this concept
182
+ if (
183
+ derived_concept.metadata
184
+ and derived_concept.metadata.concept_source
185
+ == ConceptSource.AUTO_DERIVED
186
+ and derived_concept.keys
187
+ and concept.address in derived_concept.keys
188
+ ):
189
+ environment.remove_concept(address)
190
+
191
+
192
+ def remove_datetime_concepts(concept: Concept, environment: Environment):
193
+ """Remove auto-generated datetime-related concepts for the given concept"""
194
+ datetime_suffixes = ["date", "hour", "minute", "second"]
195
+
196
+ for suffix in datetime_suffixes:
197
+ address = concept.address + f".{suffix}"
198
+ if address in environment.concepts:
199
+ derived_concept = environment.concepts[address]
200
+ # Only remove if it was auto-derived from this concept
201
+ if (
202
+ derived_concept.metadata
203
+ and derived_concept.metadata.concept_source
204
+ == ConceptSource.AUTO_DERIVED
205
+ and derived_concept.keys
206
+ and concept.address in derived_concept.keys
207
+ ):
208
+ environment.remove_concept(address)
209
+
210
+
211
+ def remove_key_concepts(concept: Concept, environment: Environment):
212
+ """Remove auto-generated key-related concepts for the given concept"""
213
+ key_suffixes = ["count"]
214
+
215
+ for suffix in key_suffixes:
216
+ address = concept.address + f".{suffix}"
217
+ if address in environment.concepts:
218
+ derived_concept = environment.concepts[address]
219
+ if (
220
+ derived_concept.metadata
221
+ and derived_concept.metadata.concept_source
222
+ == ConceptSource.AUTO_DERIVED
223
+ ):
224
+ environment.remove_concept(address)
225
+
226
+
227
+ def remove_struct_concepts(concept: Concept, environment: Environment):
228
+ """Remove auto-generated struct field concepts for the given concept"""
229
+ if not isinstance(concept.datatype, StructType):
230
+ return
231
+
232
+ target_namespace = (
233
+ environment.namespace + "." + concept.name
234
+ if environment.namespace and environment.namespace != DEFAULT_NAMESPACE
235
+ else concept.name
236
+ )
237
+
238
+ # Get all concepts in the target namespace that were auto-derived
239
+ concepts_to_remove = []
240
+ for address, derived_concept in environment.concepts.items():
241
+ if (
242
+ derived_concept.namespace == target_namespace
243
+ and derived_concept.metadata
244
+ and derived_concept.metadata.concept_source == ConceptSource.AUTO_DERIVED
245
+ and isinstance(derived_concept.lineage, Function)
246
+ and derived_concept.lineage.operator == FunctionType.ATTR_ACCESS
247
+ and len(derived_concept.lineage.arguments) >= 1
248
+ and derived_concept.lineage.arguments[0] == concept.reference
249
+ ):
250
+ concepts_to_remove.append(address)
251
+
252
+ for address in concepts_to_remove:
253
+ environment.remove_concept(address)
254
+
255
+
256
+ def remove_related_concepts(concept: Concept, environment: Environment):
257
+ """Remove all auto-generated concepts that were derived from the given concept"""
258
+
259
+ # Remove key-related concepts
260
+ if concept.purpose == Purpose.KEY:
261
+ remove_key_concepts(concept, environment)
262
+
263
+ # Remove datatype-specific concepts
264
+ if concept.datatype == DataType.DATE:
265
+ remove_date_concepts(concept, environment)
266
+ elif concept.datatype == DataType.DATETIME:
267
+ remove_date_concepts(concept, environment)
268
+ remove_datetime_concepts(concept, environment)
269
+ elif concept.datatype == DataType.TIMESTAMP:
270
+ remove_date_concepts(concept, environment)
271
+ remove_datetime_concepts(concept, environment)
272
+
273
+ # Remove struct field concepts
274
+ if isinstance(concept.datatype, StructType):
275
+ remove_struct_concepts(concept, environment)
276
+
277
+
172
278
  def generate_related_concepts(
173
279
  concept: Concept,
174
280
  environment: Environment,
@@ -183,6 +289,7 @@ def generate_related_concepts(
183
289
  if concept.datatype == DataType.DATE and add_derived:
184
290
  generate_date_concepts(concept, environment)
185
291
  elif concept.datatype == DataType.DATETIME and add_derived:
292
+
186
293
  generate_date_concepts(concept, environment)
187
294
  generate_datetime_concepts(concept, environment)
188
295
  elif concept.datatype == DataType.TIMESTAMP and add_derived:
@@ -203,6 +310,10 @@ def generate_related_concepts(
203
310
  ),
204
311
  lineage=AttrAccess([concept.reference, key], environment=environment),
205
312
  grain=concept.grain,
313
+ metadata=Metadata(
314
+ concept_source=ConceptSource.AUTO_DERIVED,
315
+ ),
316
+ keys=concept.keys,
206
317
  )
207
318
  environment.add_concept(auto, meta=meta)
208
319
  if isinstance(value, Concept):
@@ -1,4 +1,4 @@
1
- from typing import List
1
+ from typing import List, Sequence
2
2
 
3
3
 
4
4
  class UndefinedConceptException(Exception):
@@ -24,6 +24,26 @@ class NoDatasourceException(UnresolvableQueryException):
24
24
  pass
25
25
 
26
26
 
27
+ class ModelValidationError(Exception):
28
+ def __init__(
29
+ self,
30
+ message,
31
+ children: Sequence["ModelValidationError"] | None = None,
32
+ **kwargs
33
+ ):
34
+ super().__init__(self, message, **kwargs)
35
+ self.message = message
36
+ self.children = children
37
+
38
+
39
+ class DatasourceModelValidationError(ModelValidationError):
40
+ pass
41
+
42
+
43
+ class ConceptModelValidationError(ModelValidationError):
44
+ pass
45
+
46
+
27
47
  class AmbiguousRelationshipResolutionException(UnresolvableQueryException):
28
48
  def __init__(self, message, parents: List[set[str]]):
29
49
  super().__init__(self, message)
trilogy/core/functions.py CHANGED
@@ -380,7 +380,12 @@ FUNCTION_REGISTRY: dict[FunctionType, FunctionConfig] = {
380
380
  ),
381
381
  FunctionType.CURRENT_DATETIME: FunctionConfig(
382
382
  output_purpose=Purpose.CONSTANT,
383
- output_type=DataType.DATE,
383
+ output_type=DataType.DATETIME,
384
+ arg_count=0,
385
+ ),
386
+ FunctionType.CURRENT_TIMESTAMP: FunctionConfig(
387
+ output_purpose=Purpose.CONSTANT,
388
+ output_type=DataType.TIMESTAMP,
384
389
  arg_count=0,
385
390
  ),
386
391
  FunctionType.BOOL: FunctionConfig(
@@ -1,50 +1,48 @@
1
+ from typing import Union
2
+
1
3
  import networkx as nx
2
4
 
3
5
  from trilogy.core.models.build import BuildConcept, BuildDatasource, BuildWhereClause
4
6
 
5
7
 
6
8
  def get_graph_exact_match(
7
- g: nx.DiGraph, accept_partial: bool, conditions: BuildWhereClause | None
9
+ g: Union[nx.DiGraph, "ReferenceGraph"],
10
+ accept_partial: bool,
11
+ conditions: BuildWhereClause | None,
8
12
  ) -> set[str]:
9
- datasources: dict[str, BuildDatasource | list[BuildDatasource]] = (
10
- nx.get_node_attributes(g, "datasource")
11
- )
12
13
  exact: set[str] = set()
13
- for node in g.nodes:
14
- if node in datasources:
15
- ds = datasources[node]
16
- if isinstance(ds, list):
17
- exact.add(node)
18
- continue
19
-
20
- if not conditions and not ds.non_partial_for:
21
- exact.add(node)
14
+ for node, ds in g.datasources.items():
15
+ if isinstance(ds, list):
16
+ exact.add(node)
17
+ continue
18
+
19
+ if not conditions and not ds.non_partial_for:
20
+ exact.add(node)
21
+ continue
22
+ elif not conditions and accept_partial and ds.non_partial_for:
23
+ exact.add(node)
24
+ continue
25
+ elif conditions:
26
+ if not ds.non_partial_for:
22
27
  continue
23
- elif not conditions and accept_partial and ds.non_partial_for:
28
+ if ds.non_partial_for and conditions == ds.non_partial_for:
24
29
  exact.add(node)
25
30
  continue
26
- elif conditions:
27
- if not ds.non_partial_for:
28
- continue
29
- if ds.non_partial_for and conditions == ds.non_partial_for:
30
- exact.add(node)
31
- continue
32
- else:
33
- continue
31
+ else:
32
+ continue
34
33
 
35
34
  return exact
36
35
 
37
36
 
38
37
  def prune_sources_for_conditions(
39
- g: nx.DiGraph,
38
+ g: "ReferenceGraph",
40
39
  accept_partial: bool,
41
40
  conditions: BuildWhereClause | None,
42
41
  ):
43
-
44
42
  complete = get_graph_exact_match(g, accept_partial, conditions)
45
43
  to_remove = []
46
- for node in g.nodes:
47
- if node.startswith("ds~") and node not in complete:
44
+ for node in g.datasources:
45
+ if node not in complete:
48
46
  to_remove.append(node)
49
47
 
50
48
  for node in to_remove:
@@ -68,46 +66,41 @@ def datasource_to_node(input: BuildDatasource) -> str:
68
66
  class ReferenceGraph(nx.DiGraph):
69
67
  def __init__(self, *args, **kwargs):
70
68
  super().__init__(*args, **kwargs)
71
-
72
- def add_node(self, node_for_adding, **attr):
73
- if isinstance(node_for_adding, BuildConcept):
74
- node_name = concept_to_node(node_for_adding)
75
- # if node_name in self.nodes:
76
- # return
77
- attr["type"] = "concept"
78
- attr["concept"] = node_for_adding
79
- attr["grain"] = node_for_adding.grain
80
- elif isinstance(node_for_adding, BuildDatasource):
81
- node_name = datasource_to_node(node_for_adding)
82
- # if node_name in self.nodes:
83
- # return
84
- attr["type"] = "datasource"
85
- attr["ds"] = node_for_adding
86
- attr["grain"] = node_for_adding.grain
87
- else:
88
- node_name = node_for_adding
69
+ self.concepts: dict[str, BuildConcept] = {}
70
+ self.datasources: dict[str, BuildDatasource] = {}
71
+ self.pseudonyms: set[tuple[str, str]] = set()
72
+
73
+ def copy(self):
74
+ g = ReferenceGraph()
75
+ g.concepts = self.concepts.copy()
76
+ g.datasources = self.datasources.copy()
77
+ g.pseudonyms = {*self.pseudonyms}
78
+ # g.add_nodes_from(self.nodes(data=True))
79
+ for node in self.nodes:
80
+ g.add_node(node, fast=True)
81
+ for edge in self.edges:
82
+ g.add_edge(edge[0], edge[1], fast=True)
83
+ # g.add_edges_from(self.edges(data=True))
84
+ return g
85
+
86
+ def remove_node(self, n):
87
+ if n in self.concepts:
88
+ del self.concepts[n]
89
+ if n in self.datasources:
90
+ del self.datasources[n]
91
+ super().remove_node(n)
92
+
93
+ def add_node(self, node_for_adding, fast: bool = False, **attr):
94
+ if fast:
95
+ return super().add_node(node_for_adding, **attr)
96
+ node_name = node_for_adding
97
+ if attr.get("datasource"):
98
+ self.datasources[node_name] = attr["datasource"]
89
99
  super().add_node(node_name, **attr)
90
100
 
91
- def add_edge(self, u_of_edge, v_of_edge, **attr):
92
- if isinstance(u_of_edge, BuildConcept):
93
- orig = u_of_edge
94
- u_of_edge = concept_to_node(u_of_edge)
95
- if u_of_edge not in self.nodes:
96
- self.add_node(orig)
97
- elif isinstance(u_of_edge, BuildDatasource):
98
- orig = u_of_edge
99
- u_of_edge = datasource_to_node(u_of_edge)
100
- if u_of_edge not in self.nodes:
101
- self.add_node(orig)
102
-
103
- if isinstance(v_of_edge, BuildConcept):
104
- orig = v_of_edge
105
- v_of_edge = concept_to_node(v_of_edge)
106
- if v_of_edge not in self.nodes:
107
- self.add_node(orig)
108
- elif isinstance(v_of_edge, BuildDatasource):
109
- orig = v_of_edge
110
- v_of_edge = datasource_to_node(v_of_edge)
111
- if v_of_edge not in self.nodes:
112
- self.add_node(orig)
113
- super().add_edge(u_of_edge, v_of_edge, **attr)
101
+ def add_datasource_node(self, node_name, datasource):
102
+ self.datasources[node_name] = datasource
103
+ super().add_node(node_name, datasource=datasource)
104
+
105
+ def add_edge(self, u_of_edge, v_of_edge, fast: bool = False, **attr):
106
+ return super().add_edge(u_of_edge, v_of_edge, **attr)
trilogy/core/internal.py CHANGED
@@ -64,4 +64,22 @@ DEFAULT_CONCEPTS = {
64
64
  granularity=Granularity.SINGLE_ROW,
65
65
  derivation=Derivation.CONSTANT,
66
66
  ),
67
+ "label": Concept(
68
+ name="label",
69
+ namespace=INTERNAL_NAMESPACE,
70
+ datatype=DataType.STRING,
71
+ purpose=Purpose.KEY,
72
+ grain=Grain(),
73
+ granularity=Granularity.SINGLE_ROW,
74
+ derivation=Derivation.CONSTANT,
75
+ ),
76
+ "expected": Concept(
77
+ name="expected_value",
78
+ namespace=INTERNAL_NAMESPACE,
79
+ datatype=DataType.STRING,
80
+ purpose=Purpose.KEY,
81
+ grain=Grain(),
82
+ granularity=Granularity.SINGLE_ROW,
83
+ derivation=Derivation.CONSTANT,
84
+ ),
67
85
  }
@@ -902,7 +902,11 @@ class Concept(Addressable, DataTyped, ConceptArgs, Mergeable, Namespaced, BaseMo
902
902
 
903
903
  @property
904
904
  def is_aggregate(self):
905
- return self.calculate_is_aggregate(self.lineage)
905
+ base = getattr(self, "_is_aggregate", None)
906
+ if base:
907
+ return base
908
+ setattr(self, "_is_aggregate", self.calculate_is_aggregate(self.lineage))
909
+ return self._is_aggregate
906
910
 
907
911
  def with_merge(self, source: Self, target: Self, modifiers: List[Modifier]) -> Self:
908
912
  if self.address == source.address:
@@ -1069,18 +1073,25 @@ class Concept(Addressable, DataTyped, ConceptArgs, Mergeable, Namespaced, BaseMo
1069
1073
  final_grain = grain if not self.grain.components else self.grain
1070
1074
  keys = self.keys
1071
1075
 
1072
- if self.is_aggregate and isinstance(new_lineage, Function) and grain.components:
1076
+ if self.is_aggregate and grain.components and isinstance(new_lineage, Function):
1073
1077
  grain_components: list[ConceptRef | Concept] = [
1074
1078
  environment.concepts[c].reference for c in grain.components
1075
1079
  ]
1076
- new_lineage = AggregateWrapper(function=new_lineage, by=grain_components)
1080
+ new_lineage = AggregateWrapper.model_construct(
1081
+ function=new_lineage, by=grain_components
1082
+ )
1077
1083
  final_grain = grain
1078
1084
  keys = set(grain.components)
1079
- elif isinstance(new_lineage, AggregateWrapper) and not new_lineage.by and grain:
1085
+ elif (
1086
+ grain
1087
+ and new_lineage
1088
+ and isinstance(new_lineage, AggregateWrapper)
1089
+ and not new_lineage.by
1090
+ ):
1080
1091
  grain_components = [
1081
1092
  environment.concepts[c].reference for c in grain.components
1082
1093
  ]
1083
- new_lineage = AggregateWrapper(
1094
+ new_lineage = AggregateWrapper.model_construct(
1084
1095
  function=new_lineage.function, by=grain_components
1085
1096
  )
1086
1097
  final_grain = grain
@@ -1670,15 +1681,6 @@ class Function(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1670
1681
  def datatype(self):
1671
1682
  return self.output_datatype
1672
1683
 
1673
- @field_validator("output_datatype")
1674
- @classmethod
1675
- def parse_output_datatype(cls, v, info: ValidationInfo):
1676
- values = info.data
1677
- if values.get("operator") == FunctionType.ATTR_ACCESS:
1678
- if isinstance(v, StructType):
1679
- raise SyntaxError
1680
- return v
1681
-
1682
1684
  @field_validator("arguments", mode="before")
1683
1685
  @classmethod
1684
1686
  def parse_arguments(cls, v, info: ValidationInfo):
@@ -1845,17 +1847,6 @@ class Function(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1845
1847
  base += get_concept_arguments(arg)
1846
1848
  return base
1847
1849
 
1848
- @property
1849
- def output_grain(self):
1850
- # aggregates have an abstract grain
1851
- base_grain = Grain(components=[])
1852
- if self.operator in FunctionClass.AGGREGATE_FUNCTIONS.value:
1853
- return base_grain
1854
- # scalars have implicit grain of all arguments
1855
- for input in self.concept_arguments:
1856
- base_grain += input.grain
1857
- return base_grain
1858
-
1859
1850
 
1860
1851
  class FunctionCallWrapper(
1861
1852
  DataTyped,
@@ -1533,6 +1533,7 @@ class Factory:
1533
1533
  )
1534
1534
  self.local_non_build_concepts: dict[str, Concept] = {}
1535
1535
  self.pseudonym_map = pseudonym_map or get_canonical_pseudonyms(environment)
1536
+ self.build_grain = self.build(self.grain) if self.grain else None
1536
1537
 
1537
1538
  def instantiate_concept(
1538
1539
  self,
@@ -1792,11 +1793,11 @@ class Factory:
1792
1793
  address = base.concept.address
1793
1794
  fetched = (
1794
1795
  self._build_concept(
1795
- self.environment.alias_origin_lookup[address].with_grain(self.grain)
1796
- )
1796
+ self.environment.alias_origin_lookup[address]
1797
+ ).with_grain(self.build_grain)
1797
1798
  if address in self.environment.alias_origin_lookup
1798
- else self._build_concept(
1799
- self.environment.concepts[address].with_grain(self.grain)
1799
+ else self._build_concept(self.environment.concepts[address]).with_grain(
1800
+ self.build_grain
1800
1801
  )
1801
1802
  )
1802
1803
 
@@ -103,6 +103,9 @@ class DataType(Enum):
103
103
  def data_type(self):
104
104
  return self
105
105
 
106
+ def __str__(self) -> str:
107
+ return self.name
108
+
106
109
 
107
110
  class TraitDataType(BaseModel):
108
111
  type: DataType | NumericType | StructType | ArrayType | MapType
@@ -571,6 +571,34 @@ class Environment(BaseModel):
571
571
 
572
572
  return concept
573
573
 
574
+ def remove_concept(
575
+ self,
576
+ concept: Concept | str,
577
+ ) -> bool:
578
+ if self.frozen:
579
+ raise FrozenEnvironmentException(
580
+ "Environment is frozen, cannot remove concepts"
581
+ )
582
+ if isinstance(concept, Concept):
583
+ address = concept.address
584
+ c_instance = concept
585
+ else:
586
+ address = concept
587
+ c_instance_check = self.concepts.get(address)
588
+ if not c_instance_check:
589
+ return False
590
+ c_instance = c_instance_check
591
+ from trilogy.core.environment_helpers import remove_related_concepts
592
+
593
+ remove_related_concepts(c_instance, self)
594
+ if address in self.concepts:
595
+ del self.concepts[address]
596
+ return True
597
+ if address in self.alias_origin_lookup:
598
+ del self.alias_origin_lookup[address]
599
+
600
+ return False
601
+
574
602
  def add_datasource(
575
603
  self,
576
604
  datasource: Datasource,
@@ -23,6 +23,7 @@ from trilogy.core.constants import CONSTANT_DATASET
23
23
  from trilogy.core.enums import (
24
24
  ComparisonOperator,
25
25
  Derivation,
26
+ FunctionClass,
26
27
  FunctionType,
27
28
  JoinType,
28
29
  Modifier,
@@ -375,6 +376,12 @@ class CTE(BaseModel):
375
376
  return check_is_not_in_group(c.lineage.content)
376
377
  if c.derivation == Derivation.CONSTANT:
377
378
  return True
379
+ if (
380
+ c.purpose == Purpose.CONSTANT
381
+ and isinstance(c.lineage, BuildFunction)
382
+ and c.lineage.operator in FunctionClass.AGGREGATE_FUNCTIONS.value
383
+ ):
384
+ return True
378
385
  if c.purpose == Purpose.METRIC:
379
386
  return True
380
387