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,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from sqlglot import expressions as exp
|
|
9
|
+
from sqlglot import parse_one
|
|
10
|
+
|
|
11
|
+
from metricflow_sql.objects.base import (
|
|
12
|
+
PARENT_LINK,
|
|
13
|
+
BaseObject,
|
|
14
|
+
Id,
|
|
15
|
+
Link,
|
|
16
|
+
Path,
|
|
17
|
+
Prefix,
|
|
18
|
+
deps_from_dataset_expr,
|
|
19
|
+
)
|
|
20
|
+
from metricflow_sql.utils.normalize import resolve_name
|
|
21
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
22
|
+
from metricflow_sql.utils.sql import ref_type
|
|
23
|
+
from metricflow_sql.utils.template import render_sql
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class _DatasetField(BaseObject):
|
|
30
|
+
dataset: exp.Table
|
|
31
|
+
name: exp.Identifier
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def id(self) -> Id:
|
|
35
|
+
return Id(Prefix.FIELD, (self.dataset, self.name))
|
|
36
|
+
|
|
37
|
+
def links(self) -> OrderedSet[Link]:
|
|
38
|
+
links = super().links()
|
|
39
|
+
links.add(Link(target=Id(Prefix.TABLE, (self.dataset,)), label=PARENT_LINK))
|
|
40
|
+
return links
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Column(_DatasetField):
|
|
45
|
+
"""
|
|
46
|
+
Concrete column in the underlying database.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
datatype: exp.DataType | None = None
|
|
50
|
+
|
|
51
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
52
|
+
return OrderedSet([Id(Prefix.TABLE, (self.dataset,))])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Calculation(_DatasetField):
|
|
57
|
+
"""
|
|
58
|
+
Derived column.
|
|
59
|
+
|
|
60
|
+
`expr` is a SQL expression, where columns reference other `Column`s, `Calculation`s, or
|
|
61
|
+
`Relationship`s.
|
|
62
|
+
|
|
63
|
+
`datatype` is an optional declaration. The type resolver infers undeclared types
|
|
64
|
+
from dependencies without changing this object.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
expr: exp.Expr
|
|
68
|
+
datatype: exp.DataType | None = None
|
|
69
|
+
|
|
70
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
71
|
+
return deps_from_dataset_expr(self.dataset, self.expr)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Cardinality(StrEnum):
|
|
75
|
+
"""Relationship cardinality"""
|
|
76
|
+
|
|
77
|
+
MANY_TO_ONE = "many_to_one"
|
|
78
|
+
ONE_TO_MANY = "one_to_many"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Relationship(_DatasetField):
|
|
83
|
+
"""
|
|
84
|
+
Relationship to its `target`, an `Entity` or a `Dataset`.
|
|
85
|
+
|
|
86
|
+
Unique by `dataset`/`name`.
|
|
87
|
+
|
|
88
|
+
`key` maps the target's fields to expressions on this dataset. For an entity, the fields are
|
|
89
|
+
its `Entity.key` columns or its attributes. For a dataset, they are its `DatasetKey` parts or
|
|
90
|
+
any of its fields. A key that binds all of the target's key is many-to-one, and any other is
|
|
91
|
+
one-to-many.
|
|
92
|
+
|
|
93
|
+
`cardinality` optionally declares which, and must then agree with the key.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
target: exp.Table
|
|
97
|
+
key: dict[exp.Identifier, exp.Expr]
|
|
98
|
+
cardinality: Cardinality | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def datatype(self) -> exp.DataType:
|
|
102
|
+
"""A reference to the target, e.g. `REF(customer)`; registries resolve its fields."""
|
|
103
|
+
return ref_type(self.target)
|
|
104
|
+
|
|
105
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
106
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
107
|
+
deps.add(Id(Prefix.TABLE, (self.target,)))
|
|
108
|
+
for name in self.key:
|
|
109
|
+
deps.add(Id(Prefix.FIELD, (self.target, name)))
|
|
110
|
+
for expr in self.key.values():
|
|
111
|
+
deps |= deps_from_dataset_expr(self.dataset, expr)
|
|
112
|
+
return deps
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Parameter:
|
|
117
|
+
"""
|
|
118
|
+
A value a query passes on its root table function, e.g. `store_sales(min_quantity => 90)`,
|
|
119
|
+
to every dataset the query reaches through relationships.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
datatype: exp.DataType | None = None
|
|
123
|
+
default: exp.Expr | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parameter_value(parameter: Parameter, argument: exp.Expr | None) -> exp.Expr:
|
|
127
|
+
"""What the query passed, else the parameter's default, else NULL."""
|
|
128
|
+
return argument or parameter.default or exp.null()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def argument(arguments: dict[str, exp.Expr], parameter: exp.Identifier) -> exp.Expr | None:
|
|
132
|
+
"""
|
|
133
|
+
What the query passed for `parameter`, by its name as `resolve_name` resolves it: sqlglot
|
|
134
|
+
doesn't keep an argument name's quotes, e.g. `"MinAmount" => 90`.
|
|
135
|
+
"""
|
|
136
|
+
name = resolve_name(parameter.name, arguments)
|
|
137
|
+
return None if name is None else arguments[name]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class KeyParameter:
|
|
142
|
+
"""A placeholder of a `KeyCondition`, e.g. `:as_of`."""
|
|
143
|
+
|
|
144
|
+
datatype: exp.DataType | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class KeyCondition:
|
|
149
|
+
"""
|
|
150
|
+
A part of a `DatasetKey` that identifies rows by a condition rather than a field's value,
|
|
151
|
+
e.g. `:as_of >= valid_from AND :as_of < valid_to` for versions valid over a period.
|
|
152
|
+
|
|
153
|
+
`on` reads the dataset's fields, and the values relationships bind as `:name` placeholders.
|
|
154
|
+
`parameters` are those placeholders, by name.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
on: exp.Expr
|
|
158
|
+
parameters: dict[exp.Identifier, KeyParameter]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class DatasetKey:
|
|
163
|
+
"""
|
|
164
|
+
What identifies a row of a dataset, e.g. `[user_id, item_id]`.
|
|
165
|
+
|
|
166
|
+
Each part is the name of a field, whose value identifies rows, or a `KeyCondition`.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
parts: tuple[exp.Identifier | KeyCondition, ...]
|
|
170
|
+
|
|
171
|
+
def fields(self) -> tuple[exp.Identifier, ...]:
|
|
172
|
+
"""The parts that are fields, e.g. `user_id`."""
|
|
173
|
+
fields: list[exp.Identifier] = []
|
|
174
|
+
for part in self.parts:
|
|
175
|
+
match part:
|
|
176
|
+
case exp.Identifier():
|
|
177
|
+
fields.append(part)
|
|
178
|
+
return tuple(fields)
|
|
179
|
+
|
|
180
|
+
def conditions(self) -> tuple[KeyCondition, ...]:
|
|
181
|
+
"""The parts that are conditions."""
|
|
182
|
+
conditions: list[KeyCondition] = []
|
|
183
|
+
for part in self.parts:
|
|
184
|
+
match part:
|
|
185
|
+
case KeyCondition():
|
|
186
|
+
conditions.append(part)
|
|
187
|
+
return tuple(conditions)
|
|
188
|
+
|
|
189
|
+
def names(self) -> tuple[exp.Identifier, ...]:
|
|
190
|
+
"""
|
|
191
|
+
The names relationships bind to the key, in order: fields, and condition parameters,
|
|
192
|
+
e.g. `user_id`, `as_of`.
|
|
193
|
+
"""
|
|
194
|
+
names: list[exp.Identifier] = []
|
|
195
|
+
for part in self.parts:
|
|
196
|
+
match part:
|
|
197
|
+
case exp.Identifier():
|
|
198
|
+
names.append(part)
|
|
199
|
+
case KeyCondition(parameters=parameters):
|
|
200
|
+
names.extend(parameters)
|
|
201
|
+
return tuple(names)
|
|
202
|
+
|
|
203
|
+
def dependencies(self, dataset: exp.Table) -> OrderedSet[Id | Path]:
|
|
204
|
+
"""
|
|
205
|
+
The fields of `dataset` the key reads, e.g. a calculation it names, which joins and fan-out
|
|
206
|
+
grouping expand like a `Source`'s `on`.
|
|
207
|
+
"""
|
|
208
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
209
|
+
for name in self.fields():
|
|
210
|
+
deps.add(Id(Prefix.FIELD, (dataset, name)))
|
|
211
|
+
for condition in self.conditions():
|
|
212
|
+
deps |= deps_from_dataset_expr(dataset, condition.on)
|
|
213
|
+
return deps
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@runtime_checkable
|
|
217
|
+
class Dataset(Protocol):
|
|
218
|
+
@property
|
|
219
|
+
def name(self) -> exp.Table: ...
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def parameters(self) -> dict[exp.Identifier, Parameter]: ...
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def key(self) -> DatasetKey | None: ...
|
|
226
|
+
|
|
227
|
+
def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
|
|
228
|
+
"""What the dataset reads from, given the query's table function arguments."""
|
|
229
|
+
...
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class Table(BaseObject):
|
|
234
|
+
name: exp.Table
|
|
235
|
+
location: exp.Table
|
|
236
|
+
parameters: dict[exp.Identifier, Parameter] = field(default_factory=dict)
|
|
237
|
+
key: DatasetKey | None = None
|
|
238
|
+
|
|
239
|
+
def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
|
|
240
|
+
return self.location.copy()
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def id(self) -> Id:
|
|
244
|
+
return Id(Prefix.TABLE, (self.name,))
|
|
245
|
+
|
|
246
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
247
|
+
return self.key.dependencies(self.name) if self.key is not None else OrderedSet()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class View(BaseObject):
|
|
252
|
+
"""
|
|
253
|
+
The rows of `sql`, a Jinja template over the view's `parameters`, written in `dialect`.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
name: exp.Table
|
|
257
|
+
sql: str
|
|
258
|
+
dialect: str | None = None
|
|
259
|
+
parameters: dict[exp.Identifier, Parameter] = field(default_factory=dict)
|
|
260
|
+
key: DatasetKey | None = None
|
|
261
|
+
|
|
262
|
+
def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
|
|
263
|
+
values = {
|
|
264
|
+
name.name: parameter_value(parameter, argument(arguments, name))
|
|
265
|
+
for name, parameter in self.parameters.items()
|
|
266
|
+
}
|
|
267
|
+
rendered = render_sql(self.sql, values, dialect=self.dialect)
|
|
268
|
+
return exp.Subquery(this=parse_one(rendered, dialect=self.dialect))
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def id(self) -> Id:
|
|
272
|
+
return Id(Prefix.TABLE, (self.name,))
|
|
273
|
+
|
|
274
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
275
|
+
return self.key.dependencies(self.name) if self.key is not None else OrderedSet()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from sqlglot import exp
|
|
7
|
+
|
|
8
|
+
from metricflow_sql.objects.base import (
|
|
9
|
+
PARENT_LINK,
|
|
10
|
+
BaseObject,
|
|
11
|
+
Id,
|
|
12
|
+
Link,
|
|
13
|
+
Path,
|
|
14
|
+
Prefix,
|
|
15
|
+
dataset_paths,
|
|
16
|
+
deps_from_dataset_expr,
|
|
17
|
+
)
|
|
18
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
19
|
+
from metricflow_sql.utils.sql import column_paths
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class KeyColumn:
|
|
26
|
+
"""
|
|
27
|
+
Specification for an individual `Entity.key`.
|
|
28
|
+
|
|
29
|
+
`optional` means that `Dataset` relationships don't need to reference it,
|
|
30
|
+
but then they don't get access to `Attributes` from `Source`s that implement it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
datatype: exp.DataType | None = None
|
|
34
|
+
optional: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Entity(BaseObject):
|
|
39
|
+
"""
|
|
40
|
+
An Entity is an identifiable, referenceable business object.
|
|
41
|
+
|
|
42
|
+
Entities are like virtual datasets, composed from many datasets.
|
|
43
|
+
`Attribute`s act like an entity's columns.
|
|
44
|
+
|
|
45
|
+
Entities are unique by `name`.
|
|
46
|
+
|
|
47
|
+
`key` is an interface that `Source`s must implement and `Dataset` relationships reference.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: exp.Table
|
|
51
|
+
key: dict[exp.Identifier, KeyColumn]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def id(self) -> Id:
|
|
55
|
+
return Id(Prefix.TABLE, (self.name,))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Source(BaseObject):
|
|
60
|
+
"""
|
|
61
|
+
`Entity`s have `Attribute`s that are sourced from many `Dataset`s.
|
|
62
|
+
A Source defines such a source.
|
|
63
|
+
|
|
64
|
+
Sources are unique on `dataset`/`entity` combinations.
|
|
65
|
+
|
|
66
|
+
`on` is a SQL expression that defines how the `Dataset` implements the `Entity.key`.
|
|
67
|
+
It is used as the ON clause of JOIN expressions, where columns are relative to the `dataset`,
|
|
68
|
+
and entity key values, provided by the other side of the join, are referencable with
|
|
69
|
+
`:name` placeholders.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
dataset: exp.Table
|
|
73
|
+
entity: exp.Table
|
|
74
|
+
on: exp.Expr
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def id(self) -> Id:
|
|
78
|
+
return Id(Prefix.SOURCE, (self.entity, self.dataset))
|
|
79
|
+
|
|
80
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
81
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
82
|
+
deps |= deps_from_dataset_expr(self.dataset, self.on)
|
|
83
|
+
deps.add(Id(Prefix.TABLE, (self.entity,)))
|
|
84
|
+
deps.add(Id(Prefix.TABLE, (self.dataset,)))
|
|
85
|
+
return deps
|
|
86
|
+
|
|
87
|
+
def links(self) -> OrderedSet[Link]:
|
|
88
|
+
links = super().links()
|
|
89
|
+
links.add(Link(target=Id(Prefix.TABLE, (self.entity,)), label=PARENT_LINK))
|
|
90
|
+
return links
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Attribute(BaseObject):
|
|
95
|
+
"""
|
|
96
|
+
An Attribute is a property of an `Entity`.
|
|
97
|
+
|
|
98
|
+
Attributes are unique on `entity`/`name` combinations.
|
|
99
|
+
|
|
100
|
+
`expr` is a SQL expression, where qualified columns reference fields from a `Source`
|
|
101
|
+
and unqualified columns reference other Attributes.
|
|
102
|
+
|
|
103
|
+
`datatype` should be resolved from dependencies.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
entity: exp.Table
|
|
107
|
+
name: exp.Identifier
|
|
108
|
+
expr: exp.Expr
|
|
109
|
+
datatype: exp.DataType | None = None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def id(self) -> Id:
|
|
113
|
+
return Id(Prefix.FIELD, (self.entity, self.name))
|
|
114
|
+
|
|
115
|
+
def dependencies(self) -> OrderedSet[Id | Path]:
|
|
116
|
+
deps: OrderedSet[Id | Path] = OrderedSet()
|
|
117
|
+
for col in self.expr.find_all(exp.Column):
|
|
118
|
+
if not col.table:
|
|
119
|
+
deps.add(Id(Prefix.FIELD, (self.entity, col.this.copy())))
|
|
120
|
+
for parts in column_paths(self.expr):
|
|
121
|
+
# TODO - there's some ambiguity on whether an expression is <source>.<field> or <attribute>.<path>
|
|
122
|
+
for source_dataset, field_parts in dataset_paths(parts, self.entity.args["db"]):
|
|
123
|
+
deps.add(Id(Prefix.SOURCE, (self.entity, source_dataset)))
|
|
124
|
+
deps.add(Path(base=Id(Prefix.TABLE, (source_dataset,)), path=field_parts))
|
|
125
|
+
deps.add(Id(Prefix.FIELD, (source_dataset, self.name)))
|
|
126
|
+
return deps
|
|
127
|
+
|
|
128
|
+
def links(self) -> OrderedSet[Link]:
|
|
129
|
+
links = super().links()
|
|
130
|
+
links.add(Link(target=Id(Prefix.TABLE, (self.entity,)), label=PARENT_LINK))
|
|
131
|
+
return links
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
References to objects in wiki links, e.g. `[revenue](metric:sales:revenue)`.
|
|
3
|
+
|
|
4
|
+
A reference is an id, whose prefix may be an alias - see `Id.resolve`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from sqlglot.dialects.dialect import NormalizationStrategy
|
|
12
|
+
|
|
13
|
+
from metricflow_sql.objects.base import Id, Prefix
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_reference(url: str, dialect: str, strategy: NormalizationStrategy) -> Id | None:
|
|
19
|
+
"""
|
|
20
|
+
Resolve `url` to the id it references, or None if it isn't a reference (e.g. `https:`).
|
|
21
|
+
|
|
22
|
+
Names are percent-decoded by `Id.resolve` (as ids escape `:` and `%`, and markdown parsers
|
|
23
|
+
encode URLs), and any `#fragment` is dropped, e.g. `Metric:sales:net%20revenue#notes` names the field `net revenue`.
|
|
24
|
+
|
|
25
|
+
Raises `ValueError` for a reference with a known prefix but a malformed name, so typos
|
|
26
|
+
surface instead of silently dropping the link.
|
|
27
|
+
"""
|
|
28
|
+
prefix_text, separator, rest = url.partition(":")
|
|
29
|
+
try:
|
|
30
|
+
Prefix.parse(prefix_text)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
if not separator:
|
|
34
|
+
return None
|
|
35
|
+
return Id.resolve(f"{prefix_text}:{rest.partition('#')[0]}", dialect, strategy)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from sqlglot import exp
|
|
8
|
+
from sqlglot.dialects.dialect import NormalizationStrategy
|
|
9
|
+
from wenmode import Wenmode
|
|
10
|
+
from wenmode.ast import plain_text, walk
|
|
11
|
+
from wenmode.nodes import Heading, Root
|
|
12
|
+
from wenmode.nodes import Link as MarkdownLink
|
|
13
|
+
from wenmode.presets import github
|
|
14
|
+
|
|
15
|
+
from metricflow_sql.objects.base import BaseObject, Chunk, Id, Link, Prefix
|
|
16
|
+
from metricflow_sql.objects.reference import parse_reference
|
|
17
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Sections longer than this are split into several chunks, on block boundaries.
|
|
22
|
+
MAX_CHUNK_CHARS = 2000
|
|
23
|
+
|
|
24
|
+
_MARKDOWN = Wenmode(rules=github())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Page(BaseObject):
|
|
29
|
+
"""
|
|
30
|
+
A markdown wiki page, stored alongside the semantic layer.
|
|
31
|
+
|
|
32
|
+
Pages are unique by `name`, which may contain `/` to organize pages hierarchically. Unlike
|
|
33
|
+
the semantic objects, pages aren't in namespaces, so a name may contain `.`, e.g.
|
|
34
|
+
`guides/v1.2`.
|
|
35
|
+
|
|
36
|
+
Links to other objects are regular markdown links with a reference as the URL, e.g.
|
|
37
|
+
`[revenue](metric:sales:revenue)` - see `metricflow_sql.objects.reference`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
content: str
|
|
42
|
+
# The fragment's, which its links are written in
|
|
43
|
+
dialect: str
|
|
44
|
+
# The registry's, so links read names as queries do. It's not the page's, so not compared.
|
|
45
|
+
normalization_strategy: NormalizationStrategy = field(compare=False)
|
|
46
|
+
content_type: Literal["text/markdown"] = "text/markdown"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def id(self) -> Id:
|
|
50
|
+
return Id(Prefix.PAGE, (exp.to_identifier(self.name, quoted=True),))
|
|
51
|
+
|
|
52
|
+
def links(self) -> OrderedSet[Link]:
|
|
53
|
+
"""
|
|
54
|
+
Links to other objects, in order of appearance.
|
|
55
|
+
|
|
56
|
+
Inline, reference-style and autolinks all count; links in code spans and blocks
|
|
57
|
+
don't, nor do links that aren't references (e.g. `https:`). Pages aren't in namespaces,
|
|
58
|
+
so a bare name in a reference is in the default one.
|
|
59
|
+
"""
|
|
60
|
+
links: OrderedSet[Link] = OrderedSet()
|
|
61
|
+
for node in walk(self._parse()):
|
|
62
|
+
match node:
|
|
63
|
+
case MarkdownLink(url=url):
|
|
64
|
+
target = parse_reference(url, self.dialect, self.normalization_strategy)
|
|
65
|
+
if target is not None:
|
|
66
|
+
links.add(Link(target=target, label=plain_text(node)))
|
|
67
|
+
return links
|
|
68
|
+
|
|
69
|
+
def chunks(self) -> tuple[Chunk, ...]:
|
|
70
|
+
"""
|
|
71
|
+
The default name/label/description chunk, then the content split by heading.
|
|
72
|
+
|
|
73
|
+
Each chunk's `section` is its heading trail, e.g. `("Setup", "Install")` for text
|
|
74
|
+
under a `###` heading within a `#` heading, skipping the missing `##` level.
|
|
75
|
+
"""
|
|
76
|
+
chunks = list(super().chunks())
|
|
77
|
+
section: tuple[str, ...] = ()
|
|
78
|
+
blocks: list[str] = []
|
|
79
|
+
|
|
80
|
+
def flush() -> None:
|
|
81
|
+
if blocks:
|
|
82
|
+
chunks.append(Chunk(text="\n\n".join(blocks), section=section))
|
|
83
|
+
blocks.clear()
|
|
84
|
+
|
|
85
|
+
for node in self._parse().children:
|
|
86
|
+
match node:
|
|
87
|
+
case Heading(depth=depth):
|
|
88
|
+
flush()
|
|
89
|
+
section = (*section[: depth - 1], plain_text(node))
|
|
90
|
+
case _:
|
|
91
|
+
text = plain_text(node)
|
|
92
|
+
if not text:
|
|
93
|
+
continue
|
|
94
|
+
if blocks and sum(map(len, blocks)) + len(text) > MAX_CHUNK_CHARS:
|
|
95
|
+
flush()
|
|
96
|
+
blocks.append(text)
|
|
97
|
+
flush()
|
|
98
|
+
return tuple(chunks)
|
|
99
|
+
|
|
100
|
+
def _parse(self) -> Root:
|
|
101
|
+
return _MARKDOWN.parse(self.content)
|
metricflow_sql/py.typed
ADDED
|
File without changes
|
|
File without changes
|