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
|
File without changes
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote, unquote
|
|
8
|
+
|
|
9
|
+
from sqlglot import exp
|
|
10
|
+
from sqlglot.dialects.dialect import NormalizationStrategy
|
|
11
|
+
|
|
12
|
+
from metricflow_sql.utils.normalize import read_identifier, read_table
|
|
13
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
14
|
+
from metricflow_sql.utils.sql import column_paths
|
|
15
|
+
|
|
16
|
+
DEFAULT_NAMESPACE = "public"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Prefix(StrEnum):
|
|
20
|
+
# Datasets, entities and constellations share one, as they share names.
|
|
21
|
+
TABLE = "t"
|
|
22
|
+
FIELD = "f"
|
|
23
|
+
SOURCE = "s"
|
|
24
|
+
ASSIGNMENT = "a"
|
|
25
|
+
PAGE = "p"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def qualified(self) -> bool:
|
|
29
|
+
"""Whether names are qualified by their parent, e.g. `f:<dataset>:<field>`."""
|
|
30
|
+
return self in _QUALIFIED_PREFIXES
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def parse(cls, text: str) -> Prefix:
|
|
34
|
+
"""The prefix `text` names, case-insensitively: a prefix (`f`) or its alias (`metric`)."""
|
|
35
|
+
text = text.lower()
|
|
36
|
+
prefix = _PREFIX_ALIASES.get(text)
|
|
37
|
+
return prefix if prefix is not None else cls(text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# How many parts an id of each prefix has, tables and fields.
|
|
41
|
+
_PART_COUNTS = {
|
|
42
|
+
Prefix.TABLE: 1,
|
|
43
|
+
Prefix.FIELD: 2,
|
|
44
|
+
Prefix.SOURCE: 2,
|
|
45
|
+
Prefix.ASSIGNMENT: 3,
|
|
46
|
+
Prefix.PAGE: 1,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_QUALIFIED_PREFIXES = frozenset({Prefix.FIELD, Prefix.SOURCE, Prefix.ASSIGNMENT})
|
|
50
|
+
|
|
51
|
+
# A table after the first is in the first's namespace, as the objects resolve it.
|
|
52
|
+
_TABLE_PARTS = {
|
|
53
|
+
Prefix.TABLE: (0,),
|
|
54
|
+
Prefix.FIELD: (0,),
|
|
55
|
+
Prefix.SOURCE: (0, 1),
|
|
56
|
+
Prefix.ASSIGNMENT: (0, 2),
|
|
57
|
+
Prefix.PAGE: (),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Human-friendly names for prefixes, e.g. `metric:sales:revenue` for `f:sales:revenue`.
|
|
61
|
+
# Kinds sharing a prefix (e.g. `metric` and `role`) are interchangeable; they document intent.
|
|
62
|
+
_PREFIX_ALIASES: dict[str, Prefix] = {
|
|
63
|
+
"dataset": Prefix.TABLE,
|
|
64
|
+
"entity": Prefix.TABLE,
|
|
65
|
+
"constellation": Prefix.TABLE,
|
|
66
|
+
"field": Prefix.FIELD,
|
|
67
|
+
"column": Prefix.FIELD,
|
|
68
|
+
"calculation": Prefix.FIELD,
|
|
69
|
+
"relationship": Prefix.FIELD,
|
|
70
|
+
"attribute": Prefix.FIELD,
|
|
71
|
+
"metric": Prefix.FIELD,
|
|
72
|
+
"role": Prefix.FIELD,
|
|
73
|
+
"source": Prefix.SOURCE,
|
|
74
|
+
"assignment": Prefix.ASSIGNMENT,
|
|
75
|
+
"page": Prefix.PAGE,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Id:
|
|
81
|
+
"""
|
|
82
|
+
Object identifier.
|
|
83
|
+
|
|
84
|
+
Should be unique within an environment.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
prefix: Prefix
|
|
88
|
+
parts: tuple[exp.Table | exp.Identifier, ...]
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
for i in _TABLE_PARTS[self.prefix]:
|
|
92
|
+
match self.parts[i] if i < len(self.parts) else None:
|
|
93
|
+
case exp.Table(db="") | exp.Identifier():
|
|
94
|
+
raise ValueError(f"{self!r} names a table without its namespace")
|
|
95
|
+
|
|
96
|
+
def names(self) -> tuple[str, ...]:
|
|
97
|
+
names: list[str] = []
|
|
98
|
+
for part in self.parts:
|
|
99
|
+
match part:
|
|
100
|
+
case exp.Table():
|
|
101
|
+
names.extend((part.db, part.name))
|
|
102
|
+
case exp.Identifier():
|
|
103
|
+
names.append(part.name)
|
|
104
|
+
return tuple(names)
|
|
105
|
+
|
|
106
|
+
def text(self) -> str:
|
|
107
|
+
"""
|
|
108
|
+
`<prefix>:<name>:...`, where a table is its namespace and name, so a field is
|
|
109
|
+
`f:<namespace>:<table>:<field>`. Names are the registry's, normalized, so they're written
|
|
110
|
+
percent-encoded as a URL component, e.g. `f:SALES:Net%20Orders:AMOUNT` on Snowflake.
|
|
111
|
+
"""
|
|
112
|
+
return ":".join((self.prefix.value, *map(_escape, self.names())))
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def parse(cls, text: str) -> Id:
|
|
116
|
+
"""
|
|
117
|
+
Parse an id as `text` writes it, e.g. `f:SALES:Net%20Orders:AMOUNT`, where every table
|
|
118
|
+
names its namespace. Names are read exactly: they're already the registry's. Escapes are
|
|
119
|
+
decoded leniently, so only `:` and `%` need them.
|
|
120
|
+
"""
|
|
121
|
+
prefix, texts = _split(text)
|
|
122
|
+
if len(texts) != _PART_COUNTS[prefix] + len(_TABLE_PARTS[prefix]):
|
|
123
|
+
raise ValueError(f"Invalid id '{text}': wrong number of names for '{prefix.value}'")
|
|
124
|
+
return cls(prefix, _read_exact(prefix, texts))
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def resolve(cls, text: str, dialect: str, strategy: NormalizationStrategy) -> Id:
|
|
128
|
+
"""
|
|
129
|
+
Resolve a reference written by hand, e.g. `metric:sales:revenue`, whose prefix may be an
|
|
130
|
+
alias. Names are read as the registry reads a query's, and a table is in the namespace it
|
|
131
|
+
gives, e.g. `finance.sales`, else the first table's, else the default one, so the example
|
|
132
|
+
is `f:public:sales:revenue`. An id as
|
|
133
|
+
`text` writes it, which names every namespace, is read as `parse` does.
|
|
134
|
+
"""
|
|
135
|
+
prefix, texts = _split(text)
|
|
136
|
+
tables = _TABLE_PARTS[prefix]
|
|
137
|
+
if len(texts) == _PART_COUNTS[prefix] + len(tables):
|
|
138
|
+
return cls(prefix, _read_exact(prefix, texts))
|
|
139
|
+
if len(texts) != _PART_COUNTS[prefix]:
|
|
140
|
+
raise ValueError(f"Invalid id '{text}': wrong number of names for '{prefix.value}'")
|
|
141
|
+
namespace = read_identifier(DEFAULT_NAMESPACE, dialect, strategy)
|
|
142
|
+
parts: list[exp.Table | exp.Identifier] = []
|
|
143
|
+
for i, part in enumerate(texts):
|
|
144
|
+
if i in tables:
|
|
145
|
+
table = in_namespace(read_table(part, dialect, strategy), namespace)
|
|
146
|
+
if not parts:
|
|
147
|
+
namespace = table.args["db"]
|
|
148
|
+
parts.append(table)
|
|
149
|
+
else:
|
|
150
|
+
parts.append(read_identifier(part, dialect, strategy))
|
|
151
|
+
return cls(prefix, tuple(parts))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _split(text: str) -> tuple[Prefix, list[str]]:
|
|
155
|
+
"""The prefix of `text`, which may be an alias, and its names, unescaped."""
|
|
156
|
+
prefix_text, separator, name = text.partition(":")
|
|
157
|
+
try:
|
|
158
|
+
prefix = Prefix.parse(prefix_text)
|
|
159
|
+
except ValueError:
|
|
160
|
+
raise ValueError(f"Invalid id '{text}': unknown prefix '{prefix_text}'") from None
|
|
161
|
+
if prefix is Prefix.PAGE:
|
|
162
|
+
if not name:
|
|
163
|
+
raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<name>'")
|
|
164
|
+
return prefix, [_unescape(name)]
|
|
165
|
+
texts = [_unescape(part) for part in name.split(":")]
|
|
166
|
+
if prefix.qualified:
|
|
167
|
+
if len(texts) < 2 or not all(texts):
|
|
168
|
+
raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<parent>:<name>'")
|
|
169
|
+
elif not (separator and name):
|
|
170
|
+
raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<name>'")
|
|
171
|
+
return prefix, texts
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _escape(name: str) -> str:
|
|
175
|
+
return quote(name, safe="")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _unescape(text: str) -> str:
|
|
179
|
+
return unquote(text, errors="strict")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _read_exact(prefix: Prefix, texts: list[str]) -> tuple[exp.Table | exp.Identifier, ...]:
|
|
183
|
+
"""The parts of an id as `text` writes it: a namespace and name for each table."""
|
|
184
|
+
parts: list[exp.Table | exp.Identifier] = []
|
|
185
|
+
remaining = iter(texts)
|
|
186
|
+
for i in range(_PART_COUNTS[prefix]):
|
|
187
|
+
if i in _TABLE_PARTS[prefix]:
|
|
188
|
+
namespace, table = next(remaining), next(remaining)
|
|
189
|
+
parts.append(
|
|
190
|
+
exp.table_(
|
|
191
|
+
exp.to_identifier(table, quoted=True),
|
|
192
|
+
db=exp.to_identifier(namespace, quoted=True),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
parts.append(exp.to_identifier(next(remaining), quoted=True))
|
|
197
|
+
return tuple(parts)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def in_namespace(name: exp.Table, namespace: exp.Identifier) -> exp.Table:
|
|
201
|
+
"""A reference without a namespace is in the referring object's, as SQL resolves a table."""
|
|
202
|
+
return name if name.db else exp.table_(name.this, db=namespace)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class Path:
|
|
207
|
+
base: Id
|
|
208
|
+
path: tuple[exp.Identifier, ...]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class Link:
|
|
213
|
+
"""An outgoing reference from one object to `target`, e.g. a markdown link in a wiki page."""
|
|
214
|
+
|
|
215
|
+
target: Id
|
|
216
|
+
label: str
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
DEPENDENCY_LINK = "dependency"
|
|
220
|
+
PARENT_LINK = "parent"
|
|
221
|
+
TYPE_LINK = "type"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class Backlink:
|
|
226
|
+
"""An incoming reference to an object, from `source` - the reverse of a `Link`."""
|
|
227
|
+
|
|
228
|
+
source: Id
|
|
229
|
+
label: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class Chunk:
|
|
234
|
+
"""An independently indexable piece of an object's text, for search."""
|
|
235
|
+
|
|
236
|
+
text: str
|
|
237
|
+
# Heading trail locating the chunk within its object, e.g. ("Setup", "Install").
|
|
238
|
+
section: tuple[str, ...] = ()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, kw_only=True)
|
|
242
|
+
class BaseObject:
|
|
243
|
+
"""
|
|
244
|
+
Abstract base class for all objects.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
248
|
+
label: str | None = None
|
|
249
|
+
description: str | None = None
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def id(self) -> Id:
|
|
253
|
+
raise NotImplementedError
|
|
254
|
+
|
|
255
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
256
|
+
"""
|
|
257
|
+
References to other objects that this object depends on for compilation
|
|
258
|
+
"""
|
|
259
|
+
return OrderedSet()
|
|
260
|
+
|
|
261
|
+
def links(self) -> OrderedSet[Link]:
|
|
262
|
+
links: OrderedSet[Link] = OrderedSet()
|
|
263
|
+
for dependency in self.dependencies():
|
|
264
|
+
match dependency:
|
|
265
|
+
case Id():
|
|
266
|
+
links.add(Link(target=dependency, label=DEPENDENCY_LINK))
|
|
267
|
+
case Path():
|
|
268
|
+
# TODO: link paths too, e.g. to the relationship a path starts from.
|
|
269
|
+
pass
|
|
270
|
+
return links
|
|
271
|
+
|
|
272
|
+
def chunks(self) -> tuple[Chunk, ...]:
|
|
273
|
+
"""Indexable text for search"""
|
|
274
|
+
parts = (" ".join(self.id.names()), self.label, self.description)
|
|
275
|
+
return (Chunk(text="\n".join(part for part in parts if part)),)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def dataset_paths(
|
|
279
|
+
parts: tuple[exp.Identifier, ...], namespace: exp.Identifier
|
|
280
|
+
) -> tuple[tuple[exp.Table, tuple[exp.Identifier, ...]], ...]:
|
|
281
|
+
"""Which reading of `a.b.c` applies depends on what's in scope, as in SQL: a dataset `a` in
|
|
282
|
+
`namespace` wins over `a.b`. Dependencies can't tell, so they depend on both."""
|
|
283
|
+
own = (exp.table_(parts[0], db=namespace), parts[1:])
|
|
284
|
+
if len(parts) < 3:
|
|
285
|
+
return (own,)
|
|
286
|
+
return own, (exp.table_(parts[1], db=parts[0]), parts[2:])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def resolve_dataset_path(
|
|
290
|
+
parts: tuple[exp.Identifier, ...],
|
|
291
|
+
namespace: exp.Identifier,
|
|
292
|
+
exists: Callable[[exp.Table], bool],
|
|
293
|
+
) -> tuple[exp.Table, tuple[exp.Identifier, ...]]:
|
|
294
|
+
readings = dataset_paths(parts, namespace)
|
|
295
|
+
return next((reading for reading in readings if exists(reading[0])), readings[0])
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def deps_from_dataset_expr(dataset: exp.Table, expr: exp.Expr) -> OrderedSet[Id | Path]:
|
|
299
|
+
"""
|
|
300
|
+
Get dependencies from a dataset SQL expression, using `dataset` as the base.
|
|
301
|
+
"""
|
|
302
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
303
|
+
for col in expr.find_all(exp.Column):
|
|
304
|
+
if not col.table:
|
|
305
|
+
deps.add(Id(Prefix.FIELD, (dataset, col.this.copy())))
|
|
306
|
+
for parts in column_paths(expr):
|
|
307
|
+
deps.add(Path(base=Id(Prefix.TABLE, (dataset,)), path=parts))
|
|
308
|
+
return deps
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field as dcfield
|
|
7
|
+
|
|
8
|
+
from sqlglot import expressions as exp
|
|
9
|
+
|
|
10
|
+
from metricflow_sql.objects.base import (
|
|
11
|
+
PARENT_LINK,
|
|
12
|
+
TYPE_LINK,
|
|
13
|
+
BaseObject,
|
|
14
|
+
Id,
|
|
15
|
+
Link,
|
|
16
|
+
Path,
|
|
17
|
+
Prefix,
|
|
18
|
+
dataset_paths,
|
|
19
|
+
deps_from_dataset_expr,
|
|
20
|
+
in_namespace,
|
|
21
|
+
)
|
|
22
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
23
|
+
from metricflow_sql.utils.sql import column_paths, ref_target
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class IncludeAll:
|
|
30
|
+
"""Include all fields in the import"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Include:
|
|
35
|
+
"""Include `field` in the import"""
|
|
36
|
+
|
|
37
|
+
field: exp.Identifier
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Exclude:
|
|
42
|
+
"""Exclude `field` from the import"""
|
|
43
|
+
|
|
44
|
+
field: exp.Identifier
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ImportRule = IncludeAll | Include | Exclude
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def import_rule_from_str(rule: str, name: Callable[[str], exp.Identifier]) -> ImportRule:
|
|
51
|
+
if rule == "*":
|
|
52
|
+
return IncludeAll()
|
|
53
|
+
if rule.startswith("!"):
|
|
54
|
+
return Exclude(field=name(rule[1:]))
|
|
55
|
+
return Include(field=name(rule))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Import:
|
|
60
|
+
"""
|
|
61
|
+
Specification for how one constellation imports another
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
constellation: exp.Table
|
|
65
|
+
fields: tuple[ImportRule, ...]
|
|
66
|
+
|
|
67
|
+
def includes(self, field: exp.Identifier) -> bool:
|
|
68
|
+
"""Returns whether `field` is imported. Only exclusions, e.g. `!refunds`, imply `*`."""
|
|
69
|
+
if Exclude(field=field) in self.fields:
|
|
70
|
+
return False
|
|
71
|
+
return (
|
|
72
|
+
IncludeAll() in self.fields
|
|
73
|
+
or Include(field=field) in self.fields
|
|
74
|
+
or (bool(self.fields) and all(isinstance(rule, Exclude) for rule in self.fields))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Constellation(BaseObject):
|
|
80
|
+
"""
|
|
81
|
+
A Constellation is a group of fact tables connected by conformed dimensions.
|
|
82
|
+
Constellations capture higher-order business domains and enable multi-fact queries.
|
|
83
|
+
|
|
84
|
+
Constellations can include configuration from other constellations.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: exp.Table
|
|
88
|
+
imports: list[Import] = dcfield(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def id(self) -> Id:
|
|
92
|
+
return Id(Prefix.TABLE, (self.name,))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class _ConstellationField(BaseObject):
|
|
97
|
+
constellation: exp.Table
|
|
98
|
+
name: exp.Identifier
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def id(self) -> Id:
|
|
102
|
+
return Id(Prefix.FIELD, (self.constellation, self.name))
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def namespace(self) -> exp.Identifier:
|
|
106
|
+
namespace: exp.Identifier = self.constellation.args["db"]
|
|
107
|
+
return namespace
|
|
108
|
+
|
|
109
|
+
def links(self) -> OrderedSet[Link]:
|
|
110
|
+
links = super().links()
|
|
111
|
+
links.add(Link(target=Id(Prefix.TABLE, (self.constellation,)), label=PARENT_LINK))
|
|
112
|
+
return links
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Role(_ConstellationField):
|
|
117
|
+
"""
|
|
118
|
+
A Role is a qualitative property that describes `Metrics` - metrics can be sliced
|
|
119
|
+
and diced by roles.
|
|
120
|
+
|
|
121
|
+
Roles are scoped to `Constellation`s.
|
|
122
|
+
|
|
123
|
+
Each table contributing to a constellation can assign columns to a Role.
|
|
124
|
+
|
|
125
|
+
`datatype` can be a physical data type or a REF to an entity or dataset, in which case
|
|
126
|
+
datasets must implement the role with a relationship.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
datatype: exp.DataType | None = None
|
|
130
|
+
|
|
131
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
132
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
133
|
+
deps.add(Id(Prefix.TABLE, (self.constellation,)))
|
|
134
|
+
return deps
|
|
135
|
+
|
|
136
|
+
def links(self) -> OrderedSet[Link]:
|
|
137
|
+
links = super().links()
|
|
138
|
+
reference = ref_target(self.datatype)
|
|
139
|
+
if reference is not None:
|
|
140
|
+
target = Id(Prefix.TABLE, (in_namespace(reference, self.namespace),))
|
|
141
|
+
links.add(Link(target=target, label=TYPE_LINK))
|
|
142
|
+
return links
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Metric(_ConstellationField):
|
|
147
|
+
"""
|
|
148
|
+
A Metric is an aggregatable measurement.
|
|
149
|
+
|
|
150
|
+
Metrics are typically quantitative, but they can be any aggregatable value.
|
|
151
|
+
Metrics can span multiple datasets.
|
|
152
|
+
|
|
153
|
+
Metrics are scoped to `Constellation`s.
|
|
154
|
+
|
|
155
|
+
`expr` is a SQL expression, where qualified columns reference fields from datasets
|
|
156
|
+
and unqualified columns reference other metrics or roles.
|
|
157
|
+
|
|
158
|
+
`datatype` should be resolved from dependencies.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
expr: exp.Expr
|
|
162
|
+
datatype: exp.DataType | None = None
|
|
163
|
+
|
|
164
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
165
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
166
|
+
deps.add(Id(Prefix.TABLE, (self.constellation,)))
|
|
167
|
+
for col in self.expr.find_all(exp.Column):
|
|
168
|
+
# Unqualified references are other metrics or roles.
|
|
169
|
+
if not col.table:
|
|
170
|
+
deps.add(Id(Prefix.FIELD, (self.constellation, col.this.copy())))
|
|
171
|
+
for parts in column_paths(self.expr):
|
|
172
|
+
for dataset, path in dataset_paths(parts, self.namespace):
|
|
173
|
+
deps.add(Path(base=Id(Prefix.TABLE, (dataset,)), path=path))
|
|
174
|
+
return deps
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class Assignment(BaseObject):
|
|
179
|
+
"""
|
|
180
|
+
An Assignment defines how a `Dataset` provides a `Role`.
|
|
181
|
+
|
|
182
|
+
This is akin to "dimension conforming" in traditional dimensional modeling, allowing
|
|
183
|
+
cross-table analysis - queries reference roles, and the physical location
|
|
184
|
+
of roles are resolved per dataset.
|
|
185
|
+
|
|
186
|
+
Assignments are unique per `constellation`/`dataset`/`role`.
|
|
187
|
+
|
|
188
|
+
`expr` is a sql expression, where columns are resolved relative to `dataset`.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
constellation: exp.Table
|
|
192
|
+
dataset: exp.Table
|
|
193
|
+
role: exp.Identifier
|
|
194
|
+
expr: exp.Expr
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def id(self) -> Id:
|
|
198
|
+
return Id(Prefix.ASSIGNMENT, (self.constellation, self.role, self.dataset))
|
|
199
|
+
|
|
200
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
201
|
+
deps = deps_from_dataset_expr(self.dataset, self.expr)
|
|
202
|
+
# A constant like `'store'` reads no column, but the branch still reads the dataset.
|
|
203
|
+
deps.add(Id(Prefix.TABLE, (self.dataset,)))
|
|
204
|
+
return deps
|
|
205
|
+
|
|
206
|
+
def links(self) -> OrderedSet[Link]:
|
|
207
|
+
links = super().links()
|
|
208
|
+
links.add(Link(target=Id(Prefix.TABLE, (self.constellation,)), label=PARENT_LINK))
|
|
209
|
+
links.add(Link(target=Id(Prefix.FIELD, (self.constellation, self.role)), label=PARENT_LINK))
|
|
210
|
+
return links
|