metricflow-sql 0.1.1__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.
Files changed (56) hide show
  1. metricflow_sql/__init__.py +8 -0
  2. metricflow_sql/compiler/__init__.py +0 -0
  3. metricflow_sql/compiler/catalog/__init__.py +0 -0
  4. metricflow_sql/compiler/catalog/cluster.py +152 -0
  5. metricflow_sql/compiler/catalog/information_schema.py +47 -0
  6. metricflow_sql/compiler/catalog/metadata.py +65 -0
  7. metricflow_sql/compiler/catalog/passthrough.py +184 -0
  8. metricflow_sql/compiler/catalog/protocols.py +67 -0
  9. metricflow_sql/compiler/catalog/semantic.py +59 -0
  10. metricflow_sql/compiler/compiler.py +297 -0
  11. metricflow_sql/compiler/rules/__init__.py +0 -0
  12. metricflow_sql/compiler/rules/expand_constellations.py +790 -0
  13. metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
  14. metricflow_sql/compiler/rules/expand_metadata.py +88 -0
  15. metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
  16. metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
  17. metricflow_sql/compiler/table_arguments.py +35 -0
  18. metricflow_sql/converters/__init__.py +0 -0
  19. metricflow_sql/converters/lookml.py +1041 -0
  20. metricflow_sql/objects/__init__.py +0 -0
  21. metricflow_sql/objects/base.py +308 -0
  22. metricflow_sql/objects/constellation.py +210 -0
  23. metricflow_sql/objects/dataset.py +275 -0
  24. metricflow_sql/objects/entity.py +131 -0
  25. metricflow_sql/objects/reference.py +35 -0
  26. metricflow_sql/objects/wiki.py +101 -0
  27. metricflow_sql/py.typed +0 -0
  28. metricflow_sql/registry/__init__.py +0 -0
  29. metricflow_sql/registry/memory.py +409 -0
  30. metricflow_sql/registry/operation.py +25 -0
  31. metricflow_sql/registry/reference_resolution.py +68 -0
  32. metricflow_sql/registry/registry.py +225 -0
  33. metricflow_sql/registry/type_resolver.py +460 -0
  34. metricflow_sql/schemas/__init__.py +0 -0
  35. metricflow_sql/schemas/bundle.py +327 -0
  36. metricflow_sql/schemas/fragment.schema.json +1165 -0
  37. metricflow_sql/schemas/generate.py +70 -0
  38. metricflow_sql/schemas/load.py +333 -0
  39. metricflow_sql/schemas/v1/__init__.py +51 -0
  40. metricflow_sql/schemas/v1/fragment.py +544 -0
  41. metricflow_sql/server/__init__.py +4 -0
  42. metricflow_sql/server/app.py +33 -0
  43. metricflow_sql/server/compile.py +69 -0
  44. metricflow_sql/server/errors.py +10 -0
  45. metricflow_sql/server/serve.py +161 -0
  46. metricflow_sql/utils/__init__.py +0 -0
  47. metricflow_sql/utils/composed_set.py +32 -0
  48. metricflow_sql/utils/normalize.py +74 -0
  49. metricflow_sql/utils/ordered_set.py +24 -0
  50. metricflow_sql/utils/sql.py +226 -0
  51. metricflow_sql/utils/template.py +57 -0
  52. metricflow_sql-0.1.1.dist-info/METADATA +30 -0
  53. metricflow_sql-0.1.1.dist-info/RECORD +56 -0
  54. metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
  55. metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
  56. metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,409 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Collection, Iterator, Mapping
5
+ from dataclasses import dataclass
6
+ from dataclasses import field as dcfield
7
+ from functools import cached_property
8
+ from typing import assert_never, cast
9
+
10
+ from sqlglot import expressions as exp
11
+ from sqlglot.dialects.dialect import NormalizationStrategy
12
+ from sqlglot.errors import SchemaError
13
+
14
+ from metricflow_sql.objects.base import Backlink, BaseObject, Id, Link, Prefix
15
+ from metricflow_sql.objects.constellation import Assignment, Constellation, Import, Metric, Role
16
+ from metricflow_sql.objects.dataset import (
17
+ Calculation,
18
+ Column,
19
+ Dataset,
20
+ DatasetKey,
21
+ Parameter,
22
+ Relationship,
23
+ Table,
24
+ View,
25
+ )
26
+ from metricflow_sql.objects.entity import Attribute, Entity, KeyColumn, Source
27
+ from metricflow_sql.objects.wiki import Page
28
+ from metricflow_sql.registry.operation import Batch, Delete, Insert
29
+ from metricflow_sql.registry.registry import (
30
+ ConstellationProxy,
31
+ DatasetProxy,
32
+ DatasetTarget,
33
+ EntityProxy,
34
+ EntityTarget,
35
+ Registry,
36
+ SearchHit,
37
+ relationship_target,
38
+ )
39
+ from metricflow_sql.registry.type_resolver import TypeResolver
40
+ from metricflow_sql.utils.composed_set import ComposedSet
41
+ from metricflow_sql.utils.normalize import DEFAULT_STRATEGY
42
+ from metricflow_sql.utils.ordered_set import OrderedSet
43
+ from metricflow_sql.utils.sql import ref_type
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class InMemoryRegistry(Registry):
48
+ """
49
+ Graph defined entirely in-memory of the current process
50
+ """
51
+
52
+ dialect: str
53
+ normalization_strategy: NormalizationStrategy = DEFAULT_STRATEGY
54
+
55
+ # Datasets
56
+ datasets: dict[exp.Table, Table | View] = dcfield(default_factory=dict)
57
+ dataset_fields: dict[exp.Table, dict[exp.Identifier, Column | Calculation | Relationship]] = (
58
+ dcfield(default_factory=lambda: defaultdict(dict))
59
+ )
60
+ # reverse index
61
+ dataset_entity_sources: dict[exp.Table, dict[exp.Table, Source]] = dcfield(
62
+ default_factory=lambda: defaultdict(dict)
63
+ )
64
+
65
+ # Entities
66
+ entities: dict[exp.Table, Entity] = dcfield(default_factory=dict)
67
+ entity_fields: dict[exp.Table, dict[exp.Identifier, Attribute]] = dcfield(
68
+ default_factory=lambda: defaultdict(dict)
69
+ )
70
+ entity_sources: dict[exp.Table, dict[exp.Table, Source]] = dcfield(
71
+ default_factory=lambda: defaultdict(dict)
72
+ )
73
+
74
+ # Constellations
75
+ constellations: dict[exp.Table, Constellation] = dcfield(default_factory=dict)
76
+ constellation_fields: dict[exp.Table, dict[exp.Identifier, Metric | Role]] = dcfield(
77
+ default_factory=lambda: defaultdict(dict)
78
+ )
79
+ constellation_assignments: dict[
80
+ exp.Table, dict[exp.Table, dict[exp.Identifier, Assignment]]
81
+ ] = dcfield(default_factory=dict)
82
+
83
+ # Wiki
84
+ pages: dict[str, Page] = dcfield(default_factory=dict)
85
+
86
+ # Every object by id, and reverse index of links by target
87
+ objects: dict[Id, BaseObject] = dcfield(default_factory=dict)
88
+ backlinks: dict[Id, OrderedSet[Backlink]] = dcfield(
89
+ default_factory=lambda: defaultdict(OrderedSet)
90
+ )
91
+
92
+ @cached_property
93
+ def type_resolver(self) -> TypeResolver:
94
+ return TypeResolver(self)
95
+
96
+ def apply(self, batch: Batch) -> None:
97
+ for op in batch.ops:
98
+ match op:
99
+ case Insert():
100
+ self.insert(op.object)
101
+ case Delete():
102
+ self.delete(op.id)
103
+ case _:
104
+ assert_never(op)
105
+
106
+ def insert(self, obj: BaseObject) -> BaseObject:
107
+ match obj:
108
+ case Table() | View():
109
+ self.datasets[obj.name] = obj
110
+ case Column() | Calculation() | Relationship():
111
+ self.dataset_fields[obj.dataset][obj.name] = obj
112
+ case Entity():
113
+ self.entities[obj.name] = obj
114
+ case Attribute():
115
+ self.entity_fields[obj.entity][obj.name] = obj
116
+ case Source():
117
+ self.entity_sources[obj.entity][obj.dataset] = obj
118
+ self.dataset_entity_sources[obj.dataset][obj.entity] = obj
119
+ case Constellation():
120
+ self.constellations[obj.name] = obj
121
+ case Metric() | Role():
122
+ self.constellation_fields[obj.constellation][obj.name] = obj
123
+ case Assignment():
124
+ assignments = self.constellation_assignments.setdefault(obj.constellation, {})
125
+ roles = assignments.setdefault(obj.dataset, {})
126
+ roles[obj.role] = obj
127
+ case Page():
128
+ self.pages[obj.name] = obj
129
+ case _:
130
+ raise ValueError(f"Unknown object type: {obj}")
131
+
132
+ replaced = self.objects.get(obj.id)
133
+ if replaced is not obj:
134
+ self.type_resolver.clear()
135
+
136
+ if replaced is not None:
137
+ for link in replaced.links():
138
+ self.backlinks[link.target].discard(Backlink(source=obj.id, label=link.label))
139
+
140
+ self.objects[obj.id] = obj
141
+ for link in obj.links():
142
+ self.backlinks[link.target].add(Backlink(source=obj.id, label=link.label))
143
+ return obj
144
+
145
+ def delete(self, id: Id) -> BaseObject:
146
+ raise NotImplementedError
147
+
148
+ def get_objects(self, ids: Collection[Id]) -> dict[Id, BaseObject | None]:
149
+ return {object_id: self.objects.get(object_id) for object_id in ids}
150
+
151
+ def get_links(self, ids: Collection[Id]) -> dict[Id, list[Link]]:
152
+ objects = self.get_objects(ids)
153
+ return {
154
+ object_id: list(obj.links()) if obj is not None else []
155
+ for object_id, obj in objects.items()
156
+ }
157
+
158
+ def get_backlinks(self, ids: Collection[Id]) -> dict[Id, list[Backlink]]:
159
+ return {object_id: list(self.backlinks.get(object_id, ())) for object_id in ids}
160
+
161
+ def get_dataset_field_ids(self, dataset: exp.Table) -> list[Id]:
162
+ return [field.id for field in self.dataset_fields.get(dataset, {}).values()]
163
+
164
+ def search(self, query: str, limit: int = 10) -> list[SearchHit]:
165
+ """
166
+ Naive text search.
167
+
168
+ Chunks containing every whitespace-separated term of `query`, case-insensitively,
169
+ scored by term occurrences.
170
+ """
171
+ terms = query.lower().split()
172
+ if not terms:
173
+ return []
174
+ hits: list[SearchHit] = []
175
+ for obj in self.objects.values():
176
+ for chunk in obj.chunks():
177
+ haystack = " ".join((*chunk.section, chunk.text)).lower()
178
+ if all(term in haystack for term in terms):
179
+ score = sum(haystack.count(term) for term in terms)
180
+ hits.append(SearchHit(obj.id, chunk.section, chunk.text, float(score)))
181
+ # Stable sort, so equal scores keep insertion order.
182
+ hits.sort(key=lambda hit: -hit.score)
183
+ return hits[:limit]
184
+
185
+ def get_dataset(self, name: exp.Table) -> InMemoryDatasetProxy | None:
186
+ table = self.datasets.get(name)
187
+ if not table:
188
+ return None
189
+ fields = self.dataset_fields.get(name) or {}
190
+ return InMemoryDatasetProxy(table, fields, self)
191
+
192
+ def get_entity(self, name: exp.Table) -> EntityProxy | None:
193
+ entity = self.entities.get(name)
194
+ if not entity:
195
+ return None
196
+ fields = self.entity_fields.get(name) or {}
197
+ sources = self.entity_sources.get(name) or {}
198
+ return InMemoryEntityProxy(entity, fields, sources, self)
199
+
200
+ def get_constellation(self, name: exp.Table) -> InMemoryConstellationProxy | None:
201
+ if name not in self.constellations:
202
+ return None
203
+ return self._constellation_proxy(name, frozenset())
204
+
205
+ def _constellation_proxy(
206
+ self, name: exp.Table, importing: frozenset[exp.Table]
207
+ ) -> InMemoryConstellationProxy:
208
+ """`importing` are the constellations importing `name`, to catch a cycle."""
209
+ if name in importing:
210
+ raise SchemaError(f"Constellation imports itself: {name.sql(self.dialect)}")
211
+ constellation = self.constellations.get(name)
212
+ if not constellation:
213
+ raise SchemaError(f"Unknown constellation: {name.sql(self.dialect)}")
214
+ return InMemoryConstellationProxy(
215
+ constellation,
216
+ self.constellation_fields.get(name) or {},
217
+ self.constellation_assignments.get(name) or {},
218
+ tuple(
219
+ (import_, self._constellation_proxy(import_.constellation, importing | {name}))
220
+ for import_ in constellation.imports
221
+ ),
222
+ self,
223
+ )
224
+
225
+ def entity_struct_type(self, entity: exp.Table, *, max_depth: int = 1) -> exp.DataType:
226
+ """Describe an entity as a bounded STRUCT, with only keys at the depth limit."""
227
+ if entity not in self.entities:
228
+ raise SchemaError(f"Unknown entity: {entity.sql(self.dialect)}")
229
+ return self.type_resolver.expand_type(ref_type(entity), max_depth=max_depth)
230
+
231
+ def dataset_struct_type(self, dataset: exp.Table, *, max_depth: int = 1) -> exp.DataType:
232
+ """Describe a dataset as a bounded STRUCT, with only key fields at the depth limit."""
233
+ if dataset not in self.datasets:
234
+ raise SchemaError(f"Unknown dataset: {dataset.sql(self.dialect)}")
235
+ return self.type_resolver.expand_type(ref_type(dataset), max_depth=max_depth)
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class InMemoryDatasetProxy(DatasetProxy):
240
+ dataset: Dataset
241
+ fields: Mapping[exp.Identifier, Column | Calculation | Relationship]
242
+ registry: InMemoryRegistry
243
+
244
+ @property
245
+ def parameters(self) -> dict[exp.Identifier, Parameter]:
246
+ return self.dataset.parameters
247
+
248
+ @property
249
+ def key(self) -> DatasetKey | None:
250
+ return self.dataset.key
251
+
252
+ def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
253
+ return self.dataset.from_(arguments)
254
+
255
+ @property
256
+ def name(self) -> exp.Table:
257
+ return self.dataset.name
258
+
259
+ def column_names(self) -> Collection[exp.Identifier]:
260
+ return sorted(self.fields, key=lambda name: name.name)
261
+
262
+ def get_column(self, name: exp.Identifier) -> Column | Calculation | Relationship | None:
263
+ return self.fields.get(name)
264
+
265
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType:
266
+ match self.get_column(name):
267
+ case Column() | Calculation() as field:
268
+ return self.registry.type_resolver.expand_type(
269
+ self.registry.type_resolver.infer(field.id)
270
+ )
271
+ case Relationship() as relationship:
272
+ match relationship_target(self.registry, relationship):
273
+ case EntityTarget(entity=entity):
274
+ return self.registry.entity_struct_type(entity.name)
275
+ case DatasetTarget(dataset=dataset):
276
+ return self.registry.dataset_struct_type(dataset.name)
277
+ case _:
278
+ dialect = self.registry.dialect
279
+ raise SchemaError(f"Unknown column: {self.name.sql(dialect)}.{name.sql(dialect)}")
280
+
281
+ def get_entity_sources(self) -> Mapping[exp.Table, Source]:
282
+ return self.registry.dataset_entity_sources.get(self.name) or {}
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class InMemoryEntityProxy(EntityProxy):
287
+ entity: Entity
288
+ fields: Mapping[exp.Identifier, Attribute]
289
+ sources: Mapping[exp.Table, Source]
290
+ registry: InMemoryRegistry
291
+
292
+ @property
293
+ def key(self) -> dict[exp.Identifier, KeyColumn]:
294
+ return self.entity.key
295
+
296
+ @property
297
+ def name(self) -> exp.Table:
298
+ return self.entity.name
299
+
300
+ def column_names(self) -> Collection[exp.Identifier]:
301
+ return self.fields
302
+
303
+ def get_column(self, name: exp.Identifier) -> Attribute | None:
304
+ return self.fields.get(name)
305
+
306
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType:
307
+ field = self.get_column(name)
308
+ if not field:
309
+ dialect = self.registry.dialect
310
+ raise SchemaError(f"Unknown column: {self.name.sql(dialect)}.{name.sql(dialect)}")
311
+
312
+ return self.registry.type_resolver.expand_type(self.registry.type_resolver.infer(field.id))
313
+
314
+ def get_source(self, name: exp.Table) -> Source | None:
315
+ return self.sources.get(name)
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class ImportedNames(Collection[exp.Identifier]):
320
+ """The names an import lets through, filtered when read instead of copied."""
321
+
322
+ import_: Import
323
+ names: Collection[exp.Identifier]
324
+
325
+ def __contains__(self, name: object) -> bool:
326
+ return name in self.names and self.import_.includes(cast(exp.Identifier, name))
327
+
328
+ def __iter__(self) -> Iterator[exp.Identifier]:
329
+ return filter(self.import_.includes, self.names)
330
+
331
+ def __len__(self) -> int:
332
+ return sum(1 for _ in self)
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class InMemoryConstellationProxy(ConstellationProxy):
337
+ """
338
+ A constellation with the fields it imports. Its own fields override imported ones, and a
339
+ later import overrides an earlier one.
340
+ """
341
+
342
+ constellation: Constellation
343
+ fields: Mapping[exp.Identifier, Metric | Role]
344
+ # Assignments by dataset, then role.
345
+ assignments: Mapping[exp.Table, Mapping[exp.Identifier, Assignment]]
346
+ imports: tuple[tuple[Import, InMemoryConstellationProxy], ...]
347
+ registry: InMemoryRegistry
348
+
349
+ @property
350
+ def name(self) -> exp.Table:
351
+ return self.constellation.name
352
+
353
+ def column_names(self) -> Collection[exp.Identifier]:
354
+ return ComposedSet(
355
+ *(
356
+ ImportedNames(import_, imported.column_names())
357
+ for import_, imported in self.imports
358
+ ),
359
+ self.fields,
360
+ )
361
+
362
+ def get_column(self, name: exp.Identifier) -> Metric | Role | None:
363
+ if (field := self.fields.get(name)) is not None:
364
+ return field
365
+ for import_, imported in reversed(self.imports):
366
+ if import_.includes(name) and (field := imported.get_column(name)) is not None:
367
+ return field
368
+ return None
369
+
370
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType:
371
+ field = self.get_column(name)
372
+ if not field:
373
+ raise SchemaError(
374
+ f"Unknown column: {self.name.sql(self.registry.dialect)}.{name.sql(self.registry.dialect)}"
375
+ )
376
+ return self.registry.type_resolver.expand_type(
377
+ self.registry.type_resolver.infer(Id(Prefix.FIELD, (self.name, name)))
378
+ )
379
+
380
+ def get_assignment(self, dataset: exp.Table, role: exp.Identifier) -> Assignment | None:
381
+ if (assignment := self.assignments.get(dataset, {}).get(role)) is not None:
382
+ return assignment
383
+ for import_, imported in reversed(self.imports):
384
+ if (
385
+ import_.includes(role)
386
+ and (assignment := imported.get_assignment(dataset, role)) is not None
387
+ ):
388
+ return assignment
389
+ return None
390
+
391
+ def assigned_datasets(self) -> Collection[exp.Table]:
392
+ return ComposedSet(
393
+ self.assignments,
394
+ *(
395
+ # Skips datasets that only provide roles the import leaves out.
396
+ tuple(
397
+ dataset
398
+ for dataset in imported.assigned_datasets()
399
+ if any(map(import_.includes, imported._assigned_roles(dataset)))
400
+ )
401
+ for import_, imported in self.imports
402
+ ),
403
+ )
404
+
405
+ def _assigned_roles(self, dataset: exp.Table) -> Iterator[exp.Identifier]:
406
+ """Yields the roles `dataset` provides, including imported ones."""
407
+ yield from self.assignments.get(dataset, {})
408
+ for import_, imported in self.imports:
409
+ yield from filter(import_.includes, imported._assigned_roles(dataset))
@@ -0,0 +1,25 @@
1
+ """
2
+ Operations that can be applied on a `Registry`.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from metricflow_sql.objects.base import BaseObject, Id
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Insert:
12
+ object: BaseObject
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Delete:
17
+ id: Id
18
+
19
+
20
+ Operation = Insert | Delete
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Batch:
25
+ ops: list[Operation]
@@ -0,0 +1,68 @@
1
+ """Reference lookup shared by compilation and type inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+
8
+ from sqlglot import exp
9
+
10
+ from metricflow_sql.objects.base import resolve_dataset_path
11
+ from metricflow_sql.objects.constellation import Metric
12
+ from metricflow_sql.objects.entity import Source
13
+ from metricflow_sql.registry.registry import ConstellationProxy, EntityProxy, Registry
14
+ from metricflow_sql.utils.sql import column_references, is_shadowed, path_names
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AttributeReference:
19
+ """A qualified attribute reference, with its entity source when one exists."""
20
+
21
+ column: exp.Column | exp.Dot
22
+ dataset: exp.Table
23
+
24
+ # None can mean a sibling struct attribute (profile.city) or an unknown name.
25
+ source: Source | None
26
+
27
+ # The dataset field and any remaining access, preserving normalized identifiers.
28
+ path: tuple[exp.Identifier, ...]
29
+
30
+
31
+ def resolve_attribute_reference(
32
+ entity: EntityProxy, column: exp.Column | exp.Dot
33
+ ) -> AttributeReference:
34
+ """Use the compiler's namespace precedence to resolve a qualified reference."""
35
+ dataset, path = resolve_dataset_path(
36
+ path_names(column),
37
+ entity.name.args["db"],
38
+ lambda name: entity.get_source(name) is not None,
39
+ )
40
+ return AttributeReference(
41
+ column=column, dataset=dataset, source=entity.get_source(dataset), path=path
42
+ )
43
+
44
+
45
+ def resolve_attribute_references(
46
+ entity: EntityProxy, expression: exp.Expr
47
+ ) -> Iterator[AttributeReference]:
48
+ """Resolve source references, excluding local SQL aliases and retaining unresolved paths."""
49
+ for column in column_references(expression):
50
+ if not is_shadowed(column, column.parts[0].name):
51
+ yield resolve_attribute_reference(entity, column)
52
+
53
+
54
+ def resolve_metric(
55
+ registry: Registry,
56
+ constellation: ConstellationProxy,
57
+ name: exp.Identifier,
58
+ source: exp.Table | None = None,
59
+ ) -> Metric | None:
60
+ """Prefer the queried constellation's metric, then its caller's defining constellation."""
61
+ match constellation.get_column(name):
62
+ case Metric() as metric:
63
+ return metric
64
+ if source is not None and (defined := registry.get_constellation(source)) is not None:
65
+ match defined.get_column(name):
66
+ case Metric() as metric:
67
+ return metric
68
+ return None