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

@@ -5,7 +5,7 @@ from trilogy.core.graph_models import ReferenceGraph
5
5
  from trilogy.core.constants import CONSTANT_DATASET
6
6
  from trilogy.core.processing.concept_strategies_v3 import source_query_concepts
7
7
  from trilogy.core.enums import BooleanOperator
8
- from trilogy.constants import CONFIG, DEFAULT_NAMESPACE
8
+ from trilogy.constants import CONFIG
9
9
  from trilogy.core.processing.nodes import SelectNode, StrategyNode, History
10
10
  from trilogy.core.models import (
11
11
  Concept,
@@ -17,7 +17,6 @@ from trilogy.core.models import (
17
17
  CTE,
18
18
  Join,
19
19
  UnnestJoin,
20
- JoinKey,
21
20
  MaterializedDataset,
22
21
  ProcessedQuery,
23
22
  ProcessedQueryPersist,
@@ -28,6 +27,7 @@ from trilogy.core.models import (
28
27
  Conditional,
29
28
  ProcessedCopyStatement,
30
29
  CopyStatement,
30
+ CTEConceptPair,
31
31
  )
32
32
 
33
33
  from trilogy.utility import unique
@@ -52,44 +52,53 @@ def base_join_to_join(
52
52
  concept_to_unnest=base_join.parent.concept_arguments[0],
53
53
  alias=base_join.alias,
54
54
  )
55
- if base_join.left_datasource.identifier == base_join.right_datasource.identifier:
56
- raise ValueError(f"Joining on same datasource {base_join}")
57
- left_ctes = [
58
- cte
59
- for cte in ctes
60
- if (cte.source.full_name == base_join.left_datasource.full_name)
61
- ]
62
- if not left_ctes:
63
- left_ctes = [
64
- cte
65
- for cte in ctes
66
- if (
67
- cte.source.datasources[0].full_name
68
- == base_join.left_datasource.full_name
55
+
56
+ def get_datasource_cte(datasource: Datasource | QueryDatasource) -> CTE:
57
+ for cte in ctes:
58
+ if cte.source.identifier == datasource.identifier:
59
+ return cte
60
+ for cte in ctes:
61
+ if cte.source.datasources[0].identifier == datasource.identifier:
62
+ return cte
63
+ raise ValueError(f"Could not find CTE for datasource {datasource.identifier}")
64
+
65
+ if base_join.left_datasource is not None:
66
+ left_cte = get_datasource_cte(base_join.left_datasource)
67
+ else:
68
+ # multiple left ctes
69
+ left_cte = None
70
+ right_cte = get_datasource_cte(base_join.right_datasource)
71
+ if base_join.concept_pairs:
72
+ final_pairs = [
73
+ CTEConceptPair(
74
+ left=pair.left,
75
+ right=pair.right,
76
+ existing_datasource=pair.existing_datasource,
77
+ modifiers=pair.modifiers,
78
+ cte=get_datasource_cte(pair.existing_datasource),
69
79
  )
80
+ for pair in base_join.concept_pairs
70
81
  ]
71
- left_cte = left_ctes[0]
72
- right_ctes = [
73
- cte
74
- for cte in ctes
75
- if (cte.source.full_name == base_join.right_datasource.full_name)
76
- ]
77
- if not right_ctes:
78
- right_ctes = [
79
- cte
80
- for cte in ctes
81
- if (
82
- cte.source.datasources[0].full_name
83
- == base_join.right_datasource.full_name
82
+ elif base_join.concepts and base_join.left_datasource:
83
+ final_pairs = [
84
+ CTEConceptPair(
85
+ left=concept,
86
+ right=concept,
87
+ existing_datasource=base_join.left_datasource,
88
+ modifiers=[],
89
+ cte=get_datasource_cte(
90
+ base_join.left_datasource,
91
+ ),
84
92
  )
93
+ for concept in base_join.concepts
85
94
  ]
86
- right_cte = right_ctes[0]
95
+ else:
96
+ final_pairs = []
87
97
  return Join(
88
98
  left_cte=left_cte,
89
99
  right_cte=right_cte,
90
- joinkeys=[JoinKey(concept=concept) for concept in base_join.concepts],
91
100
  jointype=base_join.join_type,
92
- joinkey_pairs=base_join.concept_pairs if base_join.concept_pairs else None,
101
+ joinkey_pairs=final_pairs,
93
102
  )
94
103
 
95
104
 
@@ -100,7 +109,7 @@ def generate_source_map(
100
109
  # now populate anything derived in this level
101
110
  for qdk, qdv in query_datasource.source_map.items():
102
111
  unnest = [x for x in qdv if isinstance(x, UnnestJoin)]
103
- for x in unnest:
112
+ for _ in unnest:
104
113
  source_map[qdk] = []
105
114
  if (
106
115
  qdk not in source_map
@@ -110,16 +119,18 @@ def generate_source_map(
110
119
  source_map[qdk] = []
111
120
  basic = [x for x in qdv if isinstance(x, Datasource)]
112
121
  for base in basic:
113
- source_map[qdk].append(base.name)
122
+ source_map[qdk].append(base.safe_identifier)
114
123
 
115
124
  ctes = [x for x in qdv if isinstance(x, QueryDatasource)]
116
125
  if ctes:
117
- names = set([x.name for x in ctes])
118
- matches = [cte for cte in all_new_ctes if cte.source.name in names]
126
+ names = set([x.safe_identifier for x in ctes])
127
+ matches = [
128
+ cte for cte in all_new_ctes if cte.source.safe_identifier in names
129
+ ]
119
130
 
120
131
  if not matches and names:
121
132
  raise SyntaxError(
122
- f"Missing parent CTEs for source map; expecting {names}, have {[cte.source.name for cte in all_new_ctes]}"
133
+ f"Missing parent CTEs for source map; expecting {names}, have {[cte.source.safe_identifier for cte in all_new_ctes]}"
123
134
  )
124
135
  for cte in matches:
125
136
  output_address = [
@@ -128,11 +139,11 @@ def generate_source_map(
128
139
  if x.address not in [z.address for z in cte.partial_concepts]
129
140
  ]
130
141
  if qdk in output_address:
131
- source_map[qdk].append(cte.name)
142
+ source_map[qdk].append(cte.safe_identifier)
132
143
  # now do a pass that accepts partials
133
144
  for cte in matches:
134
145
  if qdk not in source_map:
135
- source_map[qdk] = [cte.name]
146
+ source_map[qdk] = [cte.safe_identifier]
136
147
  if qdk not in source_map:
137
148
  if not qdv:
138
149
  source_map[qdk] = []
@@ -145,8 +156,10 @@ def generate_source_map(
145
156
  # as they cannot be referenced in row resolution
146
157
  existence_source_map: Dict[str, list[str]] = defaultdict(list)
147
158
  for ek, ev in query_datasource.existence_source_map.items():
148
- names = set([x.name for x in ev])
149
- ematches = [cte.name for cte in all_new_ctes if cte.source.name in names]
159
+ ids = set([x.safe_identifier for x in ev])
160
+ ematches = [
161
+ cte.name for cte in all_new_ctes if cte.source.safe_identifier in ids
162
+ ]
150
163
  existence_source_map[ek] = ematches
151
164
  return {
152
165
  k: [] if not v else list(set(v)) for k, v in source_map.items()
@@ -195,16 +208,19 @@ def resolve_cte_base_name_and_alias_v2(
195
208
  source_map: Dict[str, list[str]],
196
209
  raw_joins: List[Join | InstantiatedUnnestJoin],
197
210
  ) -> Tuple[str | None, str | None]:
198
- joins: List[Join] = [join for join in raw_joins if isinstance(join, Join)]
199
211
  if (
200
212
  isinstance(source.datasources[0], Datasource)
201
213
  and not source.datasources[0].name == CONSTANT_DATASET
202
214
  ):
203
215
  ds = source.datasources[0]
204
- return ds.safe_location, ds.identifier
216
+ return ds.safe_location, ds.safe_identifier
205
217
 
218
+ joins: List[Join] = [join for join in raw_joins if isinstance(join, Join)]
206
219
  if joins and len(joins) > 0:
207
- candidates = [x.left_cte.name for x in joins]
220
+ candidates = [x.left_cte.name for x in joins if x.left_cte]
221
+ for join in joins:
222
+ if join.joinkey_pairs:
223
+ candidates += [x.cte.name for x in join.joinkey_pairs if x.cte]
208
224
  disallowed = [x.right_cte.name for x in joins]
209
225
  try:
210
226
  cte = [y for y in candidates if y not in disallowed][0]
@@ -213,7 +229,6 @@ def resolve_cte_base_name_and_alias_v2(
213
229
  raise SyntaxError(
214
230
  f"Invalid join configuration {candidates} {disallowed} for {name}",
215
231
  )
216
-
217
232
  counts: dict[str, int] = defaultdict(lambda: 0)
218
233
  output_addresses = [x.address for x in source.output_concepts]
219
234
  input_address = [x.address for x in source.input_concepts]
@@ -257,23 +272,20 @@ def datasource_to_ctes(
257
272
  # this is required to ensure that constant datasets
258
273
  # render properly on initial access; since they have
259
274
  # no actual source
260
- if source.full_name == DEFAULT_NAMESPACE + "_" + CONSTANT_DATASET:
275
+ if source.name == CONSTANT_DATASET:
261
276
  source_map = {k: [] for k in query_datasource.source_map}
262
277
  existence_map = source_map
263
278
  else:
264
279
  source_map = {
265
- k: [] if not v else [source.identifier]
280
+ k: [] if not v else [source.safe_identifier]
266
281
  for k, v in query_datasource.source_map.items()
267
282
  }
268
283
  existence_map = source_map
269
284
 
270
- human_id = generate_cte_name(query_datasource.full_name, name_map)
285
+ human_id = generate_cte_name(query_datasource.identifier, name_map)
286
+
287
+ final_joins = [base_join_to_join(join, parents) for join in query_datasource.joins]
271
288
 
272
- final_joins = [
273
- x
274
- for x in [base_join_to_join(join, parents) for join in query_datasource.joins]
275
- if x
276
- ]
277
289
  base_name, base_alias = resolve_cte_base_name_and_alias_v2(
278
290
  human_id, query_datasource, source_map, final_joins
279
291
  )
trilogy/dialect/base.py CHANGED
@@ -607,6 +607,7 @@ class BaseDialect:
607
607
  else:
608
608
  having = having + x if having else x
609
609
 
610
+ logger.info(f"{len(final_joins)} joins for cte {cte.name}")
610
611
  return CompiledCTE(
611
612
  name=cte.name,
612
613
  statement=self.SQL_TEMPLATE.render(
trilogy/dialect/common.py CHANGED
@@ -68,39 +68,18 @@ def render_join(
68
68
  if unnest_mode == UnnestMode.CROSS_JOIN_ALIAS:
69
69
  return f"CROSS JOIN {render_unnest(unnest_mode, quote_character, join.concept_to_unnest, render_func, cte)}"
70
70
  return f"FULL JOIN {render_unnest(unnest_mode, quote_character, join.concept_to_unnest, render_func, cte)}"
71
- left_name = join.left_name
71
+ # left_name = join.left_name
72
72
  right_name = join.right_name
73
73
  right_base = join.right_ref
74
- base_joinkeys = [
75
- null_wrapper(
76
- render_join_concept(
77
- left_name,
78
- quote_character,
79
- join.left_cte,
80
- key.concept,
81
- render_expr_func,
82
- join.inlined_ctes,
83
- ),
84
- render_join_concept(
85
- right_name,
86
- quote_character,
87
- join.right_cte,
88
- key.concept,
89
- render_expr_func,
90
- join.inlined_ctes,
91
- ),
92
- modifiers=key.concept.modifiers or [],
93
- )
94
- for key in join.joinkeys
95
- ]
74
+ base_joinkeys = []
96
75
  if join.joinkey_pairs:
97
76
  base_joinkeys.extend(
98
77
  [
99
78
  null_wrapper(
100
79
  render_join_concept(
101
- left_name,
80
+ join.get_name(pair.cte),
102
81
  quote_character,
103
- join.left_cte,
82
+ pair.cte,
104
83
  pair.left,
105
84
  render_expr_func,
106
85
  join.inlined_ctes,
trilogy/executor.py CHANGED
@@ -334,7 +334,9 @@ class Executor(object):
334
334
  text(command),
335
335
  )
336
336
 
337
- def execute_text(self, command: str) -> List[CursorResult]:
337
+ def execute_text(
338
+ self, command: str, non_interactive: bool = False
339
+ ) -> List[CursorResult]:
338
340
  """Run a preql text command"""
339
341
  output = []
340
342
  # connection = self.engine.connect()
@@ -351,11 +353,18 @@ class Executor(object):
351
353
  )
352
354
  )
353
355
  continue
356
+ if non_interactive:
357
+ if not isinstance(
358
+ statement, (ProcessedCopyStatement, ProcessedQueryPersist)
359
+ ):
360
+ continue
354
361
  output.append(self.execute_query(statement))
355
362
  return output
356
363
 
357
- def execute_file(self, file: str | Path) -> List[CursorResult]:
364
+ def execute_file(
365
+ self, file: str | Path, non_interactive: bool = False
366
+ ) -> List[CursorResult]:
358
367
  file = Path(file)
359
368
  with open(file, "r") as f:
360
369
  command = f.read()
361
- return self.execute_text(command)
370
+ return self.execute_text(command, non_interactive=non_interactive)
@@ -77,7 +77,11 @@ def print_recursive_nodes(
77
77
  ]
78
78
  ]
79
79
  for child in input.parents:
80
- display += print_recursive_nodes(child, mode=mode, depth=depth + 1)
80
+ display += print_recursive_nodes(
81
+ child,
82
+ mode=mode,
83
+ depth=depth + 1,
84
+ )
81
85
  return display
82
86
 
83
87
 
trilogy/parsing/common.py CHANGED
@@ -292,21 +292,19 @@ def arbitrary_to_concept(
292
292
 
293
293
  if isinstance(parent, AggregateWrapper):
294
294
  if not name:
295
- name = (
296
- f"_agg_{parent.function.operator.value}_{string_to_hash(str(parent))}"
297
- )
295
+ name = f"{VIRTUAL_CONCEPT_PREFIX}_agg_{parent.function.operator.value}_{string_to_hash(str(parent))}"
298
296
  return agg_wrapper_to_concept(parent, namespace, name, metadata, purpose)
299
297
  elif isinstance(parent, WindowItem):
300
298
  if not name:
301
- name = f"_window_{parent.type.value}_{string_to_hash(str(parent))}"
299
+ name = f"{VIRTUAL_CONCEPT_PREFIX}_window_{parent.type.value}_{string_to_hash(str(parent))}"
302
300
  return window_item_to_concept(parent, name, namespace, purpose, metadata)
303
301
  elif isinstance(parent, FilterItem):
304
302
  if not name:
305
- name = f"_filter_{parent.content.name}_{string_to_hash(str(parent))}"
303
+ name = f"{VIRTUAL_CONCEPT_PREFIX}_filter_{parent.content.name}_{string_to_hash(str(parent))}"
306
304
  return filter_item_to_concept(parent, name, namespace, purpose, metadata)
307
305
  elif isinstance(parent, Function):
308
306
  if not name:
309
- name = f"_func_{parent.operator.value}_{string_to_hash(str(parent))}"
307
+ name = f"{VIRTUAL_CONCEPT_PREFIX}_func_{parent.operator.value}_{string_to_hash(str(parent))}"
310
308
  return function_to_concept(parent, name, namespace)
311
309
  elif isinstance(parent, ListWrapper):
312
310
  if not name:
@@ -123,6 +123,13 @@ from trilogy.parsing.common import (
123
123
  arbitrary_to_concept,
124
124
  process_function_args,
125
125
  )
126
+ from dataclasses import dataclass
127
+
128
+
129
+ @dataclass
130
+ class WholeGrainWrapper:
131
+ where: WhereClause
132
+
126
133
 
127
134
  CONSTANT_TYPES = (int, float, str, bool, list, ListWrapper, MapWrapper)
128
135
 
@@ -254,8 +261,9 @@ class ParseToObjects(Transformer):
254
261
  def IDENTIFIER(self, args) -> str:
255
262
  return args.value
256
263
 
257
- def concept_lit(self, args) -> Concept:
258
- return self.environment.concepts.__getitem__(args[0])
264
+ @v_args(meta=True)
265
+ def concept_lit(self, meta: Meta, args) -> Concept:
266
+ return self.environment.concepts.__getitem__(args[0], meta.line)
259
267
 
260
268
  def ADDRESS(self, args) -> Address:
261
269
  return Address(location=args.value, quoted=False)
@@ -565,9 +573,11 @@ class ParseToObjects(Transformer):
565
573
  return args
566
574
 
567
575
  def grain_clause(self, args) -> Grain:
568
- # namespace=self.environment.namespace,
569
576
  return Grain(components=[self.environment.concepts[a] for a in args[0]])
570
577
 
578
+ def whole_grain_clause(self, args) -> WholeGrainWrapper:
579
+ return WholeGrainWrapper(where=args[0])
580
+
571
581
  def MULTILINE_STRING(self, args) -> str:
572
582
  return args[3:-3]
573
583
 
@@ -581,11 +591,14 @@ class ParseToObjects(Transformer):
581
591
  grain: Optional[Grain] = None
582
592
  address: Optional[Address] = None
583
593
  where: Optional[WhereClause] = None
594
+ non_partial_for: Optional[WhereClause] = None
584
595
  for val in args[1:]:
585
596
  if isinstance(val, Address):
586
597
  address = val
587
598
  elif isinstance(val, Grain):
588
599
  grain = val
600
+ elif isinstance(val, WholeGrainWrapper):
601
+ non_partial_for = val.where
589
602
  elif isinstance(val, Query):
590
603
  address = Address(location=f"({val.text})", is_query=True)
591
604
  elif isinstance(val, WhereClause):
@@ -595,7 +608,7 @@ class ParseToObjects(Transformer):
595
608
  "Malformed datasource, missing address or query declaration"
596
609
  )
597
610
  datasource = Datasource(
598
- identifier=name,
611
+ name=name,
599
612
  columns=columns,
600
613
  # grain will be set by default from args
601
614
  # TODO: move to factory
@@ -603,6 +616,7 @@ class ParseToObjects(Transformer):
603
616
  address=address,
604
617
  namespace=self.environment.namespace,
605
618
  where=where,
619
+ non_partial_for=non_partial_for,
606
620
  )
607
621
  for column in columns:
608
622
  column.concept = column.concept.with_grain(datasource.grain)
@@ -800,20 +814,10 @@ class ParseToObjects(Transformer):
800
814
  except Exception as e:
801
815
  raise ImportError(f"Unable to import file {target}, parsing error: {e}")
802
816
 
803
- for _, concept in nparser.environment.concepts.items():
804
- self.environment.add_concept(
805
- concept.with_namespace(alias), _ignore_cache=True
806
- )
807
-
808
- for _, datasource in nparser.environment.datasources.items():
809
- self.environment.add_datasource(
810
- datasource.with_namespace(alias), _ignore_cache=True
811
- )
812
817
  imps = ImportStatement(
813
818
  alias=alias, path=Path(args[0]), environment=nparser.environment
814
819
  )
815
- self.environment.imports[alias] = imps
816
- self.environment.gen_concept_list_caches()
820
+ self.environment.add_import(alias, nparser.environment, imps)
817
821
  return imps
818
822
 
819
823
  @v_args(meta=True)
@@ -840,7 +844,7 @@ class ParseToObjects(Transformer):
840
844
  if self.environment.namespace
841
845
  else DEFAULT_NAMESPACE
842
846
  ),
843
- identifier=identifier,
847
+ name=identifier,
844
848
  address=Address(location=address),
845
849
  grain=grain,
846
850
  )
trilogy/parsing/render.py CHANGED
@@ -17,6 +17,7 @@ from trilogy.core.models import (
17
17
  SelectItem,
18
18
  WhereClause,
19
19
  Conditional,
20
+ SubselectComparison,
20
21
  Comparison,
21
22
  Environment,
22
23
  ConceptDeclarationStatement,
@@ -25,6 +26,7 @@ from trilogy.core.models import (
25
26
  WindowItem,
26
27
  FilterItem,
27
28
  ColumnAssignment,
29
+ RawColumnExpr,
28
30
  CaseElse,
29
31
  CaseWhen,
30
32
  ImportStatement,
@@ -50,13 +52,16 @@ from collections import defaultdict
50
52
 
51
53
 
52
54
  QUERY_TEMPLATE = Template(
53
- """SELECT{%- for select in select_columns %}
54
- {{ select }},{% endfor %}{% if where %}
55
- WHERE
56
- {{ where }}{% endif %}{%- if order_by %}
55
+ """{% if where %}WHERE
56
+ {{ where }}
57
+ {% endif %}SELECT{%- for select in select_columns %}
58
+ {{ select }},{% endfor %}{% if having %}
59
+ HAVING
60
+ {{ having }}
61
+ {% endif %}{%- if order_by %}
57
62
  ORDER BY{% for order in order_by %}
58
- {{ order }}{% if not loop.last %},{% endif %}
59
- {% endfor %}{% endif %}{%- if limit is not none %}
63
+ {{ order }}{% if not loop.last %},{% endif %}{% endfor %}
64
+ {% endif %}{%- if limit is not none %}
60
65
  LIMIT {{ limit }}{% endif %};"""
61
66
  )
62
67
 
@@ -75,6 +80,9 @@ class Renderer:
75
80
  metrics = []
76
81
  # first, keys
77
82
  for concept in arg.concepts.values():
83
+ if "__preql_internal" in concept.address:
84
+ continue
85
+
78
86
  # don't render anything that came from an import
79
87
  if concept.namespace in arg.imports:
80
88
  continue
@@ -117,10 +125,10 @@ class Renderer:
117
125
  for datasource in arg.datasources.values()
118
126
  if datasource.namespace == DEFAULT_NAMESPACE
119
127
  ]
120
- rendered_imports = [
121
- self.to_string(import_statement)
122
- for import_statement in arg.imports.values()
123
- ]
128
+ rendered_imports = []
129
+ for _, imports in arg.imports.items():
130
+ for import_statement in imports:
131
+ rendered_imports.append(self.to_string(import_statement))
124
132
  components = []
125
133
  if rendered_imports:
126
134
  components.append(rendered_imports)
@@ -128,19 +136,26 @@ class Renderer:
128
136
  components.append(rendered_concepts)
129
137
  if rendered_datasources:
130
138
  components.append(rendered_datasources)
139
+
131
140
  final = "\n\n".join("\n".join(x) for x in components)
132
141
  return final
133
142
 
134
143
  @to_string.register
135
144
  def _(self, arg: Datasource):
136
- assignments = ",\n\t".join([self.to_string(x) for x in arg.columns])
145
+ assignments = ",\n ".join([self.to_string(x) for x in arg.columns])
146
+ if arg.non_partial_for:
147
+ non_partial = f"\ncomplete where {self.to_string(arg.non_partial_for)}"
148
+ else:
149
+ non_partial = ""
137
150
  base = f"""datasource {arg.name} (
138
151
  {assignments}
139
- )
140
- {self.to_string(arg.grain)}
152
+ )
153
+ {self.to_string(arg.grain)}{non_partial}
141
154
  {self.to_string(arg.address)}"""
155
+
142
156
  if arg.where:
143
- base += f"\n{self.to_string(arg.where)}"
157
+ base += f"\nwhere {self.to_string(arg.where)}"
158
+
144
159
  base += ";"
145
160
  return base
146
161
 
@@ -209,7 +224,19 @@ class Renderer:
209
224
 
210
225
  @to_string.register
211
226
  def _(self, arg: "ColumnAssignment"):
212
- return f"{arg.alias}: {self.to_string(arg.concept)}"
227
+ if arg.modifiers:
228
+ modifiers = "".join(
229
+ [self.to_string(modifier) for modifier in arg.modifiers]
230
+ )
231
+ else:
232
+ modifiers = ""
233
+ if isinstance(arg.alias, str):
234
+ return f"{arg.alias}: {modifiers}{self.to_string(arg.concept)}"
235
+ return f"{self.to_string(arg.alias)}: {modifiers}{self.to_string(arg.concept)}"
236
+
237
+ @to_string.register
238
+ def _(self, arg: "RawColumnExpr"):
239
+ return f"raw('''{arg.text}''')"
213
240
 
214
241
  @to_string.register
215
242
  def _(self, arg: "ConceptDeclarationStatement"):
@@ -266,6 +293,7 @@ class Renderer:
266
293
  return QUERY_TEMPLATE.render(
267
294
  select_columns=[self.to_string(c) for c in arg.selection],
268
295
  where=self.to_string(arg.where_clause) if arg.where_clause else None,
296
+ having=self.to_string(arg.having_clause) if arg.having_clause else None,
269
297
  order_by=(
270
298
  [self.to_string(c) for c in arg.order_by.items]
271
299
  if arg.order_by
@@ -301,16 +329,23 @@ class Renderer:
301
329
 
302
330
  @to_string.register
303
331
  def _(self, arg: OrderBy):
304
- return ",\t".join([self.to_string(c) for c in arg.items])
332
+ return ",\n".join([self.to_string(c) for c in arg.items])
305
333
 
306
334
  @to_string.register
307
335
  def _(self, arg: "WhereClause"):
308
- return f"{self.to_string(arg.conditional)}"
336
+ base = f"{self.to_string(arg.conditional)}"
337
+ if base[0] == "(" and base[-1] == ")":
338
+ return base[1:-1]
339
+ return base
309
340
 
310
341
  @to_string.register
311
342
  def _(self, arg: "Conditional"):
312
343
  return f"({self.to_string(arg.left)} {arg.operator.value} {self.to_string(arg.right)})"
313
344
 
345
+ @to_string.register
346
+ def _(self, arg: "SubselectComparison"):
347
+ return f"{self.to_string(arg.left)} {arg.operator.value} {self.to_string(arg.right)}"
348
+
314
349
  @to_string.register
315
350
  def _(self, arg: "Comparison"):
316
351
  return f"{self.to_string(arg.left)} {arg.operator.value} {self.to_string(arg.right)}"
@@ -319,10 +354,12 @@ class Renderer:
319
354
  def _(self, arg: "WindowItem"):
320
355
  over = ",".join(self.to_string(c) for c in arg.over)
321
356
  order = ",".join(self.to_string(c) for c in arg.order_by)
322
- if over:
357
+ if over and order:
323
358
  return (
324
- f"{arg.type.value} {self.to_string(arg.content)} by {order} OVER {over}"
359
+ f"{arg.type.value} {self.to_string(arg.content)} by {order} over {over}"
325
360
  )
361
+ elif over:
362
+ return f"{arg.type.value} {self.to_string(arg.content)} over {over}"
326
363
  return f"{arg.type.value} {self.to_string(arg.content)} by {order}"
327
364
 
328
365
  @to_string.register
@@ -331,6 +368,8 @@ class Renderer:
331
368
 
332
369
  @to_string.register
333
370
  def _(self, arg: "ImportStatement"):
371
+ if arg.alias == DEFAULT_NAMESPACE:
372
+ return f"import {arg.path};"
334
373
  return f"import {arg.path} as {arg.alias};"
335
374
 
336
375
  @to_string.register
@@ -343,13 +382,15 @@ class Renderer:
343
382
 
344
383
  @to_string.register
345
384
  def _(self, arg: "ConceptTransform"):
346
- return f"{self.to_string(arg.function)}->{arg.output.name}"
385
+ return f"{self.to_string(arg.function)} -> {arg.output.name}"
347
386
 
348
387
  @to_string.register
349
388
  def _(self, arg: "Function"):
350
389
  inputs = ",".join(self.to_string(c) for c in arg.arguments)
351
390
  if arg.operator == FunctionType.CONSTANT:
352
391
  return f"{inputs}"
392
+ if arg.operator == FunctionType.CAST:
393
+ return f"CAST({self.to_string(arg.arguments[0])} AS {self.to_string(arg.arguments[1])})"
353
394
  if arg.operator == FunctionType.INDEX_ACCESS:
354
395
  return f"{self.to_string(arg.arguments[0])}[{self.to_string(arg.arguments[1])}]"
355
396
  return f"{arg.operator.value}({inputs})"
@@ -361,7 +402,8 @@ class Renderer:
361
402
  @to_string.register
362
403
  def _(self, arg: AggregateWrapper):
363
404
  if arg.by:
364
- return f"{self.to_string(arg.function)} by {self.to_string(arg.by)}"
405
+ by = ", ".join([self.to_string(x) for x in arg.by])
406
+ return f"{self.to_string(arg.function)} by {by}"
365
407
  return f"{self.to_string(arg.function)}"
366
408
 
367
409
  @to_string.register
@@ -35,8 +35,10 @@
35
35
  prop_ident: "<" IDENTIFIER ("," IDENTIFIER )* ","? ">" "." IDENTIFIER
36
36
 
37
37
  // datasource concepts
38
- datasource: "datasource" IDENTIFIER "(" column_assignment_list ")" grain_clause? (address | query) where?
39
-
38
+ datasource: "datasource" IDENTIFIER "(" column_assignment_list ")" grain_clause? whole_grain_clause? (address | query) where?
39
+
40
+ whole_grain_clause: "complete" where
41
+
40
42
  grain_clause: "grain" "(" column_list ")"
41
43
 
42
44
  address: "address" (QUOTED_ADDRESS | ADDRESS)
@@ -289,8 +291,8 @@
289
291
 
290
292
  // base language constructs
291
293
  concept_lit: IDENTIFIER
292
- IDENTIFIER: /[a-zA-Z\_][a-zA-Z0-9\_\-\.\-]*/
293
- QUOTED_ADDRESS: /`[a-zA-Z\_][a-zA-Z0-9\_\-\.\-\*\:]*`/
294
+ IDENTIFIER: /[a-zA-Z\_][a-zA-Z0-9\_\-\.]*/
295
+ QUOTED_ADDRESS: /`[a-zA-Z\_][a-zA-Z0-9\_\.\-\*\:]*`/
294
296
  ADDRESS: IDENTIFIER
295
297
 
296
298
  MULTILINE_STRING: /\'{3}(.*?)\'{3}/s