pytrilogy 0.0.2.55__py3-none-any.whl → 0.0.2.57__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pytrilogy
3
- Version: 0.0.2.55
3
+ Version: 0.0.2.57
4
4
  Summary: Declarative, typed query language that compiles to SQL.
5
5
  Home-page:
6
6
  Author:
@@ -1,43 +1,43 @@
1
- trilogy/__init__.py,sha256=JnykGbcn-V3aG4x6q9pNpBv7a-8Pr93-61ey6CjNHBA,291
1
+ trilogy/__init__.py,sha256=YgiCOJgZfH7Ciz9GfXgtxr5_r5NkhAaRoFEru-cSoQs,291
2
2
  trilogy/compiler.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  trilogy/constants.py,sha256=qZ1d0hoKPPV2HHCoFwPYTVB7b6bXjpWvXd3lE-zEhy8,1494
4
4
  trilogy/engine.py,sha256=yOPnR7XCjWG82Gym_LLZBkYKKJdLCvqdCyt8zguNcnM,1103
5
5
  trilogy/executor.py,sha256=SbReI_xWd081WZeRt_YAyVTdMOGg2XPrsaOKgMS7YUY,15969
6
6
  trilogy/parser.py,sha256=UtuqSiGiCjpMAYgo1bvNq-b7NSzCA5hzbUW31RXaMII,281
7
7
  trilogy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- trilogy/utility.py,sha256=eguES83XhmSOAQSBu5xq4aAXimiZFrxcUu81zDL22ug,707
8
+ trilogy/utility.py,sha256=euQccZLKoYBz0LNg5tzLlvv2YHvXh9HArnYp1V3uXsM,763
9
9
  trilogy/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  trilogy/core/constants.py,sha256=7XaCpZn5mQmjTobbeBn56SzPWq9eMNDfzfsRU-fP0VE,171
11
11
  trilogy/core/enums.py,sha256=6pGjEXNJPB1ngbDQRJjxRi4NmKM8NZQ5-iwnZhrdo5U,7281
12
12
  trilogy/core/env_processor.py,sha256=Pt4lmJfbShBbeSe5M7_FrTk5krrOziiAA__Slnettvc,2585
13
- trilogy/core/environment_helpers.py,sha256=GExsRXghccqKOIiEjkCjtDVDz0PNTE5OSRPFelPa5eU,7279
13
+ trilogy/core/environment_helpers.py,sha256=ugKDnPYQNxKzc1Weq_kj9IVppNdgT8iS1RTS_f5hHxc,7905
14
14
  trilogy/core/ergonomics.py,sha256=ASLDd0RqKWrZiG3XcKHo8nyTjaB_8xfE9t4NZ1UvGpc,1639
15
15
  trilogy/core/exceptions.py,sha256=1c1lQCwSw4_5CQS3q7scOkXU8GQvullJXfPHubprl90,617
16
- trilogy/core/functions.py,sha256=hDlwLxQUskT9iRcIic1lfACQnxMLNM5ASdHRPi0ghyw,10835
16
+ trilogy/core/functions.py,sha256=8auZhInqnY28zg7Kil4LbvDT7jD4JggwS6HzK6ZIemE,10867
17
17
  trilogy/core/graph_models.py,sha256=mameUTiuCajtihDw_2-W218xyJlvTusOWrEKP1yAWgk,2003
18
18
  trilogy/core/internal.py,sha256=FQWbuETKPfzjALMmdXJwlOMlESfm2Z5gmErSsq3BX9c,1173
19
- trilogy/core/models.py,sha256=J1s7EAbJIoHseoz1D1iP1sAbEDNt66cinS2VE-zLp7g,161242
19
+ trilogy/core/models.py,sha256=FO1JUeUN8N3qqIjytLimayNLQWczq0aAYwephZyq7Ec,165389
20
20
  trilogy/core/optimization.py,sha256=Jy3tVJNeqhpK6VSyTvgIWKCao6y-VCZ7mYA69MIF6L0,7989
21
- trilogy/core/query_processor.py,sha256=V-TqybYO0kCY8O7Nk58OBhb7_eRPs_EqAwaQv-EYLSY,18615
21
+ trilogy/core/query_processor.py,sha256=JUtsDh64mWwQHM3HFZMPtVCu-Yw7WsK3cx4NxiMACSM,18584
22
22
  trilogy/core/optimizations/__init__.py,sha256=EBanqTXEzf1ZEYjAneIWoIcxtMDite5-n2dQ5xcfUtg,356
23
23
  trilogy/core/optimizations/base_optimization.py,sha256=P4kF-eCXkBxO-5c6tLHhMZ4ODRH1A04hb_6ovkaVyLw,505
24
24
  trilogy/core/optimizations/inline_constant.py,sha256=c-YHOg6eAufL4EaCf4-0PbY_D4skBHW0ldR55_phsMA,1277
25
25
  trilogy/core/optimizations/inline_datasource.py,sha256=LsngRKBy-LYcx1sfo1-rnDym_ly73YV9WkEngSjpFx8,3943
26
26
  trilogy/core/optimizations/predicate_pushdown.py,sha256=XPWEBv8jXnc0OL2JDPNwFvJ5AtOE7dLzJK0LzdmdZMo,9252
27
27
  trilogy/core/processing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
- trilogy/core/processing/concept_strategies_v3.py,sha256=TrkaE8bY0AU7XiLP47rLqqFqkfiinrNF3v6C6DtZiY4,37474
28
+ trilogy/core/processing/concept_strategies_v3.py,sha256=hgxQ5nrlLYfx02yM--7GN8MoYYX06iLBFujjPoZaxGI,37683
29
29
  trilogy/core/processing/graph_utils.py,sha256=stbYnDxnK-1kbo9L4XNU85FQhWCP-oZYO7LCXhAdC5M,1198
30
- trilogy/core/processing/utility.py,sha256=STqSHP8fWTVmaIUCfHAb9Hke_fzOG2pTbmWIdYS4cvc,18787
30
+ trilogy/core/processing/utility.py,sha256=JpyPScfD8i4CgsTZSR0siWdXVhQDHizBAiQc81d7lbw,19769
31
31
  trilogy/core/processing/node_generators/__init__.py,sha256=s_YV1OYc336DuS9591259qjI_K_CtOCuhkf4t2aOgYs,733
32
- trilogy/core/processing/node_generators/basic_node.py,sha256=dz7i0BSn4qRv6SBIS_JnVAm09-nkNizoAHrznmqnJXY,3074
32
+ trilogy/core/processing/node_generators/basic_node.py,sha256=pExVmLDQK9okXNeC1-jQgDwpj8JWAgQfejd2lMt8L4U,3157
33
33
  trilogy/core/processing/node_generators/common.py,sha256=dHycWu9iiRxH3WIkkyibsnYD5mJfXvdEOhsTvyaO8fg,9128
34
- trilogy/core/processing/node_generators/filter_node.py,sha256=zDgPfj-LkjkLKasH6Obftw-ojxVQi1d984nvxwTLPEU,7634
34
+ trilogy/core/processing/node_generators/filter_node.py,sha256=aWR82yAZOAnUrJejTj6yD4jpqH6cSPzyJMd1V-M0Kj0,7883
35
35
  trilogy/core/processing/node_generators/group_node.py,sha256=k57SVWHSVvTqCd47tyLUGCsSZaP7UQqMCJYTSz1S7oQ,5566
36
- trilogy/core/processing/node_generators/group_to_node.py,sha256=8ToptIWQoJttquEPrRTMvU33jCJQI-VJxVObN8W8QJk,2511
37
- trilogy/core/processing/node_generators/multiselect_node.py,sha256=se-cHRYRPskxq2Wq9bw5LkUFSCN1rhk8_05-OTezLz0,6421
36
+ trilogy/core/processing/node_generators/group_to_node.py,sha256=Hz17vZ1EjKVa275CZPF12FkLdrc916PA5T6OsfgryRQ,2928
37
+ trilogy/core/processing/node_generators/multiselect_node.py,sha256=9bQPla367WT85iWGZxlZM4EWkWFGM0i6jgBuA4O0QvQ,6464
38
38
  trilogy/core/processing/node_generators/node_merge_node.py,sha256=3GzuiTiorFVe9MyLhoz2PDyI0x9XL7bQ8ucEbV54le8,14627
39
- trilogy/core/processing/node_generators/rowset_node.py,sha256=ekrXWFu4ga3VR59Ux870w5gSmzFPC9WjIRuyB4yFqag,5138
40
- trilogy/core/processing/node_generators/select_merge_node.py,sha256=YW0H81IpE9B6f0SK75QH2DVSfr8d3oA9AbbqP44Jhnc,15746
39
+ trilogy/core/processing/node_generators/rowset_node.py,sha256=aSk1Ltv1S6aSRKHpWGjEjgrNTbJXuIXkFiGQVZOyb1o,5139
40
+ trilogy/core/processing/node_generators/select_merge_node.py,sha256=yfNeuc24Ejn7j07szwJif60qmU6OVk3wfa7C2_RJ39k,15996
41
41
  trilogy/core/processing/node_generators/select_node.py,sha256=bjTylBa-vYbmzpuSpphmIo_Oi78YZpI8ppHnN9KDYDk,1795
42
42
  trilogy/core/processing/node_generators/union_node.py,sha256=MfJjF2m0ARl0oUH9QT1awzPv0e3yA3mXK1XqAvUTgKw,2504
43
43
  trilogy/core/processing/node_generators/unnest_node.py,sha256=8El2B1mzC9vIUSk-m94xHvaJwAf5GtCAGfTxGDSiqmU,2229
@@ -45,16 +45,16 @@ trilogy/core/processing/node_generators/window_node.py,sha256=5htRRxaxw6EnS-2TVo
45
45
  trilogy/core/processing/node_generators/select_helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
46
  trilogy/core/processing/node_generators/select_helpers/datasource_injection.py,sha256=hJZS7GT0dl7sK0riweEwVAVRv5LCXOnMD1hF0XY9hpE,6548
47
47
  trilogy/core/processing/nodes/__init__.py,sha256=WNUmYmZF3uqF2qiJ1L7y0u9qiVD9YnluKds0wA5opJE,4813
48
- trilogy/core/processing/nodes/base_node.py,sha256=KFk6sKNS3lEA_JyimHqdry4-QNEntnMO0GBBC_oA1bs,15552
48
+ trilogy/core/processing/nodes/base_node.py,sha256=utIs_c5V7SIRDPrIVlHJ7zD4caRFmffhuJQSM4cVYoY,16104
49
49
  trilogy/core/processing/nodes/filter_node.py,sha256=j7icDAXJ7oFPkHTOQVmm9QbZxrhhYEUGJj2lSiguXKA,2292
50
- trilogy/core/processing/nodes/group_node.py,sha256=C2U4kyfYnM0Gy1b_C5K-uh-s-9Kncr5olFxMuF0G7zQ,7852
51
- trilogy/core/processing/nodes/merge_node.py,sha256=p9KoJmOoPqXlehYWj5pF_VuP5ZESTr5dAjAOQbf66O8,14875
50
+ trilogy/core/processing/nodes/group_node.py,sha256=-dx_g1b6j3zygLKWp8yPYtnFxwLtKT9wHv62-U7GBZQ,7273
51
+ trilogy/core/processing/nodes/merge_node.py,sha256=kU4JChblGEoule-qKyXAwlQ2UtLXZsvugL50iUVQvQQ,14760
52
52
  trilogy/core/processing/nodes/select_node_v2.py,sha256=t3ln9Kxeml8mVTnLgtNPvavb5TLTRtfkJ0nyxh7UYUs,8212
53
53
  trilogy/core/processing/nodes/union_node.py,sha256=1QgOWkjJ-ADFdanoRzi0EM5buhuzJbmlda9BAUGp4mM,1352
54
54
  trilogy/core/processing/nodes/unnest_node.py,sha256=0TFANwqVPaVpUR6SF5uweGTlXfEnagXRBBZU6dUwtcY,2101
55
55
  trilogy/core/processing/nodes/window_node.py,sha256=yYwWuOq1Uwm-xEl8lFH_urm-YXaAGAgNhE20MEoD5QQ,1163
56
56
  trilogy/dialect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- trilogy/dialect/base.py,sha256=DR7cHoL5rbRBnsj6PCq5wK8GHH-l5szpKXUaxMqx1Mw,38568
57
+ trilogy/dialect/base.py,sha256=jRHr_LrI0M7Pak3HizuBcbeTnAJ2e0NoYLMpGHXJhUw,38590
58
58
  trilogy/dialect/bigquery.py,sha256=mKC3zoEU232h9RtIXJjqiZ72lWH8a6S28p6wAZKrAfg,2952
59
59
  trilogy/dialect/common.py,sha256=b0E6JqdKaaSzThLiFa9jwUg4YnXahf-3bqmzOn5z-6E,3827
60
60
  trilogy/dialect/config.py,sha256=UiBY2tBbNk9owx-zxP_3lN9lErEUXhXIU_bcXA18AvU,2992
@@ -74,14 +74,14 @@ trilogy/parsing/common.py,sha256=iPpnSkiKUtoSTsfrMCHZOexu9H6-eIQznbVsKNEPbT8,120
74
74
  trilogy/parsing/config.py,sha256=Z-DaefdKhPDmSXLgg5V4pebhSB0h590vI0_VtHnlukI,111
75
75
  trilogy/parsing/exceptions.py,sha256=92E5i2frv5hj9wxObJZsZqj5T6bglvPzvdvco_vW1Zk,38
76
76
  trilogy/parsing/helpers.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
77
- trilogy/parsing/parse_engine.py,sha256=JGL2qm4L14ytkkB4FiwaXvC1EYJSOK9J0eemi6L94Bw,69222
77
+ trilogy/parsing/parse_engine.py,sha256=rxbCR-gIK5EQsHUYCj1r0n73NiKOAt-vJq21-qXVPks,66131
78
78
  trilogy/parsing/render.py,sha256=o4C12a407iZvlRGUJDiuJUezrLLo4QEaLtu60ZQX3gk,16942
79
79
  trilogy/parsing/trilogy.lark,sha256=EazfEvYPuvkPkNjUnVzFi0uD9baavugbSI8CyfawShk,12573
80
80
  trilogy/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
81
  trilogy/scripts/trilogy.py,sha256=DQDW81E5mDMWFP8oPw8q-IyrR2JGxQSDWgUWe2VTSRQ,3731
82
- pytrilogy-0.0.2.55.dist-info/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
83
- pytrilogy-0.0.2.55.dist-info/METADATA,sha256=FFvNA0mm8yeWHphFZm0-LAIoPu_qbPSSYwTzoXVz1yI,8823
84
- pytrilogy-0.0.2.55.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
85
- pytrilogy-0.0.2.55.dist-info/entry_points.txt,sha256=0petKryjvvtEfTlbZC1AuMFumH_WQ9v8A19LvoS6G6c,54
86
- pytrilogy-0.0.2.55.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
87
- pytrilogy-0.0.2.55.dist-info/RECORD,,
82
+ pytrilogy-0.0.2.57.dist-info/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
83
+ pytrilogy-0.0.2.57.dist-info/METADATA,sha256=--PBPmro81sFziRia1QJYSp1zQBGRmEUo46DYa-xBgg,8823
84
+ pytrilogy-0.0.2.57.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
85
+ pytrilogy-0.0.2.57.dist-info/entry_points.txt,sha256=0petKryjvvtEfTlbZC1AuMFumH_WQ9v8A19LvoS6G6c,54
86
+ pytrilogy-0.0.2.57.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
87
+ pytrilogy-0.0.2.57.dist-info/RECORD,,
trilogy/__init__.py CHANGED
@@ -4,6 +4,6 @@ from trilogy.dialect.enums import Dialects
4
4
  from trilogy.executor import Executor
5
5
  from trilogy.parser import parse
6
6
 
7
- __version__ = "0.0.2.55"
7
+ __version__ = "0.0.2.57"
8
8
 
9
9
  __all__ = ["parse", "Executor", "Dialects", "Environment", "CONFIG"]
@@ -11,12 +11,23 @@ from trilogy.core.models import (
11
11
  )
12
12
  from trilogy.parsing.common import Meta, arg_to_datatype, process_function_args
13
13
 
14
+ FUNCTION_DESCRIPTION_MAPS = {
15
+ FunctionType.DATE: "The date part of a timestamp/date. Integer, 0-31 depending on month.",
16
+ FunctionType.MONTH: "The month part of a timestamp/date. Integer, 1-12.",
17
+ FunctionType.YEAR: "The year part of a timestamp/date. Integer.",
18
+ FunctionType.QUARTER: "The quarter part of a timestamp/date. Integer, 1-4.",
19
+ FunctionType.DAY_OF_WEEK: "The day of the week part of a timestamp/date. Integer, 0-6.",
20
+ FunctionType.HOUR: "The hour part of a timestamp. Integer, 0-23.",
21
+ FunctionType.MINUTE: "The minute part of a timestamp. Integer, 0-59.",
22
+ FunctionType.SECOND: "The second part of a timestamp. Integer, 0-59.",
23
+ }
24
+
14
25
 
15
26
  def generate_date_concepts(concept: Concept, environment: Environment):
16
27
  if concept.metadata and concept.metadata.description:
17
28
  base_description = concept.metadata.description
18
29
  else:
19
- base_description = f"a {concept.datatype.value}"
30
+ base_description = f"a {concept.address}"
20
31
  if concept.metadata and concept.metadata.line_number:
21
32
  base_line_number = concept.metadata.line_number
22
33
  else:
@@ -54,7 +65,7 @@ def generate_date_concepts(concept: Concept, environment: Environment):
54
65
  concept.address,
55
66
  ),
56
67
  metadata=Metadata(
57
- description=f"Auto-derived. Integer format. The {ftype.value} derived from {concept.name}, {base_description}",
68
+ description=f"Auto-derived from {base_description}. {FUNCTION_DESCRIPTION_MAPS.get(ftype, ftype.value)}. ",
58
69
  line_number=base_line_number,
59
70
  concept_source=ConceptSource.AUTO_DERIVED,
60
71
  ),
@@ -68,7 +79,7 @@ def generate_datetime_concepts(concept: Concept, environment: Environment):
68
79
  if concept.metadata and concept.metadata.description:
69
80
  base_description = concept.metadata.description
70
81
  else:
71
- base_description = f"a {concept.datatype.value}"
82
+ base_description = concept.address
72
83
  if concept.metadata and concept.metadata.line_number:
73
84
  base_line_number = concept.metadata.line_number
74
85
  else:
@@ -105,7 +116,7 @@ def generate_datetime_concepts(concept: Concept, environment: Environment):
105
116
  concept.address,
106
117
  ),
107
118
  metadata=Metadata(
108
- description=f"Auto-derived. Integer format. The {ftype.value} derived from {concept.name}, {base_description}",
119
+ description=f"Auto-derived from {base_description}. {FUNCTION_DESCRIPTION_MAPS.get(ftype, ftype.value)}.",
109
120
  line_number=base_line_number,
110
121
  concept_source=ConceptSource.AUTO_DERIVED,
111
122
  ),
@@ -147,7 +158,7 @@ def generate_key_concepts(concept: Concept, environment: Environment):
147
158
  concept.address,
148
159
  },
149
160
  metadata=Metadata(
150
- description=f"Auto-derived. Integer format. The {ftype.value} derived from {concept.name}, {base_description}",
161
+ description=f"Auto-derived integer. The {ftype.value} of {concept.address}, {base_description}",
151
162
  line_number=base_line_number,
152
163
  concept_source=ConceptSource.AUTO_DERIVED,
153
164
  ),
trilogy/core/functions.py CHANGED
@@ -127,10 +127,11 @@ def Unnest(args: list[Concept]) -> Function:
127
127
 
128
128
  def Group(args: list[Concept]) -> Function:
129
129
  output = args[0]
130
+ datatype = arg_to_datatype(output)
130
131
  return Function(
131
132
  operator=FunctionType.GROUP,
132
133
  arguments=args,
133
- output_datatype=output.datatype,
134
+ output_datatype=datatype,
134
135
  output_purpose=Purpose.PROPERTY,
135
136
  arg_count=-1,
136
137
  )
trilogy/core/models.py CHANGED
@@ -620,7 +620,7 @@ class Concept(Mergeable, Namespaced, SelectContext, BaseModel):
620
620
  )
621
621
  final_grain = self.grain or grain
622
622
  keys = self.keys if self.keys else None
623
- if self.is_aggregate and isinstance(new_lineage, Function):
623
+ if self.is_aggregate and isinstance(new_lineage, Function) and grain.components:
624
624
  grain_components = [environment.concepts[c] for c in grain.components]
625
625
  new_lineage = AggregateWrapper(function=new_lineage, by=grain_components)
626
626
  final_grain = grain
@@ -923,9 +923,16 @@ class Grain(Namespaced, BaseModel):
923
923
  if not self.where_clause:
924
924
  where = other.where_clause
925
925
  elif not other.where_clause == self.where_clause:
926
- raise NotImplementedError(
927
- f"Cannot merge grains with where clauses, self {self.where_clause} other {other.where_clause}"
926
+ where = WhereClause(
927
+ conditional=Conditional(
928
+ left=self.where_clause.conditional,
929
+ right=other.where_clause.conditional,
930
+ operator=BooleanOperator.AND,
931
+ )
928
932
  )
933
+ # raise NotImplementedError(
934
+ # f"Cannot merge grains with where clauses, self {self.where_clause} other {other.where_clause}"
935
+ # )
929
936
  return Grain(
930
937
  components=self.components.union(other.components), where_clause=where
931
938
  )
@@ -1015,6 +1022,7 @@ class EnvironmentConceptDict(dict):
1015
1022
  def raise_undefined(
1016
1023
  self, key: str, line_no: int | None = None, file: Path | str | None = None
1017
1024
  ) -> Never:
1025
+
1018
1026
  matches = self._find_similar_concepts(key)
1019
1027
  message = f"Undefined concept: {key}."
1020
1028
  if matches:
@@ -1660,6 +1668,96 @@ class SelectStatement(HasUUID, Mergeable, Namespaced, SelectTypeMixin, BaseModel
1660
1668
  ] = Field(default_factory=EnvironmentConceptDict)
1661
1669
  grain: Grain = Field(default_factory=Grain)
1662
1670
 
1671
+ @classmethod
1672
+ def from_inputs(
1673
+ cls,
1674
+ environment: Environment,
1675
+ selection: List[SelectItem],
1676
+ order_by: OrderBy | None = None,
1677
+ limit: int | None = None,
1678
+ meta: Metadata | None = None,
1679
+ where_clause: WhereClause | None = None,
1680
+ having_clause: HavingClause | None = None,
1681
+ ) -> "SelectStatement":
1682
+
1683
+ output = SelectStatement(
1684
+ selection=selection,
1685
+ where_clause=where_clause,
1686
+ having_clause=having_clause,
1687
+ limit=limit,
1688
+ order_by=order_by,
1689
+ meta=meta or Metadata(),
1690
+ )
1691
+ for parse_pass in [
1692
+ 1,
1693
+ 2,
1694
+ ]:
1695
+ # the first pass will result in all concepts being defined
1696
+ # the second will get grains appropriately
1697
+ # eg if someone does sum(x)->a, b+c -> z - we don't know if Z is a key to group by or an aggregate
1698
+ # until after the first pass, and so don't know the grain of a
1699
+
1700
+ if parse_pass == 1:
1701
+ grain = Grain.from_concepts(
1702
+ [
1703
+ x.content
1704
+ for x in output.selection
1705
+ if isinstance(x.content, Concept)
1706
+ ],
1707
+ where_clause=output.where_clause,
1708
+ )
1709
+ if parse_pass == 2:
1710
+ grain = Grain.from_concepts(
1711
+ output.output_components, where_clause=output.where_clause
1712
+ )
1713
+ output.grain = grain
1714
+ pass_grain = Grain() if parse_pass == 1 else grain
1715
+ for item in selection:
1716
+ # we don't know the grain of an aggregate at assignment time
1717
+ # so rebuild at this point in the tree
1718
+ # TODO: simplify
1719
+ if isinstance(item.content, ConceptTransform):
1720
+ new_concept = item.content.output.with_select_context(
1721
+ output.local_concepts,
1722
+ # the first pass grain will be incorrect
1723
+ pass_grain,
1724
+ environment=environment,
1725
+ )
1726
+ output.local_concepts[new_concept.address] = new_concept
1727
+ item.content.output = new_concept
1728
+ if parse_pass == 2 and CONFIG.select_as_definition:
1729
+ environment.add_concept(new_concept)
1730
+ elif isinstance(item.content, UndefinedConcept):
1731
+ environment.concepts.raise_undefined(
1732
+ item.content.address,
1733
+ line_no=item.content.metadata.line_number,
1734
+ file=environment.env_file_path,
1735
+ )
1736
+ elif isinstance(item.content, Concept):
1737
+ # Sometimes cached values here don't have the latest info
1738
+ # but we can't just use environment, as it might not have the right grain.
1739
+ item.content = item.content.with_select_context(
1740
+ output.local_concepts,
1741
+ pass_grain,
1742
+ environment=environment,
1743
+ )
1744
+ output.local_concepts[item.content.address] = item.content
1745
+
1746
+ if order_by:
1747
+ output.order_by = order_by.with_select_context(
1748
+ local_concepts=output.local_concepts,
1749
+ grain=output.grain,
1750
+ environment=environment,
1751
+ )
1752
+ if output.having_clause:
1753
+ output.having_clause = output.having_clause.with_select_context(
1754
+ local_concepts=output.local_concepts,
1755
+ grain=output.grain,
1756
+ environment=environment,
1757
+ )
1758
+ output.validate_syntax(environment)
1759
+ return output
1760
+
1663
1761
  def validate_syntax(self, environment: Environment):
1664
1762
  if self.where_clause:
1665
1763
  for x in self.where_clause.concept_arguments:
@@ -2091,6 +2189,10 @@ class Datasource(HasUUID, Namespaced, BaseModel):
2091
2189
  def duplicate(self) -> Datasource:
2092
2190
  return self.model_copy(deep=True)
2093
2191
 
2192
+ @property
2193
+ def hidden_concepts(self) -> List[Concept]:
2194
+ return []
2195
+
2094
2196
  def merge_concept(
2095
2197
  self, source: Concept, target: Concept, modifiers: List[Modifier]
2096
2198
  ):
@@ -2163,17 +2265,7 @@ class Datasource(HasUUID, Namespaced, BaseModel):
2163
2265
  @field_validator("grain", mode="before")
2164
2266
  @classmethod
2165
2267
  def grain_enforcement(cls, v: Grain, info: ValidationInfo):
2166
- values = info.data
2167
2268
  grain: Grain = safe_grain(v)
2168
- if not grain.components:
2169
- columns: List[ColumnAssignment] = values.get("columns", [])
2170
- grain = Grain.from_concepts(
2171
- [
2172
- c.concept.with_grain(Grain())
2173
- for c in columns
2174
- if c.concept.purpose == Purpose.KEY
2175
- ]
2176
- )
2177
2269
  return grain
2178
2270
 
2179
2271
  def add_column(
@@ -2982,12 +3074,18 @@ class CTE(BaseModel):
2982
3074
  assert isinstance(c.lineage, RowsetItem)
2983
3075
  return check_is_not_in_group(c.lineage.content)
2984
3076
  if c.derivation == PurposeLineage.CONSTANT:
2985
- return False
3077
+ return True
2986
3078
  if c.purpose == Purpose.METRIC:
2987
3079
  return True
2988
- elif c.derivation == PurposeLineage.BASIC and c.lineage:
3080
+
3081
+ if c.derivation == PurposeLineage.BASIC and c.lineage:
2989
3082
  if all([check_is_not_in_group(x) for x in c.lineage.concept_arguments]):
2990
3083
  return True
3084
+ if (
3085
+ isinstance(c.lineage, Function)
3086
+ and c.lineage.operator == FunctionType.GROUP
3087
+ ):
3088
+ return check_is_not_in_group(c.lineage.concept_arguments[0])
2991
3089
  return False
2992
3090
 
2993
3091
  return (
@@ -3264,6 +3362,7 @@ class Environment(BaseModel):
3264
3362
  alias_origin_lookup: Dict[str, Concept] = Field(default_factory=dict)
3265
3363
  # TODO: support freezing environments to avoid mutation
3266
3364
  frozen: bool = False
3365
+ env_file_path: Path | None = None
3267
3366
 
3268
3367
  def freeze(self):
3269
3368
  self.frozen = True
@@ -3317,7 +3416,7 @@ class Environment(BaseModel):
3317
3416
  path = Path(path)
3318
3417
  with open(path, "r") as f:
3319
3418
  read = f.read()
3320
- return Environment(working_path=Path(path).parent).parse(read)[0]
3419
+ return Environment(working_path=path.parent, env_file_path=path).parse(read)[0]
3321
3420
 
3322
3421
  @classmethod
3323
3422
  def from_string(cls, input: str) -> "Environment":
@@ -3664,6 +3763,7 @@ class Environment(BaseModel):
3664
3763
  for k, v in self.concepts.items():
3665
3764
  if v.address == target.address:
3666
3765
  v.pseudonyms.add(source.address)
3766
+
3667
3767
  if v.address == source.address:
3668
3768
  replacements[k] = target
3669
3769
  v.pseudonyms.add(target.address)
@@ -539,11 +539,14 @@ def validate_concept(
539
539
  found_addresses.add(concept.address)
540
540
  found_map[str(node)].add(concept)
541
541
  for v_address in concept.pseudonyms:
542
+ if v_address in seen:
543
+ return
542
544
  v = environment.concepts[v_address]
543
- if v == concept.address:
545
+ if v.address in seen:
544
546
  return
545
- if v in seen:
547
+ if v.address == concept.address:
546
548
  return
549
+
547
550
  validate_concept(
548
551
  v,
549
552
  node,
@@ -866,6 +869,7 @@ def _search_concepts(
866
869
  )
867
870
  if complete == ValidationResult.INCOMPLETE_CONDITION:
868
871
  cond_dict = {str(node): node.preexisting_conditions for node in stack}
872
+ logger.error(f"Have {cond_dict} and need {str(conditions)}")
869
873
  raise SyntaxError(f"Have {cond_dict} and need {str(conditions)}")
870
874
  # early exit if we have a complete stack with one node
871
875
  # we can only early exit if we have a complete stack
@@ -987,17 +991,20 @@ def source_query_concepts(
987
991
  raise ValueError(
988
992
  f"Could not resolve conections between {error_strings} from environment graph."
989
993
  )
990
- candidate = GroupNode(
991
- output_concepts=[
992
- x for x in root.output_concepts if x.address not in root.hidden_concepts
993
- ],
994
- input_concepts=[
995
- x for x in root.output_concepts if x.address not in root.hidden_concepts
996
- ],
994
+ final = [x for x in root.output_concepts if x.address not in root.hidden_concepts]
995
+ if GroupNode.check_if_required(
996
+ downstream_concepts=final,
997
+ parents=[root.resolve()],
997
998
  environment=environment,
998
- parents=[root],
999
- partial_concepts=root.partial_concepts,
1000
- )
1001
- if not candidate.resolve().group_required:
1002
- return root
999
+ ).required:
1000
+ candidate: StrategyNode = GroupNode(
1001
+ output_concepts=final,
1002
+ input_concepts=final,
1003
+ environment=environment,
1004
+ parents=[root],
1005
+ partial_concepts=root.partial_concepts,
1006
+ )
1007
+ else:
1008
+ candidate = root
1009
+
1003
1010
  return candidate
@@ -13,6 +13,7 @@ from trilogy.core.processing.node_generators.common import (
13
13
  resolve_function_parent_concepts,
14
14
  )
15
15
  from trilogy.core.processing.nodes import History, StrategyNode
16
+ from trilogy.utility import unique
16
17
 
17
18
  LOGGER_PREFIX = "[GEN_BASIC_NODE]"
18
19
 
@@ -65,7 +66,9 @@ def gen_basic_node(
65
66
  non_equivalent_optional = [
66
67
  x for x in local_optional if x not in equivalent_optional
67
68
  ]
68
- all_parents = parent_concepts + non_equivalent_optional
69
+ all_parents: list[Concept] = unique(
70
+ parent_concepts + non_equivalent_optional, "address"
71
+ )
69
72
  logger.info(
70
73
  f"{depth_prefix}{LOGGER_PREFIX} Fetching parents {[x.address for x in all_parents]}"
71
74
  )
@@ -60,7 +60,7 @@ def gen_filter_node(
60
60
  g=g,
61
61
  depth=depth + 1,
62
62
  history=history,
63
- # conditions=conditions,
63
+ conditions=conditions,
64
64
  )
65
65
 
66
66
  flattened_existence = [x for y in parent_existence_concepts for x in y]
@@ -194,6 +194,9 @@ def gen_filter_node(
194
194
  history=history,
195
195
  conditions=conditions,
196
196
  )
197
+ logger.info(
198
+ f"{padding(depth)}{LOGGER_PREFIX} returning filter node and enrich node with {enrich_node.output_concepts} and {enrich_node.input_concepts}"
199
+ )
197
200
  return MergeNode(
198
201
  input_concepts=[concept, immediate_parent] + local_optional,
199
202
  output_concepts=[
@@ -206,4 +209,5 @@ def gen_filter_node(
206
209
  filter_node,
207
210
  enrich_node,
208
211
  ],
212
+ preexisting_conditions=conditions.conditional if conditions else None,
209
213
  )
@@ -26,6 +26,7 @@ def gen_group_to_node(
26
26
  # aggregates MUST always group to the proper grain
27
27
  if not isinstance(concept.lineage, Function):
28
28
  raise SyntaxError("Group to should have function lineage")
29
+ group_arg = concept.lineage.arguments[0]
29
30
  parent_concepts: List[Concept] = concept.lineage.concept_arguments
30
31
  logger.info(
31
32
  f"{padding(depth)}{LOGGER_PREFIX} group by node has required parents {[x.address for x in parent_concepts]}"
@@ -47,6 +48,13 @@ def gen_group_to_node(
47
48
  environment=environment,
48
49
  parents=parents,
49
50
  depth=depth,
51
+ preexisting_conditions=conditions.conditional if conditions else None,
52
+ hidden_concepts=(
53
+ [group_arg]
54
+ if isinstance(group_arg, Concept)
55
+ and group_arg.address not in local_optional
56
+ else []
57
+ ),
50
58
  )
51
59
 
52
60
  # early exit if no optional
@@ -62,6 +70,7 @@ def gen_group_to_node(
62
70
  g=g,
63
71
  depth=depth + 1,
64
72
  history=history,
73
+ conditions=conditions,
65
74
  )
66
75
  if not enrich_node:
67
76
  logger.info(
@@ -83,4 +92,5 @@ def gen_group_to_node(
83
92
  ],
84
93
  whole_grain=True,
85
94
  depth=depth,
95
+ preexisting_conditions=conditions.conditional if conditions else None,
86
96
  )
@@ -69,6 +69,7 @@ def gen_multiselect_node(
69
69
  lineage: MultiSelectStatement = concept.lineage
70
70
 
71
71
  base_parents: List[StrategyNode] = []
72
+ partial = []
72
73
  for select in lineage.selects:
73
74
  snode: StrategyNode = source_concepts(
74
75
  mandatory_list=select.output_components,
@@ -103,6 +104,9 @@ def gen_multiselect_node(
103
104
  for mc in merge_concepts:
104
105
  assert mc in snode.resolve().output_concepts
105
106
  base_parents.append(snode)
107
+ if select.where_clause:
108
+ for item in select.output_components:
109
+ partial.append(item)
106
110
 
107
111
  node_joins = extra_align_joins(lineage, base_parents)
108
112
  node = MergeNode(
@@ -112,35 +116,28 @@ def gen_multiselect_node(
112
116
  depth=depth,
113
117
  parents=base_parents,
114
118
  node_joins=node_joins,
119
+ hidden_concepts=[x for y in base_parents for x in y.hidden_concepts],
115
120
  )
116
121
 
117
122
  enrichment = set([x.address for x in local_optional])
118
123
 
119
- rowset_relevant = [
124
+ multiselect_relevant = [
120
125
  x
121
126
  for x in lineage.derived_concepts
122
127
  if x.address == concept.address or x.address in enrichment
123
128
  ]
124
- additional_relevant = [
125
- x for x in select.output_components if x.address in enrichment
126
- ]
129
+ additional_relevant = [x for x in node.output_concepts if x.address in enrichment]
127
130
  # add in other other concepts
128
- for item in rowset_relevant:
129
- node.output_concepts.append(item)
130
- for item in additional_relevant:
131
- node.output_concepts.append(item)
132
- if select.where_clause:
133
- for item in additional_relevant:
134
- node.partial_concepts.append(item)
135
131
 
136
- # we need a better API for refreshing a nodes QDS
137
- node.resolution_cache = node._resolve()
132
+ node.set_output_concepts(multiselect_relevant + additional_relevant)
138
133
 
139
- # assume grain to be output of select
140
- # but don't include anything aggregate at this point
141
- node.resolution_cache.grain = Grain.from_concepts(
142
- node.output_concepts,
143
- )
134
+ # node.add_partial_concepts(partial)
135
+ # if select.where_clause:
136
+ # for item in additional_relevant:
137
+ # node.partial_concepts.append(item)
138
+ node.grain = Grain.from_concepts(node.output_concepts, environment=environment)
139
+ node.rebuild_cache()
140
+ # we need a better API for refreshing a nodes QDS
144
141
  possible_joins = concept_to_relevant_joins(additional_relevant)
145
142
  if not local_optional:
146
143
  logger.info(
@@ -159,6 +156,7 @@ def gen_multiselect_node(
159
156
  f"{padding(depth)}{LOGGER_PREFIX} all enriched concepts returned from base rowset node; exiting early"
160
157
  )
161
158
  return node
159
+
162
160
  enrich_node: MergeNode = source_concepts( # this fetches the parent + join keys
163
161
  # to then connect to the rest of the query
164
162
  mandatory_list=additional_relevant + local_optional,
@@ -103,6 +103,7 @@ def gen_rowset_node(
103
103
  )
104
104
  return node
105
105
  if any(x.derivation == PurposeLineage.ROWSET for x in possible_joins):
106
+
106
107
  logger.info(
107
108
  f"{padding(depth)}{LOGGER_PREFIX} cannot enrich rowset node with rowset concepts; exiting early"
108
109
  )
@@ -240,10 +240,12 @@ def create_datasource_node(
240
240
  depth: int,
241
241
  conditions: WhereClause | None = None,
242
242
  ) -> tuple[StrategyNode, bool]:
243
- target_grain = Grain.from_concepts(all_concepts)
243
+ target_grain = Grain.from_concepts(all_concepts, environment=environment)
244
244
  force_group = False
245
245
  if not datasource.grain.issubset(target_grain):
246
246
  force_group = True
247
+ if not datasource.grain.components:
248
+ force_group = True
247
249
  partial_concepts = [
248
250
  c.concept
249
251
  for c in datasource.columns
@@ -350,6 +352,9 @@ def create_select_node(
350
352
 
351
353
  # we need to nest the group node one further
352
354
  if force_group is True:
355
+ logger.info(
356
+ f"{padding(depth)}{LOGGER_PREFIX} source requires group before consumption."
357
+ )
353
358
  candidate: StrategyNode = GroupNode(
354
359
  output_concepts=all_concepts,
355
360
  input_concepts=all_concepts,
@@ -359,8 +364,10 @@ def create_select_node(
359
364
  partial_concepts=bcandidate.partial_concepts,
360
365
  nullable_concepts=bcandidate.nullable_concepts,
361
366
  preexisting_conditions=bcandidate.preexisting_conditions,
367
+ force_group=force_group,
362
368
  )
363
369
  else:
370
+
364
371
  candidate = bcandidate
365
372
  return candidate
366
373
 
@@ -192,6 +192,8 @@ class StrategyNode:
192
192
  for x in self.parents:
193
193
  for z in x.usable_outputs:
194
194
  non_hidden.add(z.address)
195
+ for psd in z.pseudonyms:
196
+ non_hidden.add(psd)
195
197
  if not all([x.address in non_hidden for x in self.input_concepts]):
196
198
  missing = [x for x in self.input_concepts if x.address not in non_hidden]
197
199
  raise ValueError(
@@ -210,6 +212,8 @@ class StrategyNode:
210
212
  return self
211
213
 
212
214
  def add_condition(self, condition: Conditional | Comparison | Parenthetical):
215
+ if self.conditions and condition == self.conditions:
216
+ return self
213
217
  if self.conditions:
214
218
  self.conditions = Conditional(
215
219
  left=self.conditions, right=condition, operator=BooleanOperator.AND
@@ -244,6 +248,15 @@ class StrategyNode:
244
248
  self.rebuild_cache()
245
249
  return self
246
250
 
251
+ def add_partial_concepts(self, concepts: List[Concept], rebuild: bool = True):
252
+ for concept in concepts:
253
+ if concept.address not in self.partial_lcl.addresses:
254
+ self.partial_concepts.append(concept)
255
+ self.partial_lcl = LooseConceptList(concepts=self.partial_concepts)
256
+ if rebuild:
257
+ self.rebuild_cache()
258
+ return self
259
+
247
260
  def add_existence_concepts(self, concepts: List[Concept], rebuild: bool = True):
248
261
  for concept in concepts:
249
262
  if concept.address not in self.output_concepts:
@@ -1,3 +1,4 @@
1
+ from dataclasses import dataclass
1
2
  from typing import List, Optional
2
3
 
3
4
  from trilogy.constants import logger
@@ -8,7 +9,6 @@ from trilogy.core.models import (
8
9
  Datasource,
9
10
  Environment,
10
11
  Grain,
11
- LooseConceptList,
12
12
  Parenthetical,
13
13
  QueryDatasource,
14
14
  SourceType,
@@ -24,6 +24,13 @@ from trilogy.utility import unique
24
24
  LOGGER_PREFIX = "[CONCEPT DETAIL - GROUP NODE]"
25
25
 
26
26
 
27
+ @dataclass
28
+ class GroupRequiredResponse:
29
+ target: Grain
30
+ upstream: Grain
31
+ required: bool
32
+
33
+
27
34
  class GroupNode(StrategyNode):
28
35
  source_type = SourceType.GROUP
29
36
 
@@ -59,45 +66,44 @@ class GroupNode(StrategyNode):
59
66
  hidden_concepts=hidden_concepts,
60
67
  )
61
68
 
62
- def _resolve(self) -> QueryDatasource:
63
- parent_sources: List[QueryDatasource | Datasource] = [
64
- p.resolve() for p in self.parents
65
- ]
66
-
67
- target_grain = self.grain or Grain.from_concepts(
69
+ @classmethod
70
+ def check_if_required(
71
+ cls,
72
+ downstream_concepts: List[Concept],
73
+ parents: list[QueryDatasource | Datasource],
74
+ environment: Environment,
75
+ ) -> GroupRequiredResponse:
76
+ target_grain = Grain.from_concepts(
68
77
  concepts_to_grain_concepts(
69
- self.output_concepts, environment=self.environment
78
+ downstream_concepts,
79
+ environment=environment,
70
80
  )
71
81
  )
72
82
  comp_grain = Grain()
73
- for source in parent_sources:
83
+ for source in parents:
74
84
  comp_grain += source.grain
75
85
  comp_grain = Grain.from_concepts(
76
- concepts_to_grain_concepts(
77
- comp_grain.components, environment=self.environment
78
- )
86
+ concepts_to_grain_concepts(comp_grain.components, environment=environment)
79
87
  )
80
88
  # dynamically select if we need to group
81
89
  # because sometimes, we are already at required grain
82
- if comp_grain == target_grain and self.force_group is not True:
83
- # if there is no group by, and inputs equal outputs
84
- # return the parent
85
- logger.info(
86
- f"{self.logging_prefix}{LOGGER_PREFIX} Grain of group by equals output"
87
- f" grains {comp_grain} and {target_grain}"
88
- )
89
- if (
90
- len(parent_sources) == 1
91
- and LooseConceptList(concepts=parent_sources[0].output_concepts)
92
- == self.output_lcl
93
- ) and isinstance(parent_sources[0], QueryDatasource):
94
- logger.info(
95
- f"{self.logging_prefix}{LOGGER_PREFIX} No group by required as inputs match outputs of parent; returning parent node"
96
- )
97
- will_return: QueryDatasource = parent_sources[0]
98
- if self.conditions:
99
- will_return.condition = self.conditions + will_return.condition
100
- return will_return
90
+ if comp_grain.issubset(target_grain):
91
+ return GroupRequiredResponse(target_grain, comp_grain, False)
92
+
93
+ return GroupRequiredResponse(target_grain, comp_grain, True)
94
+
95
+ def _resolve(self) -> QueryDatasource:
96
+ parent_sources: List[QueryDatasource | Datasource] = [
97
+ p.resolve() for p in self.parents
98
+ ]
99
+ grains = self.check_if_required(
100
+ self.output_concepts, parent_sources, self.environment
101
+ )
102
+ target_grain = grains.target
103
+ comp_grain = grains.upstream
104
+ # dynamically select if we need to group
105
+ # because sometimes, we are already at required grain
106
+ if not grains.required and self.force_group is not True:
101
107
  # otherwise if no group by, just treat it as a select
102
108
  source_type = SourceType.SELECT
103
109
  else:
@@ -108,15 +114,6 @@ class GroupNode(StrategyNode):
108
114
  f" target grain {target_grain}"
109
115
  f" delta: {comp_grain - target_grain}"
110
116
  )
111
- for parent in self.parents:
112
- logger.info(
113
- f"{self.logging_prefix}{LOGGER_PREFIX} Parent node"
114
- f" {[c.address for c in parent.output_concepts[:2]]}... has"
115
- " set node grain"
116
- f" {parent.grain}"
117
- f" and resolved grain {parent.resolve().grain}"
118
- f" {type(parent)}"
119
- )
120
117
  source_type = SourceType.GROUP
121
118
  source_map = resolve_concept_map(
122
119
  parent_sources,
@@ -224,9 +224,6 @@ class MergeNode(StrategyNode):
224
224
  f"{self.logging_prefix}{LOGGER_PREFIX} Final joins is not null {final_joins} but is empty, skipping join generation"
225
225
  )
226
226
  return []
227
-
228
- for join in joins:
229
- logger.info(f"{self.logging_prefix}{LOGGER_PREFIX} final join {str(join)}")
230
227
  return joins
231
228
 
232
229
  def _resolve(self) -> QueryDatasource:
@@ -313,6 +310,7 @@ class MergeNode(StrategyNode):
313
310
  for join in joins:
314
311
  if isinstance(join, BaseJoin) and join.join_type == JoinType.FULL:
315
312
  full_join_concepts += join.input_concepts
313
+
316
314
  if self.whole_grain:
317
315
  force_group = False
318
316
  elif self.force_group is False:
@@ -306,11 +306,37 @@ def resolve_instantiated_concept(
306
306
  )
307
307
 
308
308
 
309
+ def reduce_concept_pairs(input: list[ConceptPair]) -> list[ConceptPair]:
310
+ left_keys = set()
311
+ right_keys = set()
312
+ for pair in input:
313
+ if pair.left.purpose == Purpose.KEY:
314
+ left_keys.add(pair.left.address)
315
+ if pair.right.purpose == Purpose.KEY:
316
+ right_keys.add(pair.right.address)
317
+ final: list[ConceptPair] = []
318
+ for pair in input:
319
+ if (
320
+ pair.left.purpose == Purpose.PROPERTY
321
+ and pair.left.keys
322
+ and pair.left.keys.issubset(left_keys)
323
+ ):
324
+ continue
325
+ if (
326
+ pair.right.purpose == Purpose.PROPERTY
327
+ and pair.right.keys
328
+ and pair.right.keys.issubset(right_keys)
329
+ ):
330
+ continue
331
+ final.append(pair)
332
+ return final
333
+
334
+
309
335
  def get_node_joins(
310
336
  datasources: List[QueryDatasource | Datasource],
311
337
  environment: Environment,
312
338
  # concepts:List[Concept],
313
- ):
339
+ ) -> List[BaseJoin]:
314
340
  graph = nx.Graph()
315
341
  partials: dict[str, list[str]] = {}
316
342
  ds_node_map: dict[str, QueryDatasource | Datasource] = {}
@@ -321,6 +347,8 @@ def get_node_joins(
321
347
  graph.add_node(ds_node, type=NodeType.NODE)
322
348
  partials[ds_node] = [f"c~{c.address}" for c in datasource.partial_concepts]
323
349
  for concept in datasource.output_concepts:
350
+ if concept in datasource.hidden_concepts:
351
+ continue
324
352
  add_node_join_concept(
325
353
  graph=graph,
326
354
  concept=concept,
@@ -337,19 +365,21 @@ def get_node_joins(
337
365
  join_type=j.type,
338
366
  # preserve empty field for maps
339
367
  concepts=[] if not j.keys else None,
340
- concept_pairs=[
341
- ConceptPair(
342
- left=resolve_instantiated_concept(
343
- concept_map[concept], ds_node_map[k]
344
- ),
345
- right=resolve_instantiated_concept(
346
- concept_map[concept], ds_node_map[j.right]
347
- ),
348
- existing_datasource=ds_node_map[k],
349
- )
350
- for k, v in j.keys.items()
351
- for concept in v
352
- ],
368
+ concept_pairs=reduce_concept_pairs(
369
+ [
370
+ ConceptPair(
371
+ left=resolve_instantiated_concept(
372
+ concept_map[concept], ds_node_map[k]
373
+ ),
374
+ right=resolve_instantiated_concept(
375
+ concept_map[concept], ds_node_map[j.right]
376
+ ),
377
+ existing_datasource=ds_node_map[k],
378
+ )
379
+ for k, v in j.keys.items()
380
+ for concept in v
381
+ ]
382
+ ),
353
383
  )
354
384
  for j in joins
355
385
  ]
@@ -359,7 +359,7 @@ def get_query_node(
359
359
  environment.concepts[k] = v
360
360
  graph = generate_graph(environment)
361
361
  logger.info(
362
- f"{LOGGER_PREFIX} getting source datasource for query with filtering {statement.where_clause_category} and output {[str(c) for c in statement.output_components]}"
362
+ f"{LOGGER_PREFIX} getting source datasource for query with filtering {statement.where_clause_category} and grain {statement.grain}"
363
363
  )
364
364
  if not statement.output_components:
365
365
  raise ValueError(f"Statement has no output components {statement}")
trilogy/dialect/base.py CHANGED
@@ -249,7 +249,9 @@ def safe_get_cte_value(coalesce, cte: CTE | UnionCTE, c: Concept, quote_char: st
249
249
  if isinstance(raw, list) and len(raw) == 1:
250
250
  rendered = cte.get_alias(c, raw[0])
251
251
  return f"{raw[0]}.{safe_quote(rendered, quote_char)}"
252
- return coalesce([f"{x}.{safe_quote(cte.get_alias(c, x), quote_char)}" for x in raw])
252
+ return coalesce(
253
+ sorted([f"{x}.{safe_quote(cte.get_alias(c, x), quote_char)}" for x in raw])
254
+ )
253
255
 
254
256
 
255
257
  class BaseDialect:
@@ -17,7 +17,6 @@ from lark.tree import Meta
17
17
  from pydantic import ValidationError
18
18
 
19
19
  from trilogy.constants import (
20
- CONFIG,
21
20
  DEFAULT_NAMESPACE,
22
21
  NULL_VALUE,
23
22
  MagicConstants,
@@ -108,7 +107,6 @@ from trilogy.core.models import (
108
107
  StructType,
109
108
  SubselectComparison,
110
109
  TupleWrapper,
111
- UndefinedConcept,
112
110
  WhereClause,
113
111
  Window,
114
112
  WindowItem,
@@ -1030,81 +1028,15 @@ class ParseToObjects(Transformer):
1030
1028
  if not select_items:
1031
1029
  raise ValueError("Malformed select, missing select items")
1032
1030
 
1033
- output = SelectStatement(
1031
+ return SelectStatement.from_inputs(
1032
+ environment=self.environment,
1034
1033
  selection=select_items,
1034
+ order_by=order_by,
1035
1035
  where_clause=where,
1036
1036
  having_clause=having,
1037
1037
  limit=limit,
1038
- order_by=order_by,
1039
1038
  meta=Metadata(line_number=meta.line),
1040
1039
  )
1041
- for parse_pass in [
1042
- 1,
1043
- 2,
1044
- ]:
1045
- # the first pass will result in all concepts being defined
1046
- # the second will get grains appropriately
1047
- # eg if someone does sum(x)->a, b+c -> z - we don't know if Z is a key to group by or an aggregate
1048
- # until after the first pass, and so don't know the grain of a
1049
-
1050
- if parse_pass == 1:
1051
- grain = Grain.from_concepts(
1052
- [
1053
- x.content
1054
- for x in output.selection
1055
- if isinstance(x.content, Concept)
1056
- ],
1057
- where_clause=output.where_clause,
1058
- )
1059
- if parse_pass == 2:
1060
- grain = Grain.from_concepts(
1061
- output.output_components, where_clause=output.where_clause
1062
- )
1063
- output.grain = grain
1064
- for item in select_items:
1065
- # we don't know the grain of an aggregate at assignment time
1066
- # so rebuild at this point in the tree
1067
- # TODO: simplify
1068
- if isinstance(item.content, ConceptTransform):
1069
- new_concept = item.content.output.with_select_context(
1070
- output.local_concepts,
1071
- output.grain,
1072
- environment=self.environment,
1073
- )
1074
- output.local_concepts[new_concept.address] = new_concept
1075
- item.content.output = new_concept
1076
- if parse_pass == 2 and CONFIG.select_as_definition:
1077
- self.environment.add_concept(new_concept)
1078
- elif isinstance(item.content, UndefinedConcept):
1079
- self.environment.concepts.raise_undefined(
1080
- item.content.address,
1081
- line_no=item.content.metadata.line_number,
1082
- file=self.token_address,
1083
- )
1084
- elif isinstance(item.content, Concept):
1085
- # Sometimes cached values here don't have the latest info
1086
- # but we can't just use environment, as it might not have the right grain.
1087
- item.content = item.content.with_select_context(
1088
- output.local_concepts,
1089
- output.grain,
1090
- environment=self.environment,
1091
- )
1092
- output.local_concepts[item.content.address] = item.content
1093
-
1094
- if order_by:
1095
- output.order_by = order_by.with_select_context(
1096
- local_concepts=output.local_concepts,
1097
- grain=output.grain,
1098
- environment=self.environment,
1099
- )
1100
- if output.having_clause:
1101
- output.having_clause = output.having_clause.with_select_context(
1102
- local_concepts=output.local_concepts,
1103
- grain=output.grain,
1104
- environment=self.environment,
1105
- )
1106
- output.validate_syntax(self.environment)
1107
- return output
1108
1040
 
1109
1041
  @v_args(meta=True)
1110
1042
  def address(self, meta: Meta, args):
trilogy/utility.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import hashlib
2
- from typing import Any, Callable, List, Union
2
+ from typing import Callable, List, TypeVar, Union
3
3
 
4
4
  from trilogy.constants import DEFAULT_NAMESPACE
5
5
 
@@ -12,7 +12,10 @@ def string_to_hash(input: str) -> int:
12
12
  )
13
13
 
14
14
 
15
- def unique(inputs: List, property: Union[str, Callable]) -> List[Any]:
15
+ UniqueArg = TypeVar("UniqueArg")
16
+
17
+
18
+ def unique(inputs: List[UniqueArg], property: Union[str, Callable]) -> List[UniqueArg]:
16
19
  final = []
17
20
  dedupe = set()
18
21
  if isinstance(property, str):