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.
- metricflow_sql/__init__.py +8 -0
- metricflow_sql/compiler/__init__.py +0 -0
- metricflow_sql/compiler/catalog/__init__.py +0 -0
- metricflow_sql/compiler/catalog/cluster.py +152 -0
- metricflow_sql/compiler/catalog/information_schema.py +47 -0
- metricflow_sql/compiler/catalog/metadata.py +65 -0
- metricflow_sql/compiler/catalog/passthrough.py +184 -0
- metricflow_sql/compiler/catalog/protocols.py +67 -0
- metricflow_sql/compiler/catalog/semantic.py +59 -0
- metricflow_sql/compiler/compiler.py +297 -0
- metricflow_sql/compiler/rules/__init__.py +0 -0
- metricflow_sql/compiler/rules/expand_constellations.py +790 -0
- metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
- metricflow_sql/compiler/rules/expand_metadata.py +88 -0
- metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
- metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
- metricflow_sql/compiler/table_arguments.py +35 -0
- metricflow_sql/converters/__init__.py +0 -0
- metricflow_sql/converters/lookml.py +1041 -0
- metricflow_sql/objects/__init__.py +0 -0
- metricflow_sql/objects/base.py +308 -0
- metricflow_sql/objects/constellation.py +210 -0
- metricflow_sql/objects/dataset.py +275 -0
- metricflow_sql/objects/entity.py +131 -0
- metricflow_sql/objects/reference.py +35 -0
- metricflow_sql/objects/wiki.py +101 -0
- metricflow_sql/py.typed +0 -0
- metricflow_sql/registry/__init__.py +0 -0
- metricflow_sql/registry/memory.py +409 -0
- metricflow_sql/registry/operation.py +25 -0
- metricflow_sql/registry/reference_resolution.py +68 -0
- metricflow_sql/registry/registry.py +225 -0
- metricflow_sql/registry/type_resolver.py +460 -0
- metricflow_sql/schemas/__init__.py +0 -0
- metricflow_sql/schemas/bundle.py +327 -0
- metricflow_sql/schemas/fragment.schema.json +1165 -0
- metricflow_sql/schemas/generate.py +70 -0
- metricflow_sql/schemas/load.py +333 -0
- metricflow_sql/schemas/v1/__init__.py +51 -0
- metricflow_sql/schemas/v1/fragment.py +544 -0
- metricflow_sql/server/__init__.py +4 -0
- metricflow_sql/server/app.py +33 -0
- metricflow_sql/server/compile.py +69 -0
- metricflow_sql/server/errors.py +10 -0
- metricflow_sql/server/serve.py +161 -0
- metricflow_sql/utils/__init__.py +0 -0
- metricflow_sql/utils/composed_set.py +32 -0
- metricflow_sql/utils/normalize.py +74 -0
- metricflow_sql/utils/ordered_set.py +24 -0
- metricflow_sql/utils/sql.py +226 -0
- metricflow_sql/utils/template.py +57 -0
- metricflow_sql-0.1.1.dist-info/METADATA +30 -0
- metricflow_sql-0.1.1.dist-info/RECORD +56 -0
- metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
- metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|