pytrilogy 0.0.3.96__py3-none-any.whl → 0.0.3.98__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.96
3
+ Version: 0.0.3.98
4
4
  Summary: Declarative, typed query language that compiles to SQL.
5
5
  Home-page:
6
6
  Author:
@@ -334,7 +334,21 @@ from pytrilogy.authoring import Concept, Function, ...
334
334
 
335
335
  Are likely to be unstable. Open an issue if you need to take dependencies on other modules outside those two paths.
336
336
 
337
- ## Trilogy Syntax Reference
337
+ ## MCP/Server
338
+
339
+ Trilogy is straightforward to run as a server/MCP server; the former to generate SQL on demand and integrate into other tools, and MCP
340
+ for full interactive query loops.
341
+
342
+ This makes it easy to integrate Trilogy into existing tools or workflows.
343
+
344
+ You can see examples of both use cases in the trilogy-studio codebase [here](https://github.com/trilogy-data/trilogy-studio-core)
345
+ and install and run an MCP server directly with that codebase.
346
+
347
+ If you're interested in a more fleshed out standalone server or MCP server, please open an issue and we'll prioritize it!
348
+
349
+ ## Trilogy Syntax Reference
350
+
351
+ Not exhaustive - see [documentation](https://trilogydata.dev/) for more details.
338
352
 
339
353
  ### Import
340
354
  ```sql
@@ -1,8 +1,8 @@
1
- pytrilogy-0.0.3.96.dist-info/licenses/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
2
- trilogy/__init__.py,sha256=hBxtfxlbUvTLp_8FCY_-wDqJM7G2RJQ2jntfMf3a0PM,303
3
- trilogy/constants.py,sha256=eKb_EJvSqjN9tGbdVEViwdtwwh8fZ3-jpOEDqL71y70,1691
1
+ pytrilogy-0.0.3.98.dist-info/licenses/LICENSE.md,sha256=5ZRvtTyCCFwz1THxDTjAu3Lidds9WjPvvzgVwPSYNDo,1042
2
+ trilogy/__init__.py,sha256=NSBY37z8wVkVSFPfHPo0BTvi3hO7KMw7ENbaoX9PqBY,303
3
+ trilogy/constants.py,sha256=SSsRMg9HTou259nMKAw-rJNBgzkWjQ3QIQXcrq9i5Kk,1717
4
4
  trilogy/engine.py,sha256=3MiADf5MKcmxqiHBuRqiYdsXiLj7oitDfVvXvHrfjkA,2178
5
- trilogy/executor.py,sha256=YfSjuJ0FVm2gHnNgmUlXijWDTUFjqq9FNakWpeEYO48,15769
5
+ trilogy/executor.py,sha256=0yggm9Ejl1DFELUtRaPxbaU5mpKqYMHZlzSMXOFmODE,16111
6
6
  trilogy/parser.py,sha256=o4cfk3j3yhUFoiDKq9ZX_GjBF3dKhDjXEwb63rcBkBM,293
7
7
  trilogy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  trilogy/render.py,sha256=qQWwduymauOlB517UtM-VGbVe8Cswa4UJub5aGbSO6c,1512
@@ -10,12 +10,12 @@ trilogy/utility.py,sha256=euQccZLKoYBz0LNg5tzLlvv2YHvXh9HArnYp1V3uXsM,763
10
10
  trilogy/authoring/__init__.py,sha256=TABMOETSMERrWuyDLR0nK4ISlqR0yaqeXrmuOdrSvAY,3060
11
11
  trilogy/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  trilogy/core/constants.py,sha256=nizWYDCJQ1bigQMtkNIEMNTcN0NoEAXiIHLzpelxQ24,201
13
- trilogy/core/enums.py,sha256=EusAzz7o_YrWf64TLIED7MfziFOJk8EHM8se5W3nyJk,8644
13
+ trilogy/core/enums.py,sha256=H8I2Dz4POHZ4ixYCGzNs4c3KDqxLQklGLVfmje1DSMo,8877
14
14
  trilogy/core/env_processor.py,sha256=H-rr2ALj31l5oh3FqeI47Qju6OOfiXBacXNJGNZ92zQ,4521
15
15
  trilogy/core/environment_helpers.py,sha256=TRlqVctqIRBxzfjRBmpQsAVoiCcsEKBhG1B6PUE0l1M,12743
16
16
  trilogy/core/ergonomics.py,sha256=e-7gE29vPLFdg0_A1smQ7eOrUwKl5VYdxRSTddHweRA,1631
17
- trilogy/core/exceptions.py,sha256=0Lmc3awJYx94k6uifbHc-EIqlFGV6YrX0QIwP84D4a4,1150
18
- trilogy/core/functions.py,sha256=ESUWMRmwtavwCLl6z1NP9EFzWTJoXn3orTaaOSsj33Q,33093
17
+ trilogy/core/exceptions.py,sha256=fI16oTNCVMMAJFSn2AFzZVapzsF5M9WbdN5e5UixwXc,2807
18
+ trilogy/core/functions.py,sha256=oY-F0hsA9vp1ZipGTyx4QVtz_x83Ekk-lkHv6mMkHVQ,33095
19
19
  trilogy/core/graph_models.py,sha256=4EWFTHGfYd72zvS2HYoV6hm7nMC_VEd7vWr6txY-ig0,3400
20
20
  trilogy/core/internal.py,sha256=r9QagDB2GvpqlyD_I7VrsfbVfIk5mnok2znEbv72Aa4,2681
21
21
  trilogy/core/optimization.py,sha256=ojpn-p79lr03SSVQbbw74iPCyoYpDYBmj1dbZ3oXCjI,8860
@@ -28,7 +28,7 @@ trilogy/core/models/build_environment.py,sha256=mpx7MKGc60fnZLVdeLi2YSREy7eQbQYy
28
28
  trilogy/core/models/core.py,sha256=EofJ8-kltNr_7oFhyCPqauVX1bSJzJI5xOp0eMP_vlA,12892
29
29
  trilogy/core/models/datasource.py,sha256=wogTevZ-9CyUW2a8gjzqMCieircxi-J5lkI7EOAZnck,9596
30
30
  trilogy/core/models/environment.py,sha256=hwTIRnJgaHUdCYof7U5A9NPitGZ2s9yxqiW5O2SaJ9Y,28759
31
- trilogy/core/models/execute.py,sha256=k2--2xUNuoaObkzutYaS5sdUFnY9zT_UKdU2rViq9XQ,42106
31
+ trilogy/core/models/execute.py,sha256=lQTpiuNhBT4In-oQ76ImgIoTdUbs4mmyd0J0iTOZOdw,42105
32
32
  trilogy/core/optimizations/__init__.py,sha256=YH2-mGXZnVDnBcWVi8vTbrdw7Qs5TivG4h38rH3js_I,290
33
33
  trilogy/core/optimizations/base_optimization.py,sha256=gzDOKImoFn36k7XBD3ysEYDnbnb6vdVIztUfFQZsGnM,513
34
34
  trilogy/core/optimizations/inline_datasource.py,sha256=2sWNRpoRInnTgo9wExVT_r9RfLAQHI57reEV5cGHUcg,4329
@@ -49,7 +49,7 @@ trilogy/core/processing/node_generators/filter_node.py,sha256=ArBsQJl-4fWBJWCE28
49
49
  trilogy/core/processing/node_generators/group_node.py,sha256=8HJ1lkOvIXfX3xoS2IMbM_wCu_mT0J_hQ7xnTaxsVlo,6611
50
50
  trilogy/core/processing/node_generators/group_to_node.py,sha256=jKcNCDOY6fNblrdZwaRU0sbUSr9H0moQbAxrGgX6iGA,3832
51
51
  trilogy/core/processing/node_generators/multiselect_node.py,sha256=GWV5yLmKTe1yyPhN60RG1Rnrn4ktfn9lYYXi_FVU4UI,7061
52
- trilogy/core/processing/node_generators/node_merge_node.py,sha256=83FkcYuOFyDY0_0NWhL45MAT5J_6Y6L1h357WrJPzaI,18230
52
+ trilogy/core/processing/node_generators/node_merge_node.py,sha256=cBcZm3AUfx4K70MIhU0T9iKMxw_qZRVFhQ0i6qMQeoQ,21999
53
53
  trilogy/core/processing/node_generators/recursive_node.py,sha256=l5zdh0dURKwmAy8kK4OpMtZfyUEQRk6N-PwSWIyBpSM,2468
54
54
  trilogy/core/processing/node_generators/rowset_node.py,sha256=5L5u6xz1In8EaHQdcYgR2si-tz9WB9YLXURo4AkUT9A,6630
55
55
  trilogy/core/processing/node_generators/select_merge_node.py,sha256=KQvGoNT5ZBWQ_caEomRTtG1PKZC7OPT4PKfY0QmwMGE,22270
@@ -76,19 +76,20 @@ trilogy/core/statements/build.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
76
76
  trilogy/core/statements/common.py,sha256=VnVLULQg1TJLNUFzJaROT1tsf2ewk3IpuhvZaP36R6A,535
77
77
  trilogy/core/statements/execute.py,sha256=kiwJcVeMa4wZR-xLfM2oYOJ9DeyJkP8An38WFyJxktM,2413
78
78
  trilogy/core/validation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
- trilogy/core/validation/common.py,sha256=cVbDSowtLf2nl0-QVmNauAeLBBNFkSE5bRZtTHIzW20,3193
80
- trilogy/core/validation/concept.py,sha256=23wZYw_cGmTQuFvaRM-0T7M2b5ZwqjFMucfvfzyQxlc,4425
81
- trilogy/core/validation/datasource.py,sha256=HIk7iEKK99k0-WXosiIhedH-U2rDGYGdUMKEt1eMl1w,6394
82
- trilogy/core/validation/environment.py,sha256=yjSnEH893mTiW9o6YXBtXJWbGSR2kMQWjszDuECznLs,2784
79
+ trilogy/core/validation/common.py,sha256=Sd-towAX1uSDe3dK51FcVtIwVrMhayEwdHqhzeJHro0,4776
80
+ trilogy/core/validation/concept.py,sha256=PM2BxBxLvuBScSWZMPsDZVcOblDil5pNT0pHLcLhdPA,5242
81
+ trilogy/core/validation/datasource.py,sha256=d9AQNcukIRgN2spItPsXFiNtlZva-lDnfei3i06yQCE,6489
82
+ trilogy/core/validation/environment.py,sha256=ymvhQyt7jLK641JAAIQkqjQaAmr9C5022ILzYvDgPP0,2835
83
+ trilogy/core/validation/fix.py,sha256=Z818UFNLxndMTLiyhB3doLxIfnOZ-16QGvVFWuD7UsA,3750
83
84
  trilogy/dialect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
84
- trilogy/dialect/base.py,sha256=m2a8azbI3AWfQz-VtSn84H5T-BqjF5PULH6BrgwZzok,49666
85
+ trilogy/dialect/base.py,sha256=0QVHv4F0t3_gRQrZ0woFoUNKu7vaXGo-BG1l47CZUKc,49698
85
86
  trilogy/dialect/bigquery.py,sha256=XS3hpybeowgfrOrkycAigAF3NX2YUzTzfgE6f__2fT4,4316
86
- trilogy/dialect/common.py,sha256=tSthIZOXXRPQ4KeMKnDDsH7KlTmf2EVqigVtLyoc4zc,6071
87
+ trilogy/dialect/common.py,sha256=_MarnMWRBn3VcNt3k5VUdFrwH6oHzGdNQquSpHNLq4o,5644
87
88
  trilogy/dialect/config.py,sha256=olnyeVU5W5T6b9-dMeNAnvxuPlyc2uefb7FRME094Ec,3834
88
89
  trilogy/dialect/dataframe.py,sha256=RUbNgReEa9g3pL6H7fP9lPTrAij5pkqedpZ99D8_5AE,1522
89
90
  trilogy/dialect/duckdb.py,sha256=JoUvQ19WvgxoaJkGLM7DPXOd1H0394k3vBiblksQzOI,5631
90
91
  trilogy/dialect/enums.py,sha256=FRNYQ5-w-B6-X0yXKNU5g9GowsMlERFogTC5u2nxL_s,4740
91
- trilogy/dialect/metadata.py,sha256=Vt4-p82bD1ijqeoI2dagUVUbC-KgNNJ2MvDwQIa5mG8,7034
92
+ trilogy/dialect/metadata.py,sha256=p_V-MYPQ2iR6fcTjagnptCIWtsZe4fTfoS_iXpavPzY,7098
92
93
  trilogy/dialect/postgres.py,sha256=el2PKwfyvWGk5EZtLudqAH5ewLitY1sFHJiocBSyxyM,3393
93
94
  trilogy/dialect/presto.py,sha256=k1IaeilR3nzPC9Hp7jlAdzJ7TsuxB3LQTBQ28MYE7O8,3715
94
95
  trilogy/dialect/snowflake.py,sha256=T6_mKfhpDazB1xQxqFLS2AJwzwzBcPYY6_qxRnAtFBs,3326
@@ -103,21 +104,22 @@ trilogy/parsing/common.py,sha256=550-L0444GUuBFdiDWkOg_DxnMXtcJFUMES2R5zlwik,310
103
104
  trilogy/parsing/config.py,sha256=Z-DaefdKhPDmSXLgg5V4pebhSB0h590vI0_VtHnlukI,111
104
105
  trilogy/parsing/exceptions.py,sha256=Xwwsv2C9kSNv2q-HrrKC1f60JNHShXcCMzstTSEbiCw,154
105
106
  trilogy/parsing/helpers.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
106
- trilogy/parsing/parse_engine.py,sha256=fZGFNN7PEVJ7_tk0GxBHJQerIJTifbrjoMcHfTR_TYk,81647
107
- trilogy/parsing/render.py,sha256=HSNntD82GiiwHT-TWPLXAaIMWLYVV5B5zQEsgwrHIBE,19605
108
- trilogy/parsing/trilogy.lark,sha256=9-SMrKFGZLdBTNheK1szif0VYOIt5m0xhVd8pOFCByU,16267
107
+ trilogy/parsing/parse_engine.py,sha256=-3_-EoiciWGOlylKurSPlG7gYSbScjneBup7ZvjDz-c,81800
108
+ trilogy/parsing/render.py,sha256=tqB3GlGk3bX6AbkJjvADad2QH6n63nw1kgrpjzLX2tI,20520
109
+ trilogy/parsing/trilogy.lark,sha256=rM4WleeyGhoRgU-FOGcaeHOzZcYVxN4f13e_3B4OeLQ,16389
109
110
  trilogy/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
110
111
  trilogy/scripts/trilogy.py,sha256=1L0XrH4mVHRt1C9T1HnaDv2_kYEfbWTb5_-cBBke79w,3774
111
112
  trilogy/std/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
112
113
  trilogy/std/date.preql,sha256=HWZm4t4HWyxr5geWRsY05RnHBVDMci8z8YA2cu0-OOw,188
113
114
  trilogy/std/display.preql,sha256=nm7lox87Xf6lBvXCVCS6x2HskguMKzndEBucJ5pktzk,175
114
115
  trilogy/std/geography.preql,sha256=1A9Sq5PPMBnEPPf7f-rPVYxJfsnWpQ8oV_k4Fm3H2dU,675
116
+ trilogy/std/metric.preql,sha256=DRECGhkMyqfit5Fl4Ut9zbWrJuSMI1iO9HikuyoBpE0,421
115
117
  trilogy/std/money.preql,sha256=XWwvAV3WxBsHX9zfptoYRnBigcfYwrYtBHXTME0xJuQ,2082
116
118
  trilogy/std/net.preql,sha256=WZCuvH87_rZntZiuGJMmBDMVKkdhTtxeHOkrXNwJ1EE,416
117
119
  trilogy/std/ranking.preql,sha256=LDoZrYyz4g3xsII9XwXfmstZD-_92i1Eox1UqkBIfi8,83
118
120
  trilogy/std/report.preql,sha256=LbV-XlHdfw0jgnQ8pV7acG95xrd1-p65fVpiIc-S7W4,202
119
- pytrilogy-0.0.3.96.dist-info/METADATA,sha256=l4yiGzDzMYd4of8nPXBMBKyetasPk4yfIOyENWjEqcU,11023
120
- pytrilogy-0.0.3.96.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
121
- pytrilogy-0.0.3.96.dist-info/entry_points.txt,sha256=ewBPU2vLnVexZVnB-NrVj-p3E-4vukg83Zk8A55Wp2w,56
122
- pytrilogy-0.0.3.96.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
123
- pytrilogy-0.0.3.96.dist-info/RECORD,,
121
+ pytrilogy-0.0.3.98.dist-info/METADATA,sha256=8FfhicjKv1X47j0nL557PM_b1TisXXmAz3DG2B-PBdI,11683
122
+ pytrilogy-0.0.3.98.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
123
+ pytrilogy-0.0.3.98.dist-info/entry_points.txt,sha256=ewBPU2vLnVexZVnB-NrVj-p3E-4vukg83Zk8A55Wp2w,56
124
+ pytrilogy-0.0.3.98.dist-info/top_level.txt,sha256=cAy__NW_eMAa_yT9UnUNlZLFfxcg6eimUAZ184cdNiE,8
125
+ pytrilogy-0.0.3.98.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.96"
7
+ __version__ = "0.0.3.98"
8
8
 
9
9
  __all__ = ["parse", "Executor", "Dialects", "Environment", "CONFIG"]
trilogy/constants.py CHANGED
@@ -16,6 +16,7 @@ ENV_CACHE_NAME = ".preql_cache.json"
16
16
 
17
17
  class MagicConstants(Enum):
18
18
  NULL = "null"
19
+ LINE_SEPARATOR = "\n"
19
20
 
20
21
 
21
22
  NULL_VALUE = MagicConstants.NULL
trilogy/core/enums.py CHANGED
@@ -82,6 +82,15 @@ class Modifier(Enum):
82
82
  return Modifier.NULLABLE
83
83
  return super()._missing_(value=strval.capitalize())
84
84
 
85
+ def __lt__(self, other):
86
+ order = [
87
+ Modifier.HIDDEN,
88
+ Modifier.PARTIAL,
89
+ Modifier.NULLABLE,
90
+ Modifier.OPTIONAL,
91
+ ]
92
+ return order.index(self) < order.index(other)
93
+
85
94
 
86
95
  class JoinType(Enum):
87
96
  INNER = "inner"
@@ -1,4 +1,15 @@
1
- from typing import List, Sequence
1
+ from dataclasses import dataclass
2
+ from typing import Any, List, Sequence
3
+
4
+ from trilogy.core.enums import Modifier
5
+ from trilogy.core.models.core import (
6
+ ArrayType,
7
+ DataType,
8
+ MapType,
9
+ NumericType,
10
+ StructType,
11
+ TraitDataType,
12
+ )
2
13
 
3
14
 
4
15
  class UndefinedConceptException(Exception):
@@ -29,7 +40,7 @@ class ModelValidationError(Exception):
29
40
  self,
30
41
  message,
31
42
  children: Sequence["ModelValidationError"] | None = None,
32
- **kwargs
43
+ **kwargs,
33
44
  ):
34
45
  super().__init__(self, message, **kwargs)
35
46
  self.message = message
@@ -40,6 +51,49 @@ class DatasourceModelValidationError(ModelValidationError):
40
51
  pass
41
52
 
42
53
 
54
+ class DatasourceGrainValidationError(DatasourceModelValidationError):
55
+ pass
56
+
57
+
58
+ @dataclass
59
+ class DatasourceColumnBindingData:
60
+ address: str
61
+ value: Any
62
+ value_type: (
63
+ DataType | ArrayType | StructType | MapType | NumericType | TraitDataType
64
+ )
65
+ value_modifiers: List[Modifier]
66
+ actual_type: (
67
+ DataType | ArrayType | StructType | MapType | NumericType | TraitDataType
68
+ )
69
+ actual_modifiers: List[Modifier]
70
+
71
+ def format_failure(self):
72
+ return f"Concept {self.address} value '{self.value}' with type {self.value_modifiers} does not conform to expected type {str(self.actual_type)} with modifiers {self.actual_modifiers}"
73
+
74
+ def is_modifier_issue(self) -> bool:
75
+ return len(self.value_modifiers) > 0 and any(
76
+ [x not in self.actual_modifiers for x in self.value_modifiers]
77
+ )
78
+
79
+ def is_type_issue(self) -> bool:
80
+ return self.value_type != self.actual_type
81
+
82
+
83
+ class DatasourceColumnBindingError(DatasourceModelValidationError):
84
+ def __init__(
85
+ self,
86
+ address: str,
87
+ errors: list[DatasourceColumnBindingData],
88
+ message: str | None = None,
89
+ ):
90
+ if not message:
91
+ message = f"Datasource {address} failed validation. Found rows that do not conform to types: {[failure.format_failure() for failure in errors]}"
92
+ super().__init__(message)
93
+ self.errors = errors
94
+ self.dataset_address = address
95
+
96
+
43
97
  class ConceptModelValidationError(ModelValidationError):
44
98
  pass
45
99
 
trilogy/core/functions.py CHANGED
@@ -427,7 +427,7 @@ FUNCTION_REGISTRY: dict[FunctionType, FunctionConfig] = {
427
427
  {DataType.STRING},
428
428
  ],
429
429
  output_purpose=Purpose.PROPERTY,
430
- output_type=DataType.BOOL,
430
+ output_type=DataType.STRING,
431
431
  arg_count=2,
432
432
  ),
433
433
  FunctionType.SUBSTRING: FunctionConfig(
@@ -151,7 +151,6 @@ class CTE(BaseModel):
151
151
  ]
152
152
  ):
153
153
  return False
154
-
155
154
  self.source.datasources = [
156
155
  ds_being_inlined,
157
156
  *[
@@ -1,4 +1,5 @@
1
- from typing import List, Optional
1
+ from itertools import combinations
2
+ from typing import Callable, List, Optional
2
3
 
3
4
  import networkx as nx
4
5
  from networkx.algorithms import approximation as ax
@@ -11,7 +12,12 @@ from trilogy.core.graph_models import (
11
12
  concept_to_node,
12
13
  prune_sources_for_conditions,
13
14
  )
14
- from trilogy.core.models.build import BuildConcept, BuildConditional, BuildWhereClause
15
+ from trilogy.core.models.build import (
16
+ BuildConcept,
17
+ BuildConditional,
18
+ BuildGrain,
19
+ BuildWhereClause,
20
+ )
15
21
  from trilogy.core.models.build_environment import BuildEnvironment
16
22
  from trilogy.core.processing.nodes import History, MergeNode, StrategyNode
17
23
  from trilogy.core.processing.utility import padding
@@ -29,7 +35,10 @@ def filter_pseudonyms_for_source(
29
35
  if edge in pseudonyms:
30
36
  lengths = {}
31
37
  for n in edge:
32
- lengths[n] = nx.shortest_path_length(ds_graph, node, n)
38
+ try:
39
+ lengths[n] = nx.shortest_path_length(ds_graph, node, n)
40
+ except nx.NetworkXNoPath:
41
+ lengths[n] = 999
33
42
  to_remove.add(max(lengths, key=lambda x: lengths.get(x, 0)))
34
43
  for node in to_remove:
35
44
  ds_graph.remove_node(node)
@@ -84,12 +93,104 @@ def extract_ds_components(
84
93
  return graphs
85
94
 
86
95
 
96
+ def prune_and_merge(
97
+ G: nx.DiGraph,
98
+ keep_node_lambda: Callable[[str], bool],
99
+ ) -> nx.DiGraph:
100
+ """
101
+ Prune nodes of one type and create direct connections between remaining nodes.
102
+
103
+ Args:
104
+ G: NetworkX graph
105
+ keep_node_type: The node type to keep
106
+ node_type_attr: Attribute name that stores node type
107
+
108
+ Returns:
109
+ New graph with only keep_node_type nodes and merged connections
110
+ """
111
+ # Get nodes to keep
112
+ nodes_to_keep = [n for n in G.nodes if keep_node_lambda(n)]
113
+ # Create new graph
114
+ new_graph = G.subgraph(nodes_to_keep).copy()
115
+
116
+ # Find paths between nodes to keep through removed nodes
117
+ nodes_to_remove = [n for n in G.nodes() if n not in nodes_to_keep]
118
+
119
+ for node_pair in combinations(nodes_to_keep, 2):
120
+ n1, n2 = node_pair
121
+
122
+ # Check if there's a path through removed nodes
123
+ try:
124
+ path = nx.shortest_path(G, n1, n2)
125
+ # If path exists and goes through nodes we're removing
126
+ if len(path) > 2 or any(node in nodes_to_remove for node in path[1:-1]):
127
+ new_graph.add_edge(n1, n2)
128
+ except nx.NetworkXNoPath:
129
+ continue
130
+
131
+ return new_graph
132
+
133
+
134
+ def reinject_common_join_keys_v2(
135
+ G: ReferenceGraph,
136
+ final: nx.DiGraph,
137
+ nodelist: list[str],
138
+ synonyms: set[str] = set(),
139
+ ) -> bool:
140
+ # when we've discovered a concept join, for each pair of ds nodes
141
+ # check if they have more keys in common
142
+ # and inject those to discovery as join conditions
143
+ def is_ds_node(n: str) -> bool:
144
+ return n.startswith("ds~")
145
+
146
+ ds_graph = prune_and_merge(final, is_ds_node)
147
+ injected = False
148
+ for datasource in ds_graph.nodes:
149
+ node1 = G.datasources[datasource]
150
+ neighbors = nx.all_neighbors(ds_graph, datasource)
151
+ for neighbor in neighbors:
152
+ node2 = G.datasources[neighbor]
153
+ common_concepts = set(
154
+ x.concept.address for x in node1.columns
155
+ ).intersection(set(x.concept.address for x in node2.columns))
156
+ concrete_concepts = [
157
+ x.concept for x in node1.columns if x.concept.address in common_concepts
158
+ ]
159
+ reduced = BuildGrain.from_concepts(concrete_concepts).components
160
+ existing_addresses = set()
161
+ for concrete in concrete_concepts:
162
+ logger.info(
163
+ f"looking at column {concrete.address} with pseudonyms {concrete.pseudonyms}"
164
+ )
165
+ cnode = concept_to_node(concrete.with_default_grain())
166
+ if cnode in final.nodes:
167
+ existing_addresses.add(concrete.address)
168
+ continue
169
+ for concrete in concrete_concepts:
170
+ if concrete.address in synonyms:
171
+ continue
172
+ if concrete.address not in reduced:
173
+ continue
174
+ # skip anything that is already in the graph pseudonyms
175
+ if any(x in concrete.pseudonyms for x in existing_addresses):
176
+ continue
177
+ cnode = concept_to_node(concrete.with_default_grain())
178
+ final.add_edge(datasource, cnode)
179
+ final.add_edge(neighbor, cnode)
180
+ logger.info(
181
+ f"{LOGGER_PREFIX} reinjecting common join key {cnode} between {datasource} and {neighbor}"
182
+ )
183
+ injected = True
184
+ return injected
185
+
186
+
87
187
  def determine_induced_minimal_nodes(
88
188
  G: ReferenceGraph,
89
189
  nodelist: list[str],
90
190
  environment: BuildEnvironment,
91
191
  filter_downstream: bool,
92
192
  accept_partial: bool = False,
193
+ synonyms: set[str] = set(),
93
194
  ) -> nx.DiGraph | None:
94
195
  H: nx.Graph = nx.to_undirected(G).copy()
95
196
  nodes_to_remove = []
@@ -129,7 +230,7 @@ def determine_induced_minimal_nodes(
129
230
  return None
130
231
  path_removals = list(x for x in H.nodes if x not in paths)
131
232
  if path_removals:
132
- logger.debug(f"Removing paths {path_removals} from graph")
233
+ # logger.debug(f"Removing paths {path_removals} from graph")
133
234
  H.remove_nodes_from(path_removals)
134
235
  # logger.debug(f"Graph after path removal {H.nodes}")
135
236
  sG: nx.Graph = ax.steinertree.steiner_tree(H, nodelist).copy()
@@ -148,8 +249,10 @@ def determine_induced_minimal_nodes(
148
249
  if not accept_partial:
149
250
  continue
150
251
  final.add_edge(*edge)
151
- # all concept nodes must have a parent
152
252
 
253
+ reinject_common_join_keys_v2(G, final, nodelist, synonyms)
254
+
255
+ # all concept nodes must have a parent
153
256
  if not all(
154
257
  [
155
258
  final.in_degree(node) > 0
@@ -302,6 +405,7 @@ def resolve_weak_components(
302
405
  filter_downstream=filter_downstream,
303
406
  accept_partial=accept_partial,
304
407
  environment=environment,
408
+ synonyms=synonyms,
305
409
  )
306
410
 
307
411
  if not g or not g.nodes:
@@ -2,13 +2,25 @@ from dataclasses import dataclass
2
2
  from enum import Enum
3
3
 
4
4
  from trilogy import Environment
5
- from trilogy.authoring import ConceptRef
5
+ from trilogy.authoring import (
6
+ ConceptRef,
7
+ DataType,
8
+ Ordering,
9
+ Purpose,
10
+ )
11
+ from trilogy.constants import MagicConstants
12
+ from trilogy.core.enums import ComparisonOperator, FunctionType
6
13
  from trilogy.core.exceptions import ModelValidationError
7
14
  from trilogy.core.models.build import (
15
+ BuildCaseElse,
16
+ BuildCaseWhen,
8
17
  BuildComparison,
9
18
  BuildConcept,
10
19
  BuildConditional,
11
20
  BuildDatasource,
21
+ BuildFunction,
22
+ BuildOrderBy,
23
+ BuildOrderItem,
12
24
  )
13
25
  from trilogy.core.models.environment import EnvironmentConceptDict
14
26
  from trilogy.core.models.execute import (
@@ -39,6 +51,32 @@ class ValidationType(Enum):
39
51
  CONCEPTS = "concepts"
40
52
 
41
53
 
54
+ def build_order_args(concepts: list[BuildConcept]) -> list[BuildFunction]:
55
+ order_args = []
56
+ for concept in concepts:
57
+ order_args.append(
58
+ BuildFunction(
59
+ operator=FunctionType.CASE,
60
+ arguments=[
61
+ BuildCaseWhen(
62
+ comparison=BuildComparison(
63
+ left=concept,
64
+ operator=ComparisonOperator.IS,
65
+ right=MagicConstants.NULL,
66
+ ),
67
+ expr=1,
68
+ ),
69
+ BuildCaseElse(expr=0),
70
+ ],
71
+ output_data_type=DataType.INTEGER,
72
+ output_purpose=Purpose.PROPERTY,
73
+ arg_count=2,
74
+ )
75
+ )
76
+
77
+ return order_args
78
+
79
+
42
80
  def easy_query(
43
81
  concepts: list[BuildConcept],
44
82
  datasource: BuildDatasource,
@@ -81,7 +119,6 @@ def easy_query(
81
119
  group_to_grain=True,
82
120
  base_alias_override=datasource.safe_identifier,
83
121
  )
84
-
85
122
  filter_cte = CTE(
86
123
  name=f"datasource_{datasource.name}_filter",
87
124
  source=QueryDatasource(
@@ -100,6 +137,20 @@ def easy_query(
100
137
  grain=cte.grain,
101
138
  condition=condition,
102
139
  limit=limit,
140
+ order_by=BuildOrderBy(
141
+ items=[
142
+ BuildOrderItem(
143
+ expr=BuildFunction(
144
+ operator=FunctionType.SUM,
145
+ arguments=build_order_args(concepts),
146
+ output_data_type=DataType.INTEGER,
147
+ output_purpose=Purpose.PROPERTY,
148
+ arg_count=len(concepts),
149
+ ),
150
+ order=Ordering.DESCENDING,
151
+ )
152
+ ]
153
+ ),
103
154
  )
104
155
 
105
156
  return ProcessedQuery(
@@ -1,8 +1,9 @@
1
1
  from trilogy import Environment, Executor
2
- from trilogy.core.enums import Derivation, Purpose
2
+ from trilogy.core.enums import Derivation, Modifier, Purpose
3
3
  from trilogy.core.exceptions import (
4
4
  ConceptModelValidationError,
5
- DatasourceModelValidationError,
5
+ DatasourceColumnBindingData,
6
+ DatasourceColumnBindingError,
6
7
  )
7
8
  from trilogy.core.models.build import (
8
9
  BuildConcept,
@@ -25,6 +26,15 @@ def validate_key_concept(
25
26
  ):
26
27
  results: list[ValidationTest] = []
27
28
  seen: dict[str, int] = {}
29
+
30
+ count = 0
31
+ for datasource in build_env.datasources.values():
32
+ if concept.address in [c.address for c in datasource.concepts]:
33
+ count += 1
34
+ # if it only has one source, it's a key
35
+ if count <= 1:
36
+ return results
37
+
28
38
  for datasource in build_env.datasources.values():
29
39
  if concept.address in [c.address for c in datasource.concepts]:
30
40
  assignment = [
@@ -69,8 +79,19 @@ def validate_key_concept(
69
79
  err = None
70
80
  datasource_count: int = seen.get(datasource.name, 0)
71
81
  if datasource_count < max_seen and assignment.is_complete:
72
- err = DatasourceModelValidationError(
73
- f"Key concept {concept.address} is missing values in datasource {datasource.name} (max cardinality in data {max_seen}, datasource has {seen[datasource.name]} values) but is not marked as partial."
82
+ err = DatasourceColumnBindingError(
83
+ address=datasource.identifier,
84
+ errors=[
85
+ DatasourceColumnBindingData(
86
+ address=concept.address,
87
+ value=None,
88
+ value_type=concept.datatype,
89
+ value_modifiers=[Modifier.PARTIAL],
90
+ actual_type=concept.datatype,
91
+ actual_modifiers=concept.modifiers,
92
+ )
93
+ ],
94
+ message=f"Key concept {concept.address} is missing values in datasource {datasource.name} (max cardinality in data {max_seen}, datasource has {seen[datasource.name]} values) but is not marked as partial.",
74
95
  )
75
96
  results.append(
76
97
  ValidationTest(
@@ -10,9 +10,14 @@ from trilogy.authoring import (
10
10
  NumericType,
11
11
  StructType,
12
12
  TraitDataType,
13
+ arg_to_datatype,
14
+ )
15
+ from trilogy.core.enums import ComparisonOperator, Modifier
16
+ from trilogy.core.exceptions import (
17
+ DatasourceColumnBindingData,
18
+ DatasourceColumnBindingError,
19
+ DatasourceModelValidationError,
13
20
  )
14
- from trilogy.core.enums import ComparisonOperator
15
- from trilogy.core.exceptions import DatasourceModelValidationError
16
21
  from trilogy.core.models.build import (
17
22
  BuildComparison,
18
23
  BuildDatasource,
@@ -64,6 +69,7 @@ def validate_datasource(
64
69
  env: Environment,
65
70
  build_env: BuildEnvironment,
66
71
  exec: Executor | None = None,
72
+ fix: bool = False,
67
73
  ) -> list[ValidationTest]:
68
74
  results: list[ValidationTest] = []
69
75
  # we might have merged concepts, where both will map out to the same
@@ -109,14 +115,7 @@ def validate_datasource(
109
115
  )
110
116
  )
111
117
  return results
112
- failures: list[
113
- tuple[
114
- str,
115
- Any,
116
- DataType | ArrayType | StructType | MapType | NumericType | TraitDataType,
117
- bool,
118
- ]
119
- ] = []
118
+ failures: list[DatasourceColumnBindingData] = []
120
119
  cols_with_error = set()
121
120
  for row in rows:
122
121
  for col in datasource.columns:
@@ -127,26 +126,29 @@ def validate_datasource(
127
126
  passed = type_check(rval, col.concept.datatype, col.is_nullable)
128
127
  if not passed:
129
128
  failures.append(
130
- (
131
- col.concept.address,
132
- rval,
133
- col.concept.datatype,
134
- col.is_nullable,
129
+ DatasourceColumnBindingData(
130
+ address=col.concept.address,
131
+ value=rval,
132
+ value_type=(
133
+ arg_to_datatype(rval)
134
+ if rval is not None
135
+ else col.concept.datatype
136
+ ),
137
+ value_modifiers=[Modifier.NULLABLE] if rval is None else [],
138
+ actual_type=col.concept.datatype,
139
+ actual_modifiers=col.concept.modifiers,
135
140
  )
136
141
  )
137
142
  cols_with_error.add(actual_address)
138
143
 
139
- def format_failure(failure):
140
- return f"Concept {failure[0]} value '{failure[1]}' does not conform to expected type {str(failure[2])} (nullable={failure[3]})"
141
-
142
144
  if failures:
143
145
  results.append(
144
146
  ValidationTest(
145
147
  check_type=ExpectationType.LOGICAL,
146
148
  expected="datatype_match",
147
149
  ran=True,
148
- result=DatasourceModelValidationError(
149
- f"Datasource {datasource.name} failed validation. Found rows that do not conform to types: {[format_failure(failure) for failure in failures]}",
150
+ result=DatasourceColumnBindingError(
151
+ address=datasource.identifier, errors=failures
150
152
  ),
151
153
  )
152
154
  )
@@ -15,9 +15,10 @@ def validate_environment(
15
15
  scope: ValidationScope = ValidationScope.ALL,
16
16
  targets: list[str] | None = None,
17
17
  exec: Executor | None = None,
18
+ generate_only: bool = False,
18
19
  ) -> list[ValidationTest]:
19
20
  # avoid mutating the environment for validation
20
- generate_only = exec is None
21
+ generate_only = exec is None or generate_only
21
22
  env = env.duplicate()
22
23
  grain_check = function_to_concept(
23
24
  parent=Function(
@@ -68,4 +69,5 @@ def validate_environment(
68
69
  f"Environment validation failed with the following errors:\n{messages}",
69
70
  children=exceptions,
70
71
  )
72
+
71
73
  return results
@@ -0,0 +1,106 @@
1
+ from collections import defaultdict
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from trilogy import Environment, Executor
6
+ from trilogy.authoring import ConceptDeclarationStatement, Datasource
7
+ from trilogy.core.exceptions import (
8
+ DatasourceColumnBindingData,
9
+ DatasourceColumnBindingError,
10
+ )
11
+ from trilogy.core.validation.environment import validate_environment
12
+ from trilogy.parsing.render import Renderer
13
+
14
+
15
+ def rewrite_file_with_errors(
16
+ statements: list[Any], errors: list[DatasourceColumnBindingError]
17
+ ):
18
+ renderer = Renderer()
19
+ output = []
20
+ ds_error_map: dict[str, list[DatasourceColumnBindingData]] = defaultdict(list)
21
+ concept_error_map: dict[str, list[DatasourceColumnBindingData]] = defaultdict(list)
22
+ for error in errors:
23
+ if isinstance(error, DatasourceColumnBindingError):
24
+ for x in error.errors:
25
+ if error.dataset_address not in ds_error_map:
26
+ ds_error_map[error.dataset_address] = []
27
+ # this is by dataset address
28
+ if x.is_modifier_issue():
29
+ ds_error_map[error.dataset_address].append(x)
30
+ # this is by column
31
+ if x.is_type_issue():
32
+ concept_error_map[x.address].append(x)
33
+ for statement in statements:
34
+ if isinstance(statement, Datasource):
35
+ if statement.identifier in ds_error_map:
36
+ error_cols = ds_error_map[statement.identifier]
37
+ for col in statement.columns:
38
+ if col.concept.address in [x.address for x in error_cols]:
39
+ error_col = [
40
+ x for x in error_cols if x.address == col.concept.address
41
+ ][0]
42
+ col.modifiers = list(
43
+ set(col.modifiers + error_col.value_modifiers)
44
+ )
45
+ elif isinstance(statement, ConceptDeclarationStatement):
46
+ if statement.concept.address in concept_error_map:
47
+ error_cols = concept_error_map[statement.concept.address]
48
+ statement.concept.datatype = error_cols[0].value_type
49
+ output.append(statement)
50
+
51
+ return renderer.render_statement_string(output)
52
+
53
+
54
+ DEPTH_CUTOFF = 3
55
+
56
+
57
+ def validate_and_rewrite(
58
+ input: Path | str, exec: Executor | None = None, depth: int = 0
59
+ ) -> str | None:
60
+ if depth > DEPTH_CUTOFF:
61
+ print(f"Reached depth cutoff of {DEPTH_CUTOFF}, stopping.")
62
+ return None
63
+ if isinstance(input, str):
64
+ raw = input
65
+ env = Environment()
66
+ else:
67
+ with open(input, "r") as f:
68
+ raw = f.read()
69
+ env = Environment(working_path=input.parent)
70
+ if exec:
71
+ env = exec.environment
72
+ env, statements = env.parse(raw)
73
+
74
+ validation_results = validate_environment(env, exec=exec, generate_only=True)
75
+
76
+ errors = [
77
+ x.result
78
+ for x in validation_results
79
+ if isinstance(x.result, DatasourceColumnBindingError)
80
+ ]
81
+
82
+ if not errors:
83
+ print("No validation errors found")
84
+ return None
85
+ print(
86
+ f"Found {len(errors)} validation errors, attempting to fix, current depth: {depth}..."
87
+ )
88
+ for error in errors:
89
+ for item in error.errors:
90
+ print(f"- {item.format_failure()}")
91
+
92
+ new_text = rewrite_file_with_errors(statements, errors)
93
+
94
+ while iteration := validate_and_rewrite(new_text, exec=exec, depth=depth + 1):
95
+ depth = depth + 1
96
+ if depth >= DEPTH_CUTOFF:
97
+ break
98
+ if iteration:
99
+ new_text = iteration
100
+ depth += 1
101
+ if isinstance(input, Path):
102
+ with open(input, "w") as f:
103
+ f.write(new_text)
104
+ return None
105
+ else:
106
+ return new_text
trilogy/dialect/base.py CHANGED
@@ -761,6 +761,7 @@ class BaseDialect:
761
761
  elif isinstance(e, MagicConstants):
762
762
  if e == MagicConstants.NULL:
763
763
  return "null"
764
+ return str(e.value)
764
765
  elif isinstance(e, date):
765
766
  return self.FUNCTION_MAP[FunctionType.DATE_LITERAL](e)
766
767
  elif isinstance(e, datetime):
@@ -1139,7 +1140,7 @@ class BaseDialect:
1139
1140
  if isinstance(query, ProcessedShowStatement):
1140
1141
  return ";\n".join(
1141
1142
  [
1142
- f'{self.EXPLAIN_KEYWORD} {self.compile_statement(x)}'
1143
+ f"{self.EXPLAIN_KEYWORD} {self.compile_statement(x)}"
1143
1144
  for x in query.output_values
1144
1145
  if isinstance(x, (ProcessedQuery, ProcessedCopyStatement))
1145
1146
  ]
trilogy/dialect/common.py CHANGED
@@ -10,7 +10,6 @@ from trilogy.core.models.build import (
10
10
  BuildParamaterizedConceptReference,
11
11
  BuildParenthetical,
12
12
  )
13
- from trilogy.core.models.datasource import RawColumnExpr
14
13
  from trilogy.core.models.execute import (
15
14
  CTE,
16
15
  InstantiatedUnnestJoin,
@@ -65,15 +64,8 @@ def render_join_concept(
65
64
  inlined_ctes: set[str],
66
65
  ):
67
66
  if cte.name in inlined_ctes:
68
- ds = cte.source.datasources[0]
69
- raw_content = ds.get_alias(concept)
70
- if isinstance(raw_content, RawColumnExpr):
71
- rval = raw_content.text
72
- return rval
73
- elif isinstance(raw_content, BuildFunction):
74
- rval = render_expr(raw_content, cte=cte)
75
- return rval
76
- return f"{quote_character}{name}{quote_character}.{quote_character}{raw_content}{quote_character}"
67
+ base = render_expr(concept, cte)
68
+ return base
77
69
  return f"{quote_character}{name}{quote_character}.{quote_character}{concept.safe_address}{quote_character}"
78
70
 
79
71
 
@@ -174,7 +174,7 @@ def raw_validation_to_result(
174
174
  ) -> Optional[MockResult]:
175
175
  """Convert raw validation tests to mock result."""
176
176
  if not raw:
177
- return None
177
+ return MockResult([], ["check_type", "expected", "result", "ran", "query"])
178
178
  output = []
179
179
  for row in raw:
180
180
  if row.raw_query and generator and not row.generated_query:
trilogy/executor.py CHANGED
@@ -410,6 +410,13 @@ class Executor(object):
410
410
  )
411
411
  output.extend(results)
412
412
  continue
413
+ elif isinstance(statement, ProcessedValidateStatement):
414
+ validate_result = handle_processed_validate_statement(
415
+ statement, self.generator, self.validate_environment
416
+ )
417
+ if validate_result:
418
+ output.append(validate_result)
419
+ continue
413
420
  if non_interactive:
414
421
  if not isinstance(
415
422
  statement, (ProcessedCopyStatement, ProcessedQueryPersist)
@@ -379,14 +379,16 @@ class ParseToObjects(Transformer):
379
379
  def start(self, args):
380
380
  return args
381
381
 
382
+ def LINE_SEPARATOR(self, args):
383
+ return MagicConstants.LINE_SEPARATOR
384
+
382
385
  def block(self, args):
383
386
  output = args[0]
384
387
  if isinstance(output, ConceptDeclarationStatement):
385
- if len(args) > 1 and isinstance(args[1], Comment):
386
- output.concept.metadata.description = (
387
- output.concept.metadata.description
388
- or args[1].text.split("#")[1].strip()
389
- )
388
+ if len(args) > 1 and args[1] != MagicConstants.LINE_SEPARATOR:
389
+ comments = [x for x in args[1:] if isinstance(x, Comment)]
390
+ merged = "\n".join([x.text.split("#")[1].rstrip() for x in comments])
391
+ output.concept.metadata.description = merged
390
392
  # this is a bad plan for now;
391
393
  # because a comment after an import statement is very common
392
394
  # and it's not intuitive that it modifies the import description
@@ -913,7 +915,7 @@ class ParseToObjects(Transformer):
913
915
  return Comment(text=args[0].value)
914
916
 
915
917
  def PARSE_COMMENT(self, args):
916
- return Comment(text=args.value)
918
+ return Comment(text=args.value.rstrip())
917
919
 
918
920
  @v_args(meta=True)
919
921
  def select_transform(self, meta: Meta, args) -> ConceptTransform:
@@ -1000,7 +1002,7 @@ class ParseToObjects(Transformer):
1000
1002
  def validate_statement(self, meta: Meta, args) -> ValidateStatement:
1001
1003
  if len(args) == 2:
1002
1004
  scope = args[0]
1003
- targets = args[1]
1005
+ targets = args[1].split(",")
1004
1006
  elif len(args) == 0:
1005
1007
  scope = ValidationScope.ALL
1006
1008
  targets = None
@@ -2293,7 +2295,7 @@ def parse_text(
2293
2295
  raise _create_syntax_error(210, pos, text)
2294
2296
 
2295
2297
  # Handle FROM token error
2296
- parsed_tokens = [x.value for x in e.token_history] if e.token_history else []
2298
+ parsed_tokens = [x.value for x in e.token_history if x] if e.token_history else []
2297
2299
  if parsed_tokens == ["FROM"]:
2298
2300
  raise _create_syntax_error(101, pos, text)
2299
2301
 
trilogy/parsing/render.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from collections import defaultdict
2
2
  from datetime import date, datetime
3
3
  from functools import singledispatchmethod
4
+ from typing import Any
4
5
 
5
6
  from jinja2 import Template
6
7
 
@@ -12,6 +13,7 @@ from trilogy.core.models.author import (
12
13
  AlignItem,
13
14
  CaseElse,
14
15
  CaseWhen,
16
+ Comment,
15
17
  Comparison,
16
18
  Concept,
17
19
  ConceptRef,
@@ -83,6 +85,23 @@ class Renderer:
83
85
  def __init__(self, environment: Environment | None = None):
84
86
  self.environment = environment
85
87
 
88
+ def render_statement_string(self, list_of_statements: list[Any]) -> str:
89
+ new = []
90
+ last_statement_type = None
91
+ for stmt in list_of_statements:
92
+ stmt_type = type(stmt)
93
+ if last_statement_type is None:
94
+ pass
95
+ elif last_statement_type == Comment:
96
+ new.append("\n")
97
+ elif stmt_type != last_statement_type:
98
+ new.append("\n\n")
99
+ else:
100
+ new.append("\n")
101
+ new.append(Renderer().to_string(stmt))
102
+ last_statement_type = stmt_type
103
+ return "".join(new)
104
+
86
105
  @singledispatchmethod
87
106
  def to_string(self, arg):
88
107
  raise NotImplementedError("Cannot render type {}".format(type(arg)))
@@ -269,6 +288,8 @@ class Renderer:
269
288
  @to_string.register
270
289
  def _(self, arg: "Address"):
271
290
  if arg.is_query:
291
+ if arg.location.startswith("("):
292
+ return f"query '''{arg.location[1:-1]}'''"
272
293
  return f"query '''{arg.location}'''"
273
294
  return f"address {arg.location}"
274
295
 
@@ -286,7 +307,7 @@ class Renderer:
286
307
  def _(self, arg: "ColumnAssignment"):
287
308
  if arg.modifiers:
288
309
  modifiers = "".join(
289
- [self.to_string(modifier) for modifier in arg.modifiers]
310
+ [self.to_string(modifier) for modifier in sorted(arg.modifiers)]
290
311
  )
291
312
  else:
292
313
  modifiers = ""
@@ -328,7 +349,7 @@ class Renderer:
328
349
  else:
329
350
  output = f"{concept.purpose.value} {namespace}{concept.name} <- {self.to_string(concept.lineage)};"
330
351
  if base_description:
331
- output += f" # {base_description}"
352
+ output += f" #{base_description}"
332
353
  return output
333
354
 
334
355
  @to_string.register
@@ -428,6 +449,10 @@ class Renderer:
428
449
  def _(self, arg: "Comparison"):
429
450
  return f"{self.to_string(arg.left)} {arg.operator.value} {self.to_string(arg.right)}"
430
451
 
452
+ @to_string.register
453
+ def _(self, arg: "Comment"):
454
+ return f"{arg.text}"
455
+
431
456
  @to_string.register
432
457
  def _(self, arg: "WindowItem"):
433
458
  over = ",".join(self.to_string(c) for c in arg.over)
@@ -521,8 +546,8 @@ class Renderer:
521
546
  return f"{self.to_string(arg.arguments[0])}[{self.to_string(arg.arguments[1])}]"
522
547
 
523
548
  if arg.operator == FunctionType.CASE:
524
- inputs = "\n".join(args)
525
- return f"CASE {inputs}\nEND"
549
+ inputs = "\n\t".join(args)
550
+ return f"CASE\n\t{inputs}\nEND"
526
551
  return f"{arg.operator.value}({inputs})"
527
552
 
528
553
  @to_string.register
@@ -551,8 +576,10 @@ class Renderer:
551
576
  def _(self, arg: Modifier):
552
577
  if arg == Modifier.PARTIAL:
553
578
  return "~"
554
- if arg == Modifier.HIDDEN:
579
+ elif arg == Modifier.HIDDEN:
555
580
  return "--"
581
+ elif arg == Modifier.NULLABLE:
582
+ return "?"
556
583
  return arg.value
557
584
 
558
585
  @to_string.register
@@ -1,5 +1,5 @@
1
- !start: ( block | show_statement )*
2
- block: statement _TERMINATOR PARSE_COMMENT?
1
+ !start: ( block | show_statement | PARSE_COMMENT )*
2
+ block: statement _TERMINATOR LINE_SEPARATOR? PARSE_COMMENT*
3
3
  ?statement: concept
4
4
  | datasource
5
5
  | function
@@ -14,9 +14,12 @@
14
14
  | rawsql_statement
15
15
  | validate_statement
16
16
 
17
- _TERMINATOR: ";"i /\s*/
17
+ _TERMINATOR: ";"i
18
18
 
19
- PARSE_COMMENT.1: /#.*(\n|$)/ | /\/\/.*\n/
19
+ PARSE_COMMENT.1: /#.*(\n|$)/ | /\/\/.*(\n|$)/
20
+
21
+ // when whitespace matters - comment placement
22
+ LINE_SEPARATOR.1: /[ \t\r\f\v]*\n+/
20
23
 
21
24
  // property display_name string
22
25
  concept_declaration: PURPOSE IDENTIFIER data_type concept_nullable_modifier? metadata?
@@ -0,0 +1,15 @@
1
+ # Length and distance units
2
+ type m numeric; # meters
3
+ type km numeric; # kilometers
4
+ type cm numeric; # centimeters
5
+ type mm numeric; # millimeters
6
+
7
+ # Mass units
8
+ type kg numeric; # kilograms
9
+ type g numeric; # grams
10
+ type tonne numeric; # metric tons (1000 kg)
11
+
12
+ # Force units
13
+ type n numeric; # newtons
14
+ type kn numeric; # kilonewtons (1000 N)
15
+ type mn numeric; # meganewtons (1,000,000 N)