pytrilogy 0.0.3.32__py3-none-any.whl → 0.0.3.34__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.4
2
2
  Name: pytrilogy
3
- Version: 0.0.3.32
3
+ Version: 0.0.3.34
4
4
  Summary: Declarative, typed query language that compiles to SQL.
5
5
  Home-page:
6
6
  Author:
@@ -1,5 +1,5 @@
1
- pytrilogy-0.0.3.32.dist-info/licenses/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
2
- trilogy/__init__.py,sha256=LEjwWz5ASSeELMnWJRDTgcuJHGstMRNS9gvzU8PvHfQ,303
1
+ pytrilogy-0.0.3.34.dist-info/licenses/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
2
+ trilogy/__init__.py,sha256=RkaJLhtJbPi75Dpq1wKhAgq3CaTPGqR2AIjV7Ot7NWA,303
3
3
  trilogy/compiler.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  trilogy/constants.py,sha256=5eQxk1A0pv-TQk3CCvgZCFA9_K-6nxrOm7E5Lxd7KIY,1652
5
5
  trilogy/engine.py,sha256=OK2RuqCIUId6yZ5hfF8J1nxGP0AJqHRZiafcowmW0xc,1728
@@ -8,10 +8,10 @@ trilogy/parser.py,sha256=o4cfk3j3yhUFoiDKq9ZX_GjBF3dKhDjXEwb63rcBkBM,293
8
8
  trilogy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  trilogy/render.py,sha256=qQWwduymauOlB517UtM-VGbVe8Cswa4UJub5aGbSO6c,1512
10
10
  trilogy/utility.py,sha256=euQccZLKoYBz0LNg5tzLlvv2YHvXh9HArnYp1V3uXsM,763
11
- trilogy/authoring/__init__.py,sha256=ohkYA3_LGYZh3fwzEYKTN6ofACDI5GYl3VCbGxVvlzY,2233
11
+ trilogy/authoring/__init__.py,sha256=v9PRuZs4fTnxhpXAnwTxCDwlLasUax6g2FONidcujR4,2369
12
12
  trilogy/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  trilogy/core/constants.py,sha256=7XaCpZn5mQmjTobbeBn56SzPWq9eMNDfzfsRU-fP0VE,171
14
- trilogy/core/enums.py,sha256=7SW4V0PpceeAUREyxDKPBe2JAtDMOM5IOFhn9fYKObM,7318
14
+ trilogy/core/enums.py,sha256=fWexUZtssfvP5TiD7eQ66Q_tPUCNCCTGNSzLbVXrnqQ,7358
15
15
  trilogy/core/env_processor.py,sha256=pFsxnluKIusGKx1z7tTnfsd_xZcPy9pZDungkjkyvI0,3170
16
16
  trilogy/core/environment_helpers.py,sha256=5ayyhf4CGBlg_LssPu3DbS_H9H1Kq6Qog5TgR8pwyMk,8518
17
17
  trilogy/core/ergonomics.py,sha256=e-7gE29vPLFdg0_A1smQ7eOrUwKl5VYdxRSTddHweRA,1631
@@ -19,35 +19,35 @@ trilogy/core/exceptions.py,sha256=JPYyBcit3T_pRtlHdtKSeVJkIyWUTozW2aaut25A2xI,67
19
19
  trilogy/core/functions.py,sha256=z5uyC5sAS_vnFBJcky7TjA0QAy-xI4Z9uD_9vK_XP1s,27134
20
20
  trilogy/core/graph_models.py,sha256=z17EoO8oky2QOuO6E2aMWoVNKEVJFhLdsQZOhC4fNLU,2079
21
21
  trilogy/core/internal.py,sha256=iicDBlC6nM8d7e7jqzf_ZOmpUsW8yrr2AA8AqEiLx-s,1577
22
- trilogy/core/optimization.py,sha256=xGO8piVsLrpqrx-Aid_Y56_5slSv4eZmlP64hCHRiEc,7957
22
+ trilogy/core/optimization.py,sha256=aihzx4-2-mSjx5td1TDTYGvc7e9Zvy-_xEyhPqLS-Ig,8314
23
23
  trilogy/core/query_processor.py,sha256=Do8YpdPBdsbKtl9n37hobzk8SORMGqH-e_zNNxd-BE4,19456
24
24
  trilogy/core/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
- trilogy/core/models/author.py,sha256=FtECoNRevZ8sD9JI-D8a9ngxxVoKNtfrdEjbnofYcg8,76131
26
- trilogy/core/models/build.py,sha256=3_n6qxoMuzX3Ahu9HLfoyPbtH0Wb--6crZNwCnZhu7k,58104
27
- trilogy/core/models/build_environment.py,sha256=8UggvlPU708GZWYPJMc_ou2r7M3TY2g69eqGvz03YX0,5528
25
+ trilogy/core/models/author.py,sha256=Z5RQr5vsPyxLSXR11w_b8z-tQ54bSM3ks1az9uV3d40,76579
26
+ trilogy/core/models/build.py,sha256=N3Zr47iN4y9gFWS72uYjecmiQfcm8Fl9wT3QS8xKBAA,58185
27
+ trilogy/core/models/build_environment.py,sha256=s_C9xAHuD3yZ26T15pWVBvoqvlp2LdZ8yjsv2_HdXLk,5363
28
28
  trilogy/core/models/core.py,sha256=wx6hJcFECMG-Ij972ADNkr-3nFXkYESr82ObPiC46_U,10875
29
29
  trilogy/core/models/datasource.py,sha256=6RjJUd2u4nYmEwFBpJlM9LbHVYDv8iHJxqiBMZqUrwI,9422
30
- trilogy/core/models/environment.py,sha256=yd19vDdC-IiKPV5YbNZcDUOEr1BC_ZxE_5fQDIipa3s,26910
31
- trilogy/core/models/execute.py,sha256=KZHiovlSr_3ZjyzKD1mdBlAqnPCqFCChQkO4_4WlGtg,34224
30
+ trilogy/core/models/environment.py,sha256=axgk7W3STy5EIrG8fUwl2oh6WCqeBAr7PWy6EOe-_Dc,27002
31
+ trilogy/core/models/execute.py,sha256=mQm5Gydo2Ph0W7w9wm5dQEarS04PC-IKAgNVsdqOZsQ,34524
32
32
  trilogy/core/optimizations/__init__.py,sha256=EBanqTXEzf1ZEYjAneIWoIcxtMDite5-n2dQ5xcfUtg,356
33
33
  trilogy/core/optimizations/base_optimization.py,sha256=gzDOKImoFn36k7XBD3ysEYDnbnb6vdVIztUfFQZsGnM,513
34
34
  trilogy/core/optimizations/inline_constant.py,sha256=lvNTIXaLNkw3HseJyXyDNk5R52doLU9sIg3pmU2_S08,1332
35
35
  trilogy/core/optimizations/inline_datasource.py,sha256=AHuTGh2x0GQ8usOe0NiFncfTFQ_KogdgDl4uucmhIbI,4241
36
36
  trilogy/core/optimizations/predicate_pushdown.py,sha256=g4AYE8Aw_iMlAh68TjNXGP754NTurrDduFECkUjoBnc,9399
37
37
  trilogy/core/processing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
- trilogy/core/processing/concept_strategies_v3.py,sha256=O-O08cGLl4XX3Faf59UlkFjsjzTU-QkzlDQ0mzY-LRE,40515
38
+ trilogy/core/processing/concept_strategies_v3.py,sha256=m24X5FOOcLTCvY1MY1yUK5qFcWwokMWZ5cAFI2YN9G8,43352
39
39
  trilogy/core/processing/graph_utils.py,sha256=8QUVrkE9j-9C1AyrCb1nQEh8daCe0u1HuXl-Te85lag,1205
40
- trilogy/core/processing/utility.py,sha256=mnN_pewdpDgRou4QJ1JLcqYHyZdp8DrcsGsqW3QmA3o,20591
40
+ trilogy/core/processing/utility.py,sha256=3tC__aT76EzcnIexZfOqCH-3WzvPiCAYWs9TBoMvGjc,20903
41
41
  trilogy/core/processing/node_generators/__init__.py,sha256=o8rOFHPSo-s_59hREwXMW6gjUJCsiXumdbJNozHUf-Y,800
42
42
  trilogy/core/processing/node_generators/basic_node.py,sha256=UVsXMn6jTjm_ofVFt218jAS11s4RV4zD781vP4im-GI,3371
43
43
  trilogy/core/processing/node_generators/common.py,sha256=ZsDzThjm_mAtdQpKAg8QIJiPVZ4KuUkKyilj4eOhSDs,9439
44
44
  trilogy/core/processing/node_generators/filter_node.py,sha256=rlY7TbgjJlGhahYgdCIJpJbaSREAGVJEsyUIGaA38O0,8271
45
- trilogy/core/processing/node_generators/group_node.py,sha256=iB5kq-EaueoaSQkgegULwo4g-fe8PKtRtSDiphNO5_k,5939
45
+ trilogy/core/processing/node_generators/group_node.py,sha256=kO-ersxIL04rZwX5-vFIFQQnp357PFo_7ZKXoGq3wyc,5989
46
46
  trilogy/core/processing/node_generators/group_to_node.py,sha256=E5bEjovSx422d_MlAUCDFdY4P2WJVp61BmWwltkhzA8,3095
47
47
  trilogy/core/processing/node_generators/multiselect_node.py,sha256=GWV5yLmKTe1yyPhN60RG1Rnrn4ktfn9lYYXi_FVU4UI,7061
48
48
  trilogy/core/processing/node_generators/node_merge_node.py,sha256=sv55oynfqgpHEpo1OEtVDri-5fywzPhDlR85qaWikvY,16195
49
- trilogy/core/processing/node_generators/rowset_node.py,sha256=tQog2YbG7h2TSVZGdXCFbwjqpq-CKqNewFOOfpZNqh4,6751
50
- trilogy/core/processing/node_generators/select_merge_node.py,sha256=pIsHfXWA30RkKSMBDKPtDmCriJtHoNKRMJC0THSDxpI,19951
49
+ trilogy/core/processing/node_generators/rowset_node.py,sha256=YmBs6ZQ7azLXRFEmeoecpGjK4pMHsUCovuBxfb3UKZI,6848
50
+ trilogy/core/processing/node_generators/select_merge_node.py,sha256=lxXhMhDKGbu67QFNbbAT-BO8gbWppIvjn_hAXpLEPe0,19953
51
51
  trilogy/core/processing/node_generators/select_node.py,sha256=Y-zO0AFkTrpi2LyebjpyHU7WWANr7nKZSS9rY7DH4Wo,1888
52
52
  trilogy/core/processing/node_generators/synonym_node.py,sha256=9LHK2XHDjbyTLjmDQieskG8fqbiSpRnFOkfrutDnOTE,2258
53
53
  trilogy/core/processing/node_generators/union_node.py,sha256=zuMSmgF170vzlp2BBQEhKbqUMjVl2xQDqUB82Dhv-VU,2536
@@ -58,14 +58,14 @@ trilogy/core/processing/node_generators/select_helpers/datasource_injection.py,s
58
58
  trilogy/core/processing/nodes/__init__.py,sha256=DqPG3Y8vl5-UTeox6hn1EE6iwPIJpsM-XeZALHSgLZQ,5058
59
59
  trilogy/core/processing/nodes/base_node.py,sha256=FHrY8GsTKPuMJklOjILbhGqCt5s1nmlj62Z-molARDA,16835
60
60
  trilogy/core/processing/nodes/filter_node.py,sha256=5VtRfKbCORx0dV-vQfgy3gOEkmmscL9f31ExvlODwvY,2461
61
- trilogy/core/processing/nodes/group_node.py,sha256=1caU36nHknnXS-dM6xmL9Mc7kK0wCq-IS3kL7_XYNlA,8024
62
- trilogy/core/processing/nodes/merge_node.py,sha256=bEz1QU2o-fl_O-VotE5dN1GmlZPClufMvUOvL2-2Uo8,15262
61
+ trilogy/core/processing/nodes/group_node.py,sha256=MUvcOg9U5J6TnWBel8eht9PdI9BfAKjUxmfjP_ZXx9o,10484
62
+ trilogy/core/processing/nodes/merge_node.py,sha256=djFBJ8Sq1_crP0g7REGCbMFl4so7zPzabkPgAA1TUl4,15720
63
63
  trilogy/core/processing/nodes/select_node_v2.py,sha256=Xyfq8lU7rP7JTAd8VV0ATDNal64n4xIBgWQsOuMe_Ak,8824
64
64
  trilogy/core/processing/nodes/union_node.py,sha256=fDFzLAUh5876X6_NM7nkhoMvHEdGJ_LpvPokpZKOhx4,1425
65
65
  trilogy/core/processing/nodes/unnest_node.py,sha256=oLKMMNMx6PLDPlt2V5neFMFrFWxET8r6XZElAhSNkO0,2181
66
66
  trilogy/core/processing/nodes/window_node.py,sha256=JXJ0iVRlSEM2IBr1TANym2RaUf_p5E_l2sNykRzXWDo,1710
67
67
  trilogy/core/statements/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
68
- trilogy/core/statements/author.py,sha256=75ZIMOfQFAahtVLpv3qYJre2SAIdf0vrGBsTR5qweDg,14638
68
+ trilogy/core/statements/author.py,sha256=SWB755fSZo0rxc9MzN1E7-NwmTqA7BhFul0ENqU9P5k,14727
69
69
  trilogy/core/statements/build.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
70
70
  trilogy/core/statements/common.py,sha256=KxEmz2ySySyZ6CTPzn0fJl5NX2KOk1RPyuUSwWhnK1g,759
71
71
  trilogy/core/statements/execute.py,sha256=cSlvpHFOqpiZ89pPZ5GDp9Hu6j6uj-5_h21FWm_L-KM,1248
@@ -87,13 +87,13 @@ trilogy/hooks/graph_hook.py,sha256=c-vC-IXoJ_jDmKQjxQyIxyXPOuUcLIURB573gCsAfzQ,2
87
87
  trilogy/hooks/query_debugger.py,sha256=1npRjww94sPV5RRBBlLqMJRaFkH9vhEY6o828MeoEcw,5583
88
88
  trilogy/metadata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
89
89
  trilogy/parsing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
90
- trilogy/parsing/common.py,sha256=_61lUd6DONwteY_D6lanZZgi2NoEWivAxJtrOYrmw34,21915
90
+ trilogy/parsing/common.py,sha256=bhZ_f520D2diRcXC3YeEToPSXBbUBBNq42zII_smdYc,23518
91
91
  trilogy/parsing/config.py,sha256=Z-DaefdKhPDmSXLgg5V4pebhSB0h590vI0_VtHnlukI,111
92
92
  trilogy/parsing/exceptions.py,sha256=92E5i2frv5hj9wxObJZsZqj5T6bglvPzvdvco_vW1Zk,38
93
93
  trilogy/parsing/helpers.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
94
- trilogy/parsing/parse_engine.py,sha256=HBvJewa5zP4y8FasH6nYiqKP9WiIsdl7udfaY_GWxHU,62233
95
- trilogy/parsing/render.py,sha256=ElpmCWUhGs8h489S7cdlbI8bilJlnBgHZ8KMR8y1FrM,18840
96
- trilogy/parsing/trilogy.lark,sha256=h7mJiad7GgTTXnrjntE6OF7xpND5TQlvHqZFfx0nkyk,12993
94
+ trilogy/parsing/parse_engine.py,sha256=2zUqtpEyTaxNUz4L3IkA9RKHEFif7VDjxF0Y7sFigts,63506
95
+ trilogy/parsing/render.py,sha256=hI4y-xjXrEXvHslY2l2TQ8ic0zAOpN41ADH37J2_FZY,19047
96
+ trilogy/parsing/trilogy.lark,sha256=ErSKUy2sqpmc3OnflRbQnzTeE81y3ISfUJBD_wvypFM,13159
97
97
  trilogy/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
98
  trilogy/scripts/trilogy.py,sha256=1L0XrH4mVHRt1C9T1HnaDv2_kYEfbWTb5_-cBBke79w,3774
99
99
  trilogy/std/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -102,8 +102,8 @@ trilogy/std/date.preql,sha256=0MHeGLp2mG4QBKtmozcBZ7qVjOAdWOtrliIKn6hz1Pc,95
102
102
  trilogy/std/display.preql,sha256=2BbhvqR4rcltyAbOXAUo7SZ_yGFYZgFnurglHMbjW2g,40
103
103
  trilogy/std/geography.preql,sha256=-fqAGnBL6tR-UtT8DbSek3iMFg66ECR_B_41pODxv-k,504
104
104
  trilogy/std/money.preql,sha256=ZHW-csTX-kYbOLmKSO-TcGGgQ-_DMrUXy0BjfuJSFxM,80
105
- pytrilogy-0.0.3.32.dist-info/METADATA,sha256=xnPeyXro94RAWPJBvFkrPPA219KCrQe0sAUhb0kcFgA,9100
106
- pytrilogy-0.0.3.32.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
107
- pytrilogy-0.0.3.32.dist-info/entry_points.txt,sha256=ewBPU2vLnVexZVnB-NrVj-p3E-4vukg83Zk8A55Wp2w,56
108
- pytrilogy-0.0.3.32.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
109
- pytrilogy-0.0.3.32.dist-info/RECORD,,
105
+ pytrilogy-0.0.3.34.dist-info/METADATA,sha256=GisPUfJ5BGF4EpuzvClSgxb6ITvhKhX3lv2Ht9brw2M,9100
106
+ pytrilogy-0.0.3.34.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
107
+ pytrilogy-0.0.3.34.dist-info/entry_points.txt,sha256=ewBPU2vLnVexZVnB-NrVj-p3E-4vukg83Zk8A55Wp2w,56
108
+ pytrilogy-0.0.3.34.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
109
+ pytrilogy-0.0.3.34.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.3.32"
7
+ __version__ = "0.0.3.34"
8
8
 
9
9
  __all__ = ["parse", "Executor", "Dialects", "Environment", "CONFIG"]
@@ -19,12 +19,15 @@ from trilogy.core.models.author import (
19
19
  Conditional,
20
20
  FilterItem,
21
21
  Function,
22
+ FunctionCallWrapper,
22
23
  HavingClause,
23
24
  MagicConstants,
24
25
  Metadata,
26
+ MultiSelectLineage,
25
27
  OrderBy,
26
28
  OrderItem,
27
29
  Parenthetical,
30
+ RowsetItem,
28
31
  SubselectComparison,
29
32
  WhereClause,
30
33
  WindowItem,
@@ -103,4 +106,7 @@ __all__ = [
103
106
  "RawSQLStatement",
104
107
  "Datasource",
105
108
  "DatasourceMetadata",
109
+ "MultiSelectLineage",
110
+ "RowsetItem",
111
+ "FunctionCallWrapper",
106
112
  ]
trilogy/core/enums.py CHANGED
@@ -27,6 +27,7 @@ class Purpose(Enum):
27
27
  CONSTANT = "const"
28
28
  KEY = "key"
29
29
  PROPERTY = "property"
30
+ UNIQUE_PROPERTY = "unique_property"
30
31
  METRIC = "metric"
31
32
  ROWSET = "rowset"
32
33
  AUTO = "auto"
@@ -1089,7 +1089,7 @@ class Concept(Addressable, DataTyped, ConceptArgs, Mergeable, Namespaced, BaseMo
1089
1089
  pseudonyms=self.pseudonyms,
1090
1090
  )
1091
1091
 
1092
- @property
1092
+ @cached_property
1093
1093
  def sources(self) -> List["ConceptRef"]:
1094
1094
  if self.lineage:
1095
1095
  output: List[ConceptRef] = []
@@ -1326,7 +1326,7 @@ class OrderItem(Mergeable, ConceptArgs, Namespaced, BaseModel):
1326
1326
 
1327
1327
  class WindowItem(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1328
1328
  type: WindowType
1329
- content: ConceptRef
1329
+ content: FuncArgs
1330
1330
  order_by: List["OrderItem"]
1331
1331
  over: List["ConceptRef"] = Field(default_factory=list)
1332
1332
  index: Optional[int] = None
@@ -1335,7 +1335,7 @@ class WindowItem(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1335
1335
  return self.__repr__()
1336
1336
 
1337
1337
  def __repr__(self):
1338
- return f"{self.type}({self.content} {self.index}, {self.over}, {self.order_by})"
1338
+ return f"{self.type.value} {self.content} by {self.index} over {self.over} order {self.order_by}"
1339
1339
 
1340
1340
  @field_validator("content", mode="before")
1341
1341
  def enforce_concept_ref(cls, v):
@@ -1358,7 +1358,11 @@ class WindowItem(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1358
1358
  ) -> "WindowItem":
1359
1359
  output = WindowItem.model_construct(
1360
1360
  type=self.type,
1361
- content=self.content.with_merge(source, target, modifiers),
1361
+ content=(
1362
+ self.content.with_merge(source, target, modifiers)
1363
+ if isinstance(self.content, Mergeable)
1364
+ else self.content
1365
+ ),
1362
1366
  over=[x.with_merge(source, target, modifiers) for x in self.over],
1363
1367
  order_by=[x.with_merge(source, target, modifiers) for x in self.order_by],
1364
1368
  index=self.index,
@@ -1379,7 +1383,11 @@ class WindowItem(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1379
1383
  def with_namespace(self, namespace: str) -> "WindowItem":
1380
1384
  return WindowItem.model_construct(
1381
1385
  type=self.type,
1382
- content=self.content.with_namespace(namespace),
1386
+ content=(
1387
+ self.content.with_namespace(namespace)
1388
+ if isinstance(self.content, Namespaced)
1389
+ else self.content
1390
+ ),
1383
1391
  over=[x.with_namespace(namespace) for x in self.over],
1384
1392
  order_by=[x.with_namespace(namespace) for x in self.order_by],
1385
1393
  index=self.index,
@@ -1387,7 +1395,8 @@ class WindowItem(DataTyped, ConceptArgs, Mergeable, Namespaced, BaseModel):
1387
1395
 
1388
1396
  @property
1389
1397
  def concept_arguments(self) -> List[ConceptRef]:
1390
- output = [self.content]
1398
+ output = []
1399
+ output += get_concept_arguments(self.content)
1391
1400
  for order in self.order_by:
1392
1401
  output += get_concept_arguments(order)
1393
1402
  for item in self.over:
@@ -1787,6 +1796,10 @@ class FunctionCallWrapper(
1787
1796
  ],
1788
1797
  )
1789
1798
 
1799
+ def with_reference_replacement(self, source, target):
1800
+ raise NotImplementedError("Cannot reference replace")
1801
+ return self
1802
+
1790
1803
  def with_merge(
1791
1804
  self, source: Concept, target: Concept, modifiers: List[Modifier]
1792
1805
  ) -> "FunctionCallWrapper":
@@ -1646,7 +1646,7 @@ class Factory:
1646
1646
 
1647
1647
  @build.register
1648
1648
  def _(self, base: WindowItem) -> BuildWindowItem:
1649
-
1649
+ # to do proper discovery, we need to inject virtual intermediate ocncepts
1650
1650
  return BuildWindowItem.model_construct(
1651
1651
  type=base.type,
1652
1652
  content=self.build(base.content),
@@ -31,19 +31,12 @@ class BuildEnvironmentConceptDict(dict):
31
31
  def raise_undefined(
32
32
  self, key: str, line_no: int | None = None, file: Path | str | None = None
33
33
  ) -> Never:
34
-
35
- matches = self._find_similar_concepts(key)
36
- message = f"Undefined concept: {key}."
37
- if matches:
38
- message += f" Suggestions: {matches}"
39
-
40
- if line_no:
41
- if file:
42
- raise UndefinedConceptException(
43
- f"{file}: {line_no}: " + message, matches
44
- )
45
- raise UndefinedConceptException(f"line: {line_no}: " + message, matches)
46
- raise UndefinedConceptException(message, matches)
34
+ # build environment should never check for missing values.
35
+ if line_no is not None:
36
+ message = f"Concept '{key}' not found in environment at line {line_no}."
37
+ else:
38
+ message = f"Concept '{key}' not found in environment."
39
+ raise UndefinedConceptException(message, [])
47
40
 
48
41
  def __getitem__(
49
42
  self, key: str, line_no: int | None = None, file: Path | None = None
@@ -686,7 +686,8 @@ class Environment(BaseModel):
686
686
  replacements[k] = target
687
687
  # we need to update keys and grains of all concepts
688
688
  else:
689
- replacements[k] = v.with_merge(source, target, modifiers)
689
+ if source.address in v.sources or source.address in v.grain.components:
690
+ replacements[k] = v.with_merge(source, target, modifiers)
690
691
  self.concepts.update(replacements)
691
692
  for k, ds in self.datasources.items():
692
693
  if source.address in ds.output_lcl:
@@ -282,6 +282,7 @@ class CTE(BaseModel):
282
282
  **self.existence_source_map,
283
283
  **other.existence_source_map,
284
284
  }
285
+
285
286
  return self
286
287
 
287
288
  @property
@@ -637,7 +638,7 @@ class QueryDatasource(BaseModel):
637
638
  and CONFIG.validate_missing
638
639
  ):
639
640
  raise SyntaxError(
640
- f"On query datasource missing source map for {concept.address} on {key}, have {v}"
641
+ f"On query datasource missing source map for {concept.address} on {key} with pseudonyms {concept.pseudonyms}, have {v}"
641
642
  )
642
643
  return v
643
644
 
@@ -764,8 +765,15 @@ class QueryDatasource(BaseModel):
764
765
  def identifier(self) -> str:
765
766
  filters = abs(hash(str(self.condition))) if self.condition else ""
766
767
  grain = "_".join([str(c).replace(".", "_") for c in self.grain.components])
768
+ group = ""
769
+ if self.source_type == SourceType.GROUP:
770
+ keys = [
771
+ x.address for x in self.output_concepts if x.purpose != Purpose.METRIC
772
+ ]
773
+ group = "_grouped_by_" + "_".join(keys)
767
774
  return (
768
775
  "_join_".join([d.identifier for d in self.datasources])
776
+ + group
769
777
  + (f"_at_{grain}" if grain else "_at_abstract")
770
778
  + (f"_filtered_by_{filters}" if filters else "")
771
779
  )
@@ -94,6 +94,11 @@ def filter_irrelevant_ctes(
94
94
  inverse_map = gen_inverse_map(input)
95
95
  recurse(root_cte, inverse_map)
96
96
  final = [cte for cte in input if cte.name in relevant_ctes]
97
+ filtered = [cte for cte in input if cte.name not in relevant_ctes]
98
+ if filtered:
99
+ logger.info(
100
+ f"[Optimization][Irrelevent CTE filtering] Removing redundant CTEs {[x.name for x in filtered]}"
101
+ )
97
102
  if len(final) == len(input):
98
103
  return input
99
104
  return filter_irrelevant_ctes(final, root_cte)
@@ -130,6 +135,9 @@ def is_direct_return_eligible(cte: CTE | UnionCTE) -> CTE | UnionCTE | None:
130
135
  if not output_addresses.issubset(parent_output_addresses):
131
136
  return None
132
137
  if not direct_parent.grain == cte.grain:
138
+ logger.info("grain mismatch, cannot early exit")
139
+ logger.info(direct_parent.grain)
140
+ logger.info(cte.grain)
133
141
  return None
134
142
 
135
143
  assert isinstance(cte, CTE)
@@ -625,6 +625,7 @@ def validate_stack(
625
625
  for concept in resolved.output_concepts:
626
626
  if concept.address in resolved.hidden_concepts:
627
627
  continue
628
+
628
629
  validate_concept(
629
630
  concept,
630
631
  node,
@@ -773,6 +774,8 @@ def _search_concepts(
773
774
  ) -> StrategyNode | None:
774
775
  # these are the concepts we need in the output projection
775
776
  mandatory_list = unique(mandatory_list, "address")
777
+ # cache our values before an filter injection
778
+ original_mandatory = [*mandatory_list]
776
779
  for x in mandatory_list:
777
780
  if isinstance(x, UndefinedConcept):
778
781
  raise SyntaxError(f"Undefined concept {x.address}")
@@ -857,7 +860,7 @@ def _search_concepts(
857
860
  and priority_concept.address not in conditions.row_arguments
858
861
  ):
859
862
  logger.info(
860
- f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Force including conditions to push filtering above complex condition that is not condition member or parent"
863
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Force including conditions in {priority_concept.address} to push filtering above complex condition that is not condition member or parent"
861
864
  )
862
865
  local_conditions = conditions
863
866
 
@@ -921,7 +924,7 @@ def _search_concepts(
921
924
  mandatory_completion = [c.address for c in completion_mandatory]
922
925
  logger.info(
923
926
  f"{depth_to_prefix(depth)}{LOGGER_PREFIX} finished concept loop for {priority_concept} {priority_concept.derivation} condition {conditions} flag for accepting partial addresses is"
924
- f" {accept_partial} (complete: {complete}), have {found} from {[n for n in stack]} (missing {missing} partial {partial} virtual {virtual}), attempted {attempted}, mandatory w/ filter {mandatory_completion}"
927
+ f" {accept_partial} (complete: {complete}), have {found} from {[n for n in stack]} (missing {missing} synonyms partial {partial} virtual {virtual}), attempted {attempted}, mandatory w/ filter {mandatory_completion}"
925
928
  )
926
929
  if complete == ValidationResult.INCOMPLETE_CONDITION:
927
930
  cond_dict = {str(node): node.preexisting_conditions for node in stack}
@@ -933,7 +936,23 @@ def _search_concepts(
933
936
  if complete == ValidationResult.COMPLETE and (
934
937
  not accept_partial or (accept_partial and not partial)
935
938
  ):
939
+ logger.info(
940
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} breaking loop, complete"
941
+ )
936
942
  break
943
+ elif complete == ValidationResult.COMPLETE and accept_partial and partial:
944
+ if len(attempted) == len(mandatory_list):
945
+ logger.info(
946
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Breaking as we have attempted all nodes"
947
+ )
948
+ break
949
+ logger.info(
950
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Found complete stack with partials {partial}, continuing search, attempted {attempted} all {len(mandatory_list)}"
951
+ )
952
+ else:
953
+ logger.info(
954
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Not complete, continuing search"
955
+ )
937
956
  # if we have attempted on root node, we've tried them all.
938
957
  # inject in another search with filter concepts
939
958
  if priority_concept.derivation == Derivation.ROOT:
@@ -948,6 +967,11 @@ def _search_concepts(
948
967
  if complete == ValidationResult.COMPLETE:
949
968
  condition_required = True
950
969
  non_virtual = [c for c in completion_mandatory if c.address not in virtual]
970
+ non_virtual_output = [c for c in original_mandatory if c.address not in virtual]
971
+ non_virtual_different = len(completion_mandatory) != len(original_mandatory)
972
+ non_virtual_difference_values = set(
973
+ [x.address for x in completion_mandatory]
974
+ ).difference(set([x.address for x in original_mandatory]))
951
975
  if not conditions:
952
976
  condition_required = False
953
977
  non_virtual = [c for c in mandatory_list if c.address not in virtual]
@@ -966,7 +990,19 @@ def _search_concepts(
966
990
  )
967
991
  if len(stack) == 1:
968
992
  output: StrategyNode = stack[0]
969
- # _ = restrict_node_outputs_targets(output, mandatory_list, depth)
993
+ if non_virtual_different:
994
+ logger.info(
995
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Found different non-virtual output concepts ({non_virtual_difference_values}), removing condition injected values"
996
+ )
997
+ output.set_output_concepts(
998
+ [
999
+ x
1000
+ for x in output.output_concepts
1001
+ if x.address in non_virtual_output
1002
+ ],
1003
+ rebuild=False,
1004
+ )
1005
+
970
1006
  logger.info(
971
1007
  f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Source stack has single node, returning that {type(output)}"
972
1008
  )
@@ -995,6 +1031,30 @@ def _search_concepts(
995
1031
  logger.info(
996
1032
  f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Graph is connected, returning {type(output)} node partial {[c.address for c in output.partial_concepts]}"
997
1033
  )
1034
+ if condition_required and conditions and non_virtual_different:
1035
+ logger.info(
1036
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Conditions {conditions} were injected, checking if we need a group to restore grain"
1037
+ )
1038
+ result = GroupNode.check_if_required(
1039
+ downstream_concepts=original_mandatory,
1040
+ parents=[output.resolve()],
1041
+ environment=environment,
1042
+ depth=depth,
1043
+ )
1044
+ logger.info(f"gcheck result is {result}")
1045
+ if result.required:
1046
+ logger.info(
1047
+ f"{depth_to_prefix(depth)}{LOGGER_PREFIX} Adding group node"
1048
+ )
1049
+ return GroupNode(
1050
+ output_concepts=original_mandatory,
1051
+ input_concepts=original_mandatory,
1052
+ environment=environment,
1053
+ parents=[output],
1054
+ partial_concepts=output.partial_concepts,
1055
+ preexisting_conditions=conditions.conditional,
1056
+ depth=depth,
1057
+ )
998
1058
  return output
999
1059
 
1000
1060
  # if we can't find it after expanding to a merge, then
@@ -149,7 +149,9 @@ def gen_group_node(
149
149
  g=g,
150
150
  depth=depth,
151
151
  source_concepts=source_concepts,
152
- log_lambda=create_log_lambda(LOGGER_PREFIX, depth, logger),
152
+ log_lambda=create_log_lambda(
153
+ LOGGER_PREFIX + f" for {concept.address}", depth, logger
154
+ ),
153
155
  history=history,
154
156
  conditions=conditions,
155
157
  )
@@ -117,7 +117,11 @@ def gen_rowset_node(
117
117
  f"{padding(depth)}{LOGGER_PREFIX} final output is {[x.address for x in node.output_concepts]}"
118
118
  )
119
119
  if not local_optional or all(
120
- x.address in node.output_concepts and x.address not in node.partial_concepts
120
+ (
121
+ x.address in node.output_concepts
122
+ or (z in x.pseudonyms for z in node.output_concepts)
123
+ )
124
+ and x.address not in node.partial_concepts
121
125
  for x in local_optional
122
126
  ):
123
127
  logger.info(
@@ -344,12 +344,14 @@ def create_datasource_node(
344
344
  for c in datasource.columns
345
345
  if not c.is_complete and c.concept.address in all_concepts
346
346
  ]
347
+
347
348
  partial_lcl = LooseBuildConceptList(concepts=partial_concepts)
348
349
  nullable_concepts = [
349
350
  c.concept
350
351
  for c in datasource.columns
351
352
  if c.is_nullable and c.concept.address in all_concepts
352
353
  ]
354
+
353
355
  nullable_lcl = LooseBuildConceptList(concepts=nullable_concepts)
354
356
  partial_is_full = conditions and (conditions == datasource.non_partial_for)
355
357
 
@@ -2,7 +2,7 @@ from dataclasses import dataclass
2
2
  from typing import List, Optional
3
3
 
4
4
  from trilogy.constants import logger
5
- from trilogy.core.enums import SourceType
5
+ from trilogy.core.enums import Purpose, SourceType
6
6
  from trilogy.core.models.build import (
7
7
  BuildComparison,
8
8
  BuildConcept,
@@ -78,7 +78,9 @@ class GroupNode(StrategyNode):
78
78
  downstream_concepts: List[BuildConcept],
79
79
  parents: list[QueryDatasource | BuildDatasource],
80
80
  environment: BuildEnvironment,
81
+ depth: int = 0,
81
82
  ) -> GroupRequiredResponse:
83
+ padding = "\t" * depth
82
84
  target_grain = BuildGrain.from_concepts(
83
85
  downstream_concepts,
84
86
  environment=environment,
@@ -95,12 +97,67 @@ class GroupNode(StrategyNode):
95
97
  lookups: list[BuildConcept | str] = [
96
98
  concept_map[x] if x in concept_map else x for x in comp_grain.components
97
99
  ]
100
+
98
101
  comp_grain = BuildGrain.from_concepts(lookups, environment=environment)
102
+
99
103
  # dynamically select if we need to group
100
104
  # because sometimes, we are already at required grain
101
105
  if comp_grain.issubset(target_grain):
106
+
107
+ logger.info(
108
+ f"{padding}{LOGGER_PREFIX} Group requirement check: {comp_grain}, {target_grain}, is subset, no grain required"
109
+ )
102
110
  return GroupRequiredResponse(target_grain, comp_grain, False)
111
+ # find out what extra is in the comp grain vs target grain
112
+ difference = [
113
+ environment.concepts[c] for c in (comp_grain - target_grain).components
114
+ ]
115
+ logger.info(
116
+ f"{padding}{LOGGER_PREFIX} Group requirement check: {comp_grain}, {target_grain}, difference {difference}"
117
+ )
118
+
119
+ # if the difference is all unique properties whose keys are in the source grain
120
+ # we can also suppress the group
121
+ if all(
122
+ [
123
+ x.purpose == Purpose.UNIQUE_PROPERTY
124
+ and x.keys
125
+ and all(z in comp_grain.components for z in x.keys)
126
+ for x in difference
127
+ ]
128
+ ):
129
+ logger.info(
130
+ f"{padding}{LOGGER_PREFIX} Group requirement check: skipped due to unique property validation"
131
+ )
132
+ return GroupRequiredResponse(target_grain, comp_grain, False)
133
+ if all([x.purpose == Purpose.KEY for x in difference]):
134
+ logger.info(
135
+ f"{padding}{LOGGER_PREFIX} checking if downstream is unique properties of key"
136
+ )
137
+ replaced_grain_raw: list[set[str]] = [
138
+ (
139
+ x.keys or set()
140
+ if x.purpose == Purpose.UNIQUE_PROPERTY
141
+ else set([x.address])
142
+ )
143
+ for x in downstream_concepts
144
+ if x.address in target_grain.components
145
+ ]
146
+ # flatten the list of lists
147
+ replaced_grain = [
148
+ item for sublist in replaced_grain_raw for item in sublist
149
+ ]
150
+ # if the replaced grain is a subset of the comp grain, we can skip the group
151
+ unique_grain_comp = BuildGrain.from_concepts(
152
+ replaced_grain, environment=environment
153
+ )
154
+ if comp_grain.issubset(unique_grain_comp):
155
+ logger.info(
156
+ f"{padding}{LOGGER_PREFIX} Group requirement check: skipped due to unique property validation"
157
+ )
158
+ return GroupRequiredResponse(target_grain, comp_grain, False)
103
159
 
160
+ logger.info(f"{padding}{LOGGER_PREFIX} Group requirement check: group required")
104
161
  return GroupRequiredResponse(target_grain, comp_grain, True)
105
162
 
106
163
  def _resolve(self) -> QueryDatasource:
@@ -296,9 +296,21 @@ class MergeNode(StrategyNode):
296
296
  return dataset
297
297
 
298
298
  pregrain = BuildGrain()
299
+
299
300
  for source in final_datasets:
301
+ if all(
302
+ [x.address in self.existence_concepts for x in source.output_concepts]
303
+ ):
304
+ logger.info(
305
+ f"{self.logging_prefix}{LOGGER_PREFIX} skipping existence only source with {source.output_concepts} from grain accumulation"
306
+ )
307
+ continue
300
308
  pregrain += source.grain
301
309
 
310
+ pregrain = BuildGrain.from_concepts(
311
+ pregrain.components, environment=self.environment
312
+ )
313
+
302
314
  grain = self.grain if self.grain else pregrain
303
315
  logger.info(
304
316
  f"{self.logging_prefix}{LOGGER_PREFIX} has pre grain {pregrain} and final merge node grain {grain}"
@@ -310,6 +322,7 @@ class MergeNode(StrategyNode):
310
322
  )
311
323
  else:
312
324
  joins = []
325
+
313
326
  logger.info(
314
327
  f"{self.logging_prefix}{LOGGER_PREFIX} Final join count for CTE parent count {len(join_candidates)} is {len(joins)}"
315
328
  )
@@ -343,7 +356,6 @@ class MergeNode(StrategyNode):
343
356
  nullable_concepts = find_nullable_concepts(
344
357
  source_map=source_map, joins=joins, datasources=final_datasets
345
358
  )
346
-
347
359
  qds = QueryDatasource(
348
360
  input_concepts=unique(self.input_concepts, "address"),
349
361
  output_concepts=unique(self.output_concepts, "address"),
@@ -81,7 +81,7 @@ class JoinOrderOutput:
81
81
 
82
82
 
83
83
  def resolve_join_order_v2(
84
- g: nx.Graph, partials: dict[str, list[str]]
84
+ g: nx.Graph, partials: dict[str, list[str]], nullables: dict[str, list[str]]
85
85
  ) -> list[JoinOrderOutput]:
86
86
  datasources = [x for x in g.nodes if x.startswith("ds~")]
87
87
  concepts = [x for x in g.nodes if x.startswith("c~")]
@@ -108,7 +108,7 @@ def resolve_join_order_v2(
108
108
  root = next_pivots[0]
109
109
  pivots = [x for x in pivots if x != root]
110
110
  else:
111
- root = pivots.pop()
111
+ root = pivots.pop(0)
112
112
 
113
113
  # sort so less partials is last and eligible lefts are
114
114
  def score_key(x: str) -> tuple[int, int, str]:
@@ -119,6 +119,8 @@ def resolve_join_order_v2(
119
119
  # if it has the concept as a partial, lower weight
120
120
  if root in partials.get(x, []):
121
121
  base -= 1
122
+ if root in nullables.get(x, []):
123
+ base -= 1
122
124
  return (base, len(x), x)
123
125
 
124
126
  # get remainig un-joined datasets
@@ -159,9 +161,11 @@ def resolve_join_order_v2(
159
161
  )
160
162
  right_is_partial = any(key in partials.get(right, []) for key in common)
161
163
  # we don't care if left is nullable for join type (just keys), but if we did
162
- # ex: left_is_nullable = any(key in partials.get(left_candidate, [])
164
+ # left_is_nullable = any(
165
+ # key in nullables.get(left_candidate, []) for key in common
166
+ # )
163
167
  right_is_nullable = any(
164
- key in partials.get(right, []) for key in common
168
+ key in nullables.get(right, []) for key in common
165
169
  )
166
170
  if left_is_partial:
167
171
  join_type = JoinType.FULL
@@ -356,6 +360,7 @@ def get_node_joins(
356
360
  ) -> List[BaseJoin]:
357
361
  graph = nx.Graph()
358
362
  partials: dict[str, list[str]] = {}
363
+ nullables: dict[str, list[str]] = {}
359
364
  ds_node_map: dict[str, QueryDatasource | BuildDatasource] = {}
360
365
  concept_map: dict[str, BuildConcept] = {}
361
366
  for datasource in datasources:
@@ -363,6 +368,7 @@ def get_node_joins(
363
368
  ds_node_map[ds_node] = datasource
364
369
  graph.add_node(ds_node, type=NodeType.NODE)
365
370
  partials[ds_node] = [f"c~{c.address}" for c in datasource.partial_concepts]
371
+ nullables[ds_node] = [f"c~{c.address}" for c in datasource.nullable_concepts]
366
372
  for concept in datasource.output_concepts:
367
373
  if concept.address in datasource.hidden_concepts:
368
374
  continue
@@ -374,7 +380,7 @@ def get_node_joins(
374
380
  environment=environment,
375
381
  )
376
382
 
377
- joins = resolve_join_order_v2(graph, partials=partials)
383
+ joins = resolve_join_order_v2(graph, partials=partials, nullables=nullables)
378
384
  return [
379
385
  BaseJoin(
380
386
  left_datasource=ds_node_map[j.left] if j.left else None,
@@ -384,6 +384,11 @@ class MergeStatementV2(HasUUID, BaseModel):
384
384
  modifiers: List[Modifier] = Field(default_factory=list)
385
385
 
386
386
 
387
+ class KeyMergeStatement(HasUUID, BaseModel):
388
+ keys: set[str]
389
+ target: ConceptRef
390
+
391
+
387
392
  class ImportStatement(HasUUID, BaseModel):
388
393
  # import abc.def as bar
389
394
  # the bit after 'as', eg bar
trilogy/parsing/common.py CHANGED
@@ -40,6 +40,7 @@ from trilogy.core.models.author import (
40
40
  UndefinedConcept,
41
41
  WhereClause,
42
42
  WindowItem,
43
+ address_with_namespace,
43
44
  )
44
45
  from trilogy.core.models.core import DataType, arg_to_datatype
45
46
  from trilogy.core.models.environment import Environment
@@ -255,7 +256,7 @@ def concepts_to_grain_concepts(
255
256
  return v2
256
257
 
257
258
 
258
- def get_relevant_parent_concepts(arg) -> tuple[list[ConceptRef], bool]:
259
+ def _get_relevant_parent_concepts(arg) -> tuple[list[ConceptRef], bool]:
259
260
  from trilogy.core.models.author import get_concept_arguments
260
261
 
261
262
  is_metric = False
@@ -270,9 +271,16 @@ def get_relevant_parent_concepts(arg) -> tuple[list[ConceptRef], bool]:
270
271
  return [], True
271
272
  elif isinstance(arg, AggregateWrapper) and arg.by:
272
273
  return arg.by, True
274
+ elif isinstance(arg, FunctionCallWrapper):
275
+ return get_relevant_parent_concepts(arg.content)
273
276
  return get_concept_arguments(arg), False
274
277
 
275
278
 
279
+ def get_relevant_parent_concepts(arg):
280
+ results = _get_relevant_parent_concepts(arg)
281
+ return results
282
+
283
+
276
284
  def function_to_concept(
277
285
  parent: Function,
278
286
  name: str,
@@ -415,10 +423,31 @@ def window_item_to_concept(
415
423
  metadata: Metadata | None = None,
416
424
  ) -> Concept:
417
425
  fmetadata = metadata or Metadata()
426
+ # if isinstance(
427
+ # parent.content,
428
+ # (
429
+ # AggregateWrapper
430
+ # | FunctionCallWrapper
431
+ # | WindowItem
432
+ # | FilterItem
433
+ # | Function
434
+ # | ListWrapper
435
+ # | MapWrapper
436
+ # ),
437
+ # ):
438
+ # new_parent = arbitrary_to_concept(
439
+ # parent.content, environment=environment, namespace=namespace
440
+ # )
441
+ # environment.add_concept(new_parent)
442
+ # parent = parent.model_copy(update={"content": new_parent.reference})
443
+
444
+ if not isinstance(parent.content, ConceptRef):
445
+ raise NotImplementedError(
446
+ f"Window function wiht non ref content {parent.content} not yet supported"
447
+ )
418
448
  bcontent = environment.concepts[parent.content.address]
419
449
  if isinstance(bcontent, UndefinedConcept):
420
450
  return UndefinedConcept(address=f"{namespace}.{name}", metadata=fmetadata)
421
-
422
451
  if bcontent.purpose == Purpose.METRIC:
423
452
  local_purpose, keys = get_purpose_and_keys(None, (bcontent,), environment)
424
453
  else:
@@ -534,38 +563,58 @@ def align_item_to_concept(
534
563
  return new
535
564
 
536
565
 
566
+ def rowset_concept(
567
+ orig_address: ConceptRef,
568
+ environment: Environment,
569
+ rowset: RowsetDerivationStatement,
570
+ pre_output: list[Concept],
571
+ orig: dict[str, Concept],
572
+ orig_map: dict[str, Concept],
573
+ ):
574
+ orig_concept = environment.concepts[orig_address.address]
575
+ name = orig_concept.name
576
+ if isinstance(orig_concept.lineage, FilterItem):
577
+ if orig_concept.lineage.where == rowset.select.where_clause:
578
+ name = environment.concepts[orig_concept.lineage.content.address].name
579
+ base_namespace = (
580
+ f"{rowset.name}.{orig_concept.namespace}"
581
+ if orig_concept.namespace != rowset.namespace
582
+ else rowset.name
583
+ )
584
+
585
+ new_concept = Concept(
586
+ name=name,
587
+ datatype=orig_concept.datatype,
588
+ purpose=orig_concept.purpose,
589
+ lineage=None,
590
+ grain=orig_concept.grain,
591
+ metadata=Metadata(concept_source=ConceptSource.CTE),
592
+ namespace=base_namespace,
593
+ keys=orig_concept.keys,
594
+ derivation=Derivation.ROWSET,
595
+ granularity=orig_concept.granularity,
596
+ pseudonyms={
597
+ address_with_namespace(x, rowset.name) for x in orig_concept.pseudonyms
598
+ },
599
+ )
600
+ for x in orig_concept.pseudonyms:
601
+ new_address = address_with_namespace(x, rowset.name)
602
+ origa = environment.alias_origin_lookup[x]
603
+ environment.concepts[new_address] = new_concept
604
+ environment.alias_origin_lookup[new_address] = origa.model_copy(
605
+ update={"namespace": f"{rowset.name}.{origa.namespace}"}
606
+ )
607
+ orig[orig_concept.address] = new_concept
608
+ orig_map[new_concept.address] = orig_concept
609
+ pre_output.append(new_concept)
610
+
611
+
537
612
  def rowset_to_concepts(rowset: RowsetDerivationStatement, environment: Environment):
538
613
  pre_output: list[Concept] = []
539
614
  orig: dict[str, Concept] = {}
540
615
  orig_map: dict[str, Concept] = {}
541
616
  for orig_address in rowset.select.output_components:
542
- orig_concept = environment.concepts[orig_address.address]
543
- name = orig_concept.name
544
- if isinstance(orig_concept.lineage, FilterItem):
545
- if orig_concept.lineage.where == rowset.select.where_clause:
546
- name = environment.concepts[orig_concept.lineage.content.address].name
547
- base_namespace = (
548
- f"{rowset.name}.{orig_concept.namespace}"
549
- if orig_concept.namespace != rowset.namespace
550
- else rowset.name
551
- )
552
-
553
- new_concept = Concept(
554
- name=name,
555
- datatype=orig_concept.datatype,
556
- purpose=orig_concept.purpose,
557
- lineage=None,
558
- grain=orig_concept.grain,
559
- # TODO: add proper metadata
560
- metadata=Metadata(concept_source=ConceptSource.CTE),
561
- namespace=base_namespace,
562
- keys=orig_concept.keys,
563
- derivation=Derivation.ROWSET,
564
- granularity=orig_concept.granularity,
565
- )
566
- orig[orig_concept.address] = new_concept
567
- orig_map[new_concept.address] = orig_concept
568
- pre_output.append(new_concept)
617
+ rowset_concept(orig_address, environment, rowset, pre_output, orig, orig_map)
569
618
  select_lineage = rowset.select.as_lineage(environment)
570
619
  for x in pre_output:
571
620
  x.lineage = RowsetItem(
@@ -116,6 +116,7 @@ from trilogy.core.statements.author import (
116
116
  CopyStatement,
117
117
  FunctionDeclaration,
118
118
  ImportStatement,
119
+ KeyMergeStatement,
119
120
  Limit,
120
121
  MergeStatementV2,
121
122
  MultiSelectStatement,
@@ -252,6 +253,8 @@ def rehydrate_lineage(
252
253
  )
253
254
  return lineage
254
255
  elif isinstance(lineage, WindowItem):
256
+ # this is temporarily guaranteed until we do some upstream work
257
+ assert isinstance(lineage.content, ConceptRef)
255
258
  lineage.content.datatype = environment.concepts[
256
259
  lineage.content.address
257
260
  ].datatype
@@ -542,6 +545,10 @@ class ParseToObjects(Transformer):
542
545
 
543
546
  @v_args(meta=True)
544
547
  def concept_property_declaration(self, meta: Meta, args) -> Concept:
548
+ unique = False
549
+ if not args[0] == Purpose.PROPERTY:
550
+ unique = True
551
+ args = args[1:]
545
552
  metadata = Metadata()
546
553
  modifiers = []
547
554
  for arg in args:
@@ -569,7 +576,7 @@ class ParseToObjects(Transformer):
569
576
  concept = Concept(
570
577
  name=name,
571
578
  datatype=args[2],
572
- purpose=args[0],
579
+ purpose=Purpose.PROPERTY if not unique else Purpose.UNIQUE_PROPERTY,
573
580
  metadata=metadata,
574
581
  grain=Grain(components={x.address for x in parents}),
575
582
  namespace=namespace,
@@ -633,7 +640,8 @@ class ParseToObjects(Transformer):
633
640
  source_value = source_value.content
634
641
 
635
642
  if isinstance(
636
- source_value, (FilterItem, WindowItem, AggregateWrapper, Function)
643
+ source_value,
644
+ (FilterItem, WindowItem, AggregateWrapper, Function, FunctionCallWrapper),
637
645
  ):
638
646
  concept = arbitrary_to_concept(
639
647
  source_value,
@@ -883,6 +891,29 @@ class ParseToObjects(Transformer):
883
891
  def over_list(self, args):
884
892
  return [x for x in args]
885
893
 
894
+ @v_args(meta=True)
895
+ def key_merge_statement(self, meta: Meta, args) -> KeyMergeStatement | None:
896
+ key_inputs = args[:-1]
897
+ target = args[-1]
898
+ keys = [self.environment.concepts[grain] for grain in key_inputs]
899
+ target_c = self.environment.concepts[target]
900
+ new = KeyMergeStatement(
901
+ keys=set([x.address for x in keys]),
902
+ target=target_c.reference,
903
+ )
904
+ internal = Concept(
905
+ name="_" + target_c.address.replace(".", "_"),
906
+ namespace=self.environment.namespace,
907
+ purpose=Purpose.PROPERTY,
908
+ keys=set([x.address for x in keys]),
909
+ datatype=target_c.datatype,
910
+ grain=Grain(components={x.address for x in keys}),
911
+ )
912
+ self.environment.add_concept(internal)
913
+ # always a full merge
914
+ self.environment.merge_concept(target_c, internal, [])
915
+ return new
916
+
886
917
  @v_args(meta=True)
887
918
  def merge_statement(self, meta: Meta, args) -> MergeStatementV2 | None:
888
919
  modifiers = []
trilogy/parsing/render.py CHANGED
@@ -51,6 +51,7 @@ from trilogy.core.statements.author import (
51
51
  CopyStatement,
52
52
  FunctionDeclaration,
53
53
  ImportStatement,
54
+ KeyMergeStatement,
54
55
  MergeStatementV2,
55
56
  MultiSelectStatement,
56
57
  PersistStatement,
@@ -525,6 +526,11 @@ class Renderer:
525
526
  return f"MERGE {self.to_string(arg.sources[0])} into {''.join([self.to_string(modifier) for modifier in arg.modifiers])}{self.to_string(arg.targets[arg.sources[0].address])};"
526
527
  return f"MERGE {arg.source_wildcard}.* into {''.join([self.to_string(modifier) for modifier in arg.modifiers])}{arg.target_wildcard}.*;"
527
528
 
529
+ @to_string.register
530
+ def _(self, arg: KeyMergeStatement):
531
+ keys = ", ".join(sorted(list(arg.keys)))
532
+ return f"MERGE PROPERTY <{keys}> from {arg.target.address};"
533
+
528
534
  @to_string.register
529
535
  def _(self, arg: Modifier):
530
536
  if arg == Modifier.PARTIAL:
@@ -10,6 +10,7 @@
10
10
  | rowset_derivation_statement
11
11
  | import_statement
12
12
  | copy_statement
13
+ | key_merge_statement
13
14
  | merge_statement
14
15
  | rawsql_statement
15
16
 
@@ -21,7 +22,8 @@
21
22
  concept_declaration: PURPOSE IDENTIFIER data_type concept_nullable_modifier? metadata?
22
23
  //customer_id.property first_name STRING;
23
24
  //<customer_id,country>.property local_alias STRING
24
- concept_property_declaration: PROPERTY (prop_ident | IDENTIFIER) data_type concept_nullable_modifier? metadata?
25
+ UNIQUE: "UNIQUE"i
26
+ concept_property_declaration: UNIQUE? PROPERTY (prop_ident | IDENTIFIER) data_type concept_nullable_modifier? metadata?
25
27
  //metric post_length <- len(post_text);
26
28
  concept_derivation: (PURPOSE | AUTO | PROPERTY ) (prop_ident | IDENTIFIER) "<-" expr
27
29
 
@@ -75,6 +77,8 @@
75
77
 
76
78
  align_clause: align_item ("AND"i align_item)* "AND"i?
77
79
 
80
+ key_merge_statement: "merge"i "property"i "<" IDENTIFIER ("," IDENTIFIER )* ","? ">" "from"i IDENTIFIER
81
+
78
82
  merge_statement: "merge"i WILDCARD_IDENTIFIER "into"i SHORTHAND_MODIFIER? WILDCARD_IDENTIFIER
79
83
 
80
84
  // raw sql statement