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,1041 @@
|
|
|
1
|
+
"""Converts a Looker explore into a v1 fragment, and LookML that queries it through the server.
|
|
2
|
+
|
|
3
|
+
uv run python -m metricflow_sql.converters.lookml ../analytics/fivetran.model.lkml revenue \\
|
|
4
|
+
--connection metricflow \\
|
|
5
|
+
--user-attribute dbt_ft_prod_schema=transforms_bi --out out/revenue
|
|
6
|
+
|
|
7
|
+
Each view becomes a dataset and a LookML view of the same name, shared by explores, and each
|
|
8
|
+
joined view an entity, e.g. `accounts_entity`. Each explore becomes a constellation its LookML
|
|
9
|
+
reads, joining nothing, e.g.
|
|
10
|
+
|
|
11
|
+
SELECT accounts.region, AGG(revenue__annual_revenue), AGG(accounts__account_count)
|
|
12
|
+
FROM metrics.public.revenue_explore(since => ...) AS revenue
|
|
13
|
+
|
|
14
|
+
where `accounts` is a role, the relationship to it from `revenue`, and each view's measure a
|
|
15
|
+
metric, named the same in every explore so its view can be shared. A view another explore joins
|
|
16
|
+
differently gets a relationship of its own, e.g. `current_regions__region_history`. What can't be
|
|
17
|
+
converted is left out, and reported.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import operator
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Callable, Iterator
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
from urllib.parse import quote
|
|
32
|
+
|
|
33
|
+
import lkml
|
|
34
|
+
import sqlglot
|
|
35
|
+
from sqlglot import exp
|
|
36
|
+
from sqlglot.errors import SqlglotError
|
|
37
|
+
|
|
38
|
+
from metricflow_sql.objects.base import DEFAULT_NAMESPACE
|
|
39
|
+
from metricflow_sql.utils.normalize import read_table, strategy_of
|
|
40
|
+
from metricflow_sql.utils.ordered_set import OrderedSet
|
|
41
|
+
from metricflow_sql.utils.sql import ref_type
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# A column of the view's table, e.g. `${TABLE}.month`.
|
|
46
|
+
_TABLE_COLUMN = re.compile(r"\$\{TABLE\}\.(\w+)")
|
|
47
|
+
# A field, e.g. `${month}`, or another view's, e.g. `${accounts.region}`.
|
|
48
|
+
_REF = re.compile(r"\$\{(\w+)(?:\.(\w+))?\}")
|
|
49
|
+
# Liquid, e.g. `{% if x %}` or `{{ value }}`.
|
|
50
|
+
_LIQUID = re.compile(r"\{[{%]")
|
|
51
|
+
# A user attribute, e.g. `{{ _user_attributes['schema'] }}`.
|
|
52
|
+
_USER_ATTRIBUTE = re.compile(r"\{\{\s*_user_attributes\['(\w+)'\]\s*\}\}")
|
|
53
|
+
# A parameter, e.g. `{% parameter since %}`.
|
|
54
|
+
_PARAMETER = re.compile(r"\{%\s*parameter\s+(\w+)\s*%\}")
|
|
55
|
+
# Whether the query reads a field, which Looker knows, e.g. `accounts.region._in_query`.
|
|
56
|
+
_QUERY_FLAG = re.compile(r"\b\w+\.(\w+)\._(in_query|is_filtered|is_selected)\b")
|
|
57
|
+
# An `if` on query flags once they're parameters, e.g. `{% if :region_in_query %} a {% else %} b
|
|
58
|
+
# {% endif %}`.
|
|
59
|
+
_FLAG_IF = re.compile(
|
|
60
|
+
r"\{%\s*if\s+(:\w+(?:\s+(?:and|or)\s+:\w+)*)\s*%\}(.*?)\{%\s*else\s*%\}(.*?)\{%\s*endif\s*%\}",
|
|
61
|
+
re.DOTALL,
|
|
62
|
+
)
|
|
63
|
+
# A dashboard query's model and explore, e.g. `model: fivetran\n explore: revenue`.
|
|
64
|
+
_DASHBOARD_QUERY = re.compile(r"(model: )\S+(\n\s+explore: (\w+))")
|
|
65
|
+
# A number filter's comparison and number, e.g. `<>0`, `>=31` or `5`.
|
|
66
|
+
_NUMBER_FILTER = re.compile(r"(<>|<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)")
|
|
67
|
+
# Looker's string filter syntax beyond one pattern: a list, e.g. `Pro,Enterprise`, an escape,
|
|
68
|
+
# e.g. `50^%`, `EMPTY`, or a backtick, which the drill's filter expression quotes with.
|
|
69
|
+
_STRING_SYNTAX = re.compile(r"[,^`]|\bEMPTY\b", re.IGNORECASE)
|
|
70
|
+
|
|
71
|
+
# The explore's SQL is BigQuery's, as is what the converter writes.
|
|
72
|
+
_DIALECT = "bigquery"
|
|
73
|
+
# The SQL Looker sends the server, for the converted LookML's.
|
|
74
|
+
_LOOKML_DIALECT = "trino"
|
|
75
|
+
# Stands in for a reference while LookML's SQL parses, e.g. `${accounts.region}`.
|
|
76
|
+
_PLACEHOLDER = "__lookml_{}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
type _Template = Callable[[exp.Expr], exp.Expr]
|
|
80
|
+
|
|
81
|
+
# The SQL Looker writes for a dimension group's timeframe, e.g. `${created_month}`.
|
|
82
|
+
_TIMEFRAMES: dict[str, _Template] = {
|
|
83
|
+
"raw": lambda sql: sql,
|
|
84
|
+
"date": lambda sql: exp.func("DATE", sql, dialect=_DIALECT),
|
|
85
|
+
"month": lambda sql: exp.func(
|
|
86
|
+
"FORMAT_DATETIME", exp.Literal.string("%Y-%m"), sql, dialect=_DIALECT
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
_COUNT_DISTINCT = sqlglot.parse_one("COUNT(DISTINCT :sql)", read=_DIALECT)
|
|
90
|
+
_AGGREGATES: dict[str, _Template] = {
|
|
91
|
+
"sum": lambda sql: exp.func("COALESCE", exp.func("SUM", sql), 0),
|
|
92
|
+
"count": lambda sql: exp.func("COUNT", sql),
|
|
93
|
+
"count_distinct": lambda sql: exp.replace_placeholders(_COUNT_DISTINCT, sql=sql),
|
|
94
|
+
"average": lambda sql: exp.func("AVG", sql),
|
|
95
|
+
"max": lambda sql: exp.func("MAX", sql),
|
|
96
|
+
"number": lambda sql: sql,
|
|
97
|
+
}
|
|
98
|
+
# A number filter's comparisons, e.g. `<>0`.
|
|
99
|
+
_COMPARISONS: dict[str, Callable[[exp.Expr, exp.Expr], exp.Expr]] = {
|
|
100
|
+
"<>": lambda sql, number: sql.neq(number),
|
|
101
|
+
"<=": operator.le,
|
|
102
|
+
">=": operator.ge,
|
|
103
|
+
"<": operator.lt,
|
|
104
|
+
">": operator.gt,
|
|
105
|
+
"=": lambda sql, number: sql.eq(number),
|
|
106
|
+
}
|
|
107
|
+
_PARAMETER_TYPES = {"date": "DATE", "number": "FLOAT64", "string": "STRING"}
|
|
108
|
+
# The joins a relationship can express, and Looker's default.
|
|
109
|
+
_RELATIONSHIPS = ("many_to_one", "one_to_one")
|
|
110
|
+
_DEFAULT_RELATIONSHIP = "many_to_one"
|
|
111
|
+
_FIELD_KINDS = ("parameters", "filters", "dimensions", "dimension_groups", "measures")
|
|
112
|
+
# Each explore can add to these, e.g. a relationship to a view only it joins, or a LookML view's
|
|
113
|
+
# field only it converts; the rest must agree.
|
|
114
|
+
_UNITED = ("calculations", "relationships", "attributes", *_FIELD_KINDS, "sets")
|
|
115
|
+
# Keys holding lists of field references, e.g. `fields: [accounts.core_fields*]`.
|
|
116
|
+
_FIELD_LISTS = ("fields", "drill_fields", "required_fields")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Conversion:
|
|
121
|
+
fragment: dict[str, Any]
|
|
122
|
+
"""A v1 fragment."""
|
|
123
|
+
lookml: dict[str, str]
|
|
124
|
+
"""LookML files, keyed by file name."""
|
|
125
|
+
skipped: dict[str, list[str]]
|
|
126
|
+
"""Fields left out, e.g. `revenue.owner_label`, keyed by why."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Skip(Exception):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
type _Resolve = Callable[[str, str, str], exp.Expr]
|
|
134
|
+
# A field's calculation, its converted LookML's SQL for a `filter` or a parameter, which Looker
|
|
135
|
+
# renders, or why it was skipped.
|
|
136
|
+
type _Result = exp.Expr | str | _Skip
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class _Converter:
|
|
141
|
+
views: dict[str, dict[str, Any]]
|
|
142
|
+
explore: dict[str, Any]
|
|
143
|
+
user_attributes: dict[str, str]
|
|
144
|
+
relationships: dict[str, str] = field(default_factory=dict)
|
|
145
|
+
"""By joined view, e.g. `{region_history: current_regions__region_history}`; else its name."""
|
|
146
|
+
entities: dict[str, str] = field(default_factory=dict)
|
|
147
|
+
"""By joined view; else e.g. `accounts_entity`."""
|
|
148
|
+
timezone: str | None = None
|
|
149
|
+
"""Looker's query timezone, which converts the time dimension groups that don't opt out."""
|
|
150
|
+
base: str = field(init=False)
|
|
151
|
+
parents: dict[str, str] = field(init=False)
|
|
152
|
+
"""The view each joined view hangs off."""
|
|
153
|
+
paths: dict[str, list[str]] = field(init=False)
|
|
154
|
+
"""The relationship path from the base view to each view in the explore."""
|
|
155
|
+
definitions: dict[tuple[str, str], tuple[str, dict[str, Any]]] = field(init=False)
|
|
156
|
+
columns: dict[str, OrderedSet[str]] = field(init=False)
|
|
157
|
+
"""Every `${TABLE}.col` each view's LookML reads."""
|
|
158
|
+
results: dict[tuple[str, str], _Result] = field(default_factory=dict)
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
self.base = self.explore.get("view_name", self.explore["name"])
|
|
162
|
+
self.parents, self.paths = {}, {self.base: []}
|
|
163
|
+
for join in self.joins():
|
|
164
|
+
view = join["name"]
|
|
165
|
+
(self.parents[view],) = {match[1] for match in _REF.finditer(join["sql_on"])} - {view}
|
|
166
|
+
self.paths[view] = [*self.paths[self.parents[view]], view]
|
|
167
|
+
self.definitions = {
|
|
168
|
+
(view, definition["name"]): (kind, definition)
|
|
169
|
+
for view in self.paths
|
|
170
|
+
for kind, definition in self.fields(view)
|
|
171
|
+
}
|
|
172
|
+
self.columns = {
|
|
173
|
+
view: OrderedSet(c.lower() for c in _TABLE_COLUMN.findall(json.dumps(self.views[view])))
|
|
174
|
+
for view in self.paths
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def joins(self) -> list[dict[str, Any]]:
|
|
178
|
+
"""The explore's joins that relationships can express."""
|
|
179
|
+
return [
|
|
180
|
+
join for join in self.explore.get("joins", []) if _relationship(join) in _RELATIONSHIPS
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def fields(self, view: str) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
184
|
+
for kind in _FIELD_KINDS:
|
|
185
|
+
for definition in self.views[view].get(kind, []):
|
|
186
|
+
yield kind, definition
|
|
187
|
+
|
|
188
|
+
def definition(self, view: str, name: str) -> tuple[str, dict[str, Any]]:
|
|
189
|
+
"""A field's kind and definition, or empty ones when `view` has no such field."""
|
|
190
|
+
return self.definitions.get((view, name), ("", {}))
|
|
191
|
+
|
|
192
|
+
def result(self, view: str, name: str) -> _Result:
|
|
193
|
+
key = (view, name)
|
|
194
|
+
if key not in self.results:
|
|
195
|
+
try:
|
|
196
|
+
self.results[key] = self.convert(view, *self.definition(view, name))
|
|
197
|
+
except _Skip as skip:
|
|
198
|
+
self.results[key] = skip
|
|
199
|
+
return self.results[key]
|
|
200
|
+
|
|
201
|
+
def convert(self, view: str, kind: str, definition: dict[str, Any]) -> exp.Expr | str:
|
|
202
|
+
match kind:
|
|
203
|
+
# Looker renders parameters and filters' Liquid before it queries.
|
|
204
|
+
case "parameters":
|
|
205
|
+
return ""
|
|
206
|
+
case "filters":
|
|
207
|
+
return self.filter_sql(view, definition["sql"])
|
|
208
|
+
case "measures":
|
|
209
|
+
return self.measure(view, definition)
|
|
210
|
+
case _:
|
|
211
|
+
return self.dimension(view, definition, self.reference)
|
|
212
|
+
|
|
213
|
+
def measure(self, view: str, definition: dict[str, Any]) -> exp.Expr:
|
|
214
|
+
aggregate = _AGGREGATES.get(definition["type"])
|
|
215
|
+
if aggregate is None:
|
|
216
|
+
raise _Skip(f"measure type {definition['type']}")
|
|
217
|
+
sql = (
|
|
218
|
+
self.translate(view, definition["sql"], self.reference)
|
|
219
|
+
if "sql" in definition
|
|
220
|
+
else exp.Literal.number(1)
|
|
221
|
+
)
|
|
222
|
+
conditions = [
|
|
223
|
+
_condition(self.reference(view, *name.rsplit(".", 1)), value, self.datatype(name))
|
|
224
|
+
for name, value in self.filters(view, definition)
|
|
225
|
+
]
|
|
226
|
+
if conditions:
|
|
227
|
+
sql = exp.case().when(exp.and_(*conditions), sql).else_(exp.null())
|
|
228
|
+
return aggregate(sql)
|
|
229
|
+
|
|
230
|
+
def dimension(self, view: str, definition: dict[str, Any], resolve: _Resolve) -> exp.Expr:
|
|
231
|
+
"""A dimension's SQL as Looker renders it: a `case` one as a CASE, a `date` one as a
|
|
232
|
+
DATE, and a dimension group's as a DATETIME, in the timezone when it converts."""
|
|
233
|
+
if "case" in definition:
|
|
234
|
+
lookml_case = definition["case"]
|
|
235
|
+
if "else" not in lookml_case:
|
|
236
|
+
raise _Skip("case without else")
|
|
237
|
+
sql_case = exp.case()
|
|
238
|
+
for when in lookml_case["whens"]:
|
|
239
|
+
sql_case = sql_case.when(
|
|
240
|
+
self.translate(view, when["sql"], resolve), exp.Literal.string(when["label"])
|
|
241
|
+
)
|
|
242
|
+
return sql_case.else_(exp.Literal.string(lookml_case["else"]))
|
|
243
|
+
if "sql" not in definition:
|
|
244
|
+
raise _Skip("dimension without sql")
|
|
245
|
+
sql = self.translate(view, str(definition["sql"]), resolve)
|
|
246
|
+
if definition.get("type") == "date":
|
|
247
|
+
return _TIMEFRAMES["date"](sql)
|
|
248
|
+
if definition.get("type") == "time":
|
|
249
|
+
# The generated connection has no timezone, so Looker converts nothing; converting here
|
|
250
|
+
# matches what Looker renders for BigQuery. It never converts a `datatype: date`.
|
|
251
|
+
if (
|
|
252
|
+
self.timezone
|
|
253
|
+
and definition.get("convert_tz") != "no"
|
|
254
|
+
and definition.get("datatype") != "date"
|
|
255
|
+
):
|
|
256
|
+
return exp.func(
|
|
257
|
+
"DATETIME", sql, exp.Literal.string(self.timezone), dialect=_DIALECT
|
|
258
|
+
)
|
|
259
|
+
return exp.cast(sql, "DATETIME", dialect=_DIALECT)
|
|
260
|
+
return sql
|
|
261
|
+
|
|
262
|
+
def filters(self, view: str, measure: dict[str, Any]) -> list[tuple[str, str]]:
|
|
263
|
+
"""A measure's `filters`, in either form: `{field, value}` or `[field: value, ...]`, by
|
|
264
|
+
the field's name in the explore, e.g. `revenue.tier`, and once each, as LookML can state
|
|
265
|
+
a filter in both."""
|
|
266
|
+
# Looker only filters the measures that aggregate.
|
|
267
|
+
if measure.get("type") == "number":
|
|
268
|
+
return []
|
|
269
|
+
filters = []
|
|
270
|
+
for entry in measure.get("filters__all", []):
|
|
271
|
+
match entry:
|
|
272
|
+
case {"field": name, "value": value}:
|
|
273
|
+
filters.append((name, value))
|
|
274
|
+
case list():
|
|
275
|
+
filters.extend(pair for item in entry for pair in item.items())
|
|
276
|
+
# Looker reads an empty value as any value.
|
|
277
|
+
return list(
|
|
278
|
+
dict.fromkeys(
|
|
279
|
+
(name if "." in name else f"{view}.{name}", value)
|
|
280
|
+
for name, value in filters
|
|
281
|
+
if value
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def datatype(self, name: str) -> str:
|
|
286
|
+
"""The type of the field a filter names, e.g. `accounts.region`: a dimension's, or `date`
|
|
287
|
+
for a dimension group's timeframe, e.g. `accounts.closed_quarter`."""
|
|
288
|
+
view, _, field_name = name.rpartition(".")
|
|
289
|
+
kind, definition = self.definition(view, field_name)
|
|
290
|
+
match kind:
|
|
291
|
+
case "dimensions":
|
|
292
|
+
return str(definition.get("type", "string"))
|
|
293
|
+
case "measures":
|
|
294
|
+
return "measure"
|
|
295
|
+
return "date"
|
|
296
|
+
|
|
297
|
+
def translate(self, view: str, sql: str, resolve: _Resolve) -> exp.Expr:
|
|
298
|
+
"""Parses LookML `sql` into an expression on `view`'s dataset: its `${TABLE}.col` is the
|
|
299
|
+
column, its parameters and query flags placeholders, e.g. `:since`, an `if` on flags a
|
|
300
|
+
CASE, and a reference what `resolve` makes of it."""
|
|
301
|
+
sql = _QUERY_FLAG.sub(lambda match: f":{_flag(match)}", _PARAMETER.sub(r":\1", sql))
|
|
302
|
+
# Each reference and `if`, by the placeholder standing in for it while the SQL parses.
|
|
303
|
+
nodes: dict[str, exp.Expr] = {}
|
|
304
|
+
|
|
305
|
+
def stand_in(node: exp.Expr) -> str:
|
|
306
|
+
name = _PLACEHOLDER.format(len(nodes))
|
|
307
|
+
nodes[name] = node
|
|
308
|
+
return f":{name}"
|
|
309
|
+
|
|
310
|
+
def flag_if(match: re.Match[str]) -> str:
|
|
311
|
+
# Liquid has no precedence, and reads `a and b or c` as `a and (b or c)`.
|
|
312
|
+
*rest, last = re.split(r"\s+(and|or)\s+", match[1])
|
|
313
|
+
condition: exp.Expr = exp.var(last)
|
|
314
|
+
for operand, connective in reversed(list(zip(rest[::2], rest[1::2], strict=True))):
|
|
315
|
+
combine = exp.and_ if connective == "and" else exp.or_
|
|
316
|
+
condition = combine(exp.var(operand), condition)
|
|
317
|
+
then, otherwise = (
|
|
318
|
+
self.translate(view, branch, resolve) for branch in match.groups()[1:]
|
|
319
|
+
)
|
|
320
|
+
return stand_in(exp.case().when(condition, then).else_(otherwise))
|
|
321
|
+
|
|
322
|
+
def reference(match: re.Match[str]) -> str:
|
|
323
|
+
return stand_in(resolve(view, *_target(view, match)))
|
|
324
|
+
|
|
325
|
+
sql = _FLAG_IF.sub(flag_if, sql)
|
|
326
|
+
if _LIQUID.search(sql):
|
|
327
|
+
raise _Skip("Liquid")
|
|
328
|
+
# A column stands in too, so one named like a keyword, e.g. `end`, parses.
|
|
329
|
+
sql = _TABLE_COLUMN.sub(lambda match: stand_in(exp.column(match[1])), sql)
|
|
330
|
+
sql = _REF.sub(reference, sql)
|
|
331
|
+
try:
|
|
332
|
+
parsed = sqlglot.parse_one(sql, read=_DIALECT)
|
|
333
|
+
except SqlglotError:
|
|
334
|
+
raise _Skip("SQL it can't parse") from None
|
|
335
|
+
names = {placeholder.name for placeholder in parsed.find_all(exp.Placeholder)}
|
|
336
|
+
# A stand-in the SQL doesn't read as a placeholder, e.g. in a string, can't be replaced.
|
|
337
|
+
if not nodes.keys() <= names:
|
|
338
|
+
raise _Skip("SQL it can't parse")
|
|
339
|
+
# Parameters stay as the fragment reads them, e.g. `:since`, rather than BigQuery's `@since`.
|
|
340
|
+
return exp.replace_placeholders(
|
|
341
|
+
parsed, **{name: nodes.get(name, exp.var(f":{name}")) for name in names}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def filter_sql(self, view: str, sql: str) -> str:
|
|
345
|
+
"""A `filter` field's SQL as the converted LookML writes it, with its columns and
|
|
346
|
+
references read by their views' names. Looker renders its `{% condition %}` into SQL,
|
|
347
|
+
so it stays a template."""
|
|
348
|
+
sql = _TABLE_COLUMN.sub(lambda match: _path([view, match[1]]).sql(_LOOKML_DIALECT), sql)
|
|
349
|
+
|
|
350
|
+
def reference(match: re.Match[str]) -> str:
|
|
351
|
+
return self.path(*_target(view, match)).sql(_LOOKML_DIALECT)
|
|
352
|
+
|
|
353
|
+
return _REF.sub(reference, sql)
|
|
354
|
+
|
|
355
|
+
def calculation(self, view: str, kind: str, name: str) -> str:
|
|
356
|
+
"""The dataset field a LookML field converts to, renamed when it would shadow a column."""
|
|
357
|
+
if kind == "dimensions" and _is_column(self.result(view, name), name):
|
|
358
|
+
return name
|
|
359
|
+
calculation = (f"{name}_raw" if kind == "dimension_groups" else name).lower()
|
|
360
|
+
if calculation in self.columns[view]:
|
|
361
|
+
return f"{calculation}_{kind.removesuffix('s')}"
|
|
362
|
+
return calculation
|
|
363
|
+
|
|
364
|
+
def name(self, view: str, name: str) -> tuple[str, str, _Template]:
|
|
365
|
+
"""The field a reference reads, its calculation, and the template applied to it."""
|
|
366
|
+
kind = self.definition(view, name)[0]
|
|
367
|
+
group, _, timeframe = name.rpartition("_")
|
|
368
|
+
template = _TIMEFRAMES["raw"]
|
|
369
|
+
if kind not in ("dimensions", "measures"):
|
|
370
|
+
if (
|
|
371
|
+
self.definition(view, group)[0] != "dimension_groups"
|
|
372
|
+
or timeframe not in _TIMEFRAMES
|
|
373
|
+
):
|
|
374
|
+
raise _Skip("references a field that isn't a dimension or measure")
|
|
375
|
+
name, kind, template = group, "dimension_groups", _TIMEFRAMES[timeframe]
|
|
376
|
+
if isinstance(self.result(view, name), _Skip):
|
|
377
|
+
raise _Skip("references a skipped field")
|
|
378
|
+
return name, self.calculation(view, kind, name), template
|
|
379
|
+
|
|
380
|
+
def reference(self, view: str, target: str, name: str) -> exp.Expr:
|
|
381
|
+
"""A reference by name, through the relationships from `view` to `target`."""
|
|
382
|
+
prefix = self.paths[view]
|
|
383
|
+
path = self.paths.get(target)
|
|
384
|
+
if path is None or path[: len(prefix)] != prefix:
|
|
385
|
+
raise _Skip("references a view it doesn't join")
|
|
386
|
+
_, calculation, template = self.name(target, name)
|
|
387
|
+
return template(_path([*self.relationship_path(path[len(prefix) :]), calculation]))
|
|
388
|
+
|
|
389
|
+
def relationship_name(self, view: str) -> str:
|
|
390
|
+
return self.relationships.get(view, view)
|
|
391
|
+
|
|
392
|
+
def relationship_path(self, views: list[str]) -> list[str]:
|
|
393
|
+
return [self.relationship_name(view) for view in views]
|
|
394
|
+
|
|
395
|
+
def entity_name(self, view: str) -> str:
|
|
396
|
+
return self.entities.get(view, _entity_name(view))
|
|
397
|
+
|
|
398
|
+
def path(self, view: str, name: str) -> exp.Expr:
|
|
399
|
+
"""A reference as the converted LookML writes it, e.g. `accounts.region`."""
|
|
400
|
+
_, calculation, template = self.name(view, name)
|
|
401
|
+
return template(_path([view, calculation]))
|
|
402
|
+
|
|
403
|
+
def inline(self, view: str, target: str, name: str) -> exp.Expr:
|
|
404
|
+
"""A reference expanded to its columns, since join keys can't read calculations."""
|
|
405
|
+
resolved, _, template = self.name(target, name)
|
|
406
|
+
return template(self.dimension(target, self.definition(target, resolved)[1], self.inline))
|
|
407
|
+
|
|
408
|
+
def parameters(self, view: str) -> dict[str, tuple[str, dict[str, Any]]]:
|
|
409
|
+
"""The parameters `view`'s LookML reads, by name: the Liquid Looker renders as their
|
|
410
|
+
argument, and their definition. A query flag is one too, e.g. `region_in_query` for
|
|
411
|
+
`accounts.region._in_query`, and NULL when false."""
|
|
412
|
+
lookml = json.dumps(self.views[view])
|
|
413
|
+
# The explore reads them, so by their view, e.g. `accounts.min_employees`.
|
|
414
|
+
parameters = {
|
|
415
|
+
name: (
|
|
416
|
+
f"{{% parameter {view}.{name} %}}",
|
|
417
|
+
_parameter(self.definition(view, name)[1]),
|
|
418
|
+
)
|
|
419
|
+
for name in _PARAMETER.findall(lookml)
|
|
420
|
+
}
|
|
421
|
+
for match in _QUERY_FLAG.finditer(lookml):
|
|
422
|
+
parameters[_flag(match)] = (
|
|
423
|
+
f"{{% if {match[0]} %}}TRUE{{% else %}}NULL{{% endif %}}",
|
|
424
|
+
{"datatype": "BOOL"},
|
|
425
|
+
)
|
|
426
|
+
return parameters
|
|
427
|
+
|
|
428
|
+
def resolve_user_attributes(self, sql: str) -> str:
|
|
429
|
+
return _USER_ATTRIBUTE.sub(lambda match: self.user_attributes[match[1]], sql)
|
|
430
|
+
|
|
431
|
+
def calculations(self, view: str) -> dict[str, exp.Expr]:
|
|
432
|
+
calculations = {}
|
|
433
|
+
for kind, definition in self.fields(view):
|
|
434
|
+
match self.result(view, definition["name"]):
|
|
435
|
+
case exp.Expr() as result:
|
|
436
|
+
calculation = self.calculation(view, kind, definition["name"])
|
|
437
|
+
if not _is_column(result, calculation):
|
|
438
|
+
calculations[calculation] = result
|
|
439
|
+
return calculations
|
|
440
|
+
|
|
441
|
+
def dataset(self, view: str) -> dict[str, Any]:
|
|
442
|
+
calculations = self.calculations(view)
|
|
443
|
+
definition = self.views[view]
|
|
444
|
+
source: dict[str, Any]
|
|
445
|
+
parameters = {name: parameter for name, (_, parameter) in self.parameters(view).items()}
|
|
446
|
+
if "derived_table" in definition:
|
|
447
|
+
sql = self.resolve_user_attributes(definition["derived_table"]["sql"])
|
|
448
|
+
sql = _QUERY_FLAG.sub(_flag, _PARAMETER.sub(r"{{ \1 }}", sql))
|
|
449
|
+
source = {"type": "sql", "sql": sql}
|
|
450
|
+
else:
|
|
451
|
+
source = {"location": self.resolve_user_attributes(definition["sql_table_name"])}
|
|
452
|
+
source["columns"] = [{"name": column} for column in self.columns[view]]
|
|
453
|
+
return {
|
|
454
|
+
"source": source,
|
|
455
|
+
"parameters": _named(parameters),
|
|
456
|
+
"calculations": [
|
|
457
|
+
{"name": name, "expr": expr.sql(_DIALECT)} for name, expr in calculations.items()
|
|
458
|
+
],
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def join(self, join: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
462
|
+
"""
|
|
463
|
+
`sql_on: ${revenue.account_id} = ${accounts.account_id}` is the entity
|
|
464
|
+
|
|
465
|
+
key: [{name: revenue__account_id}]
|
|
466
|
+
sources: [{dataset: accounts, on: ":revenue__account_id = account_id"}]
|
|
467
|
+
|
|
468
|
+
and the relationship key `[{name: revenue__account_id, expr: account_id}]` on `revenue`.
|
|
469
|
+
"""
|
|
470
|
+
view = join["name"]
|
|
471
|
+
key: dict[str, exp.Expr] = {}
|
|
472
|
+
|
|
473
|
+
def bind(_: str, target: str, name: str) -> exp.Expr:
|
|
474
|
+
if target == view:
|
|
475
|
+
return self.inline(view, view, name)
|
|
476
|
+
# Named apart from `accounts.account_id`, which reads the joined row, NULL when the
|
|
477
|
+
# join doesn't match.
|
|
478
|
+
key_name = f"{target}__{name}"
|
|
479
|
+
key[key_name] = self.inline(target, target, name)
|
|
480
|
+
return exp.var(f":{key_name}")
|
|
481
|
+
|
|
482
|
+
on = self.translate(view, join["sql_on"], bind)
|
|
483
|
+
entity = {
|
|
484
|
+
"key": [{"name": name} for name in key],
|
|
485
|
+
"sources": [{"dataset": view, "on": on.sql(_DIALECT)}],
|
|
486
|
+
}
|
|
487
|
+
return entity, [{"name": name, "expr": expr.sql(_DIALECT)} for name, expr in key.items()]
|
|
488
|
+
|
|
489
|
+
def joins_by_view(self) -> dict[str, tuple[dict[str, Any], list[dict[str, Any]]]]:
|
|
490
|
+
"""Each joined view's `join`; one that can't convert fails the explore."""
|
|
491
|
+
joins = {}
|
|
492
|
+
for join in self.joins():
|
|
493
|
+
try:
|
|
494
|
+
joins[join["name"]] = self.join(join)
|
|
495
|
+
except _Skip as skip:
|
|
496
|
+
raise ValueError(f"{self.explore['name']}'s join {join['name']}: {skip}") from None
|
|
497
|
+
return joins
|
|
498
|
+
|
|
499
|
+
def entity(self, view: str, entity: dict[str, Any]) -> dict[str, Any]:
|
|
500
|
+
"""`join`'s entity, with the view's fields and the relationships from it as attributes,
|
|
501
|
+
e.g. `region: accounts.region` and `account_facts: accounts.account_facts`."""
|
|
502
|
+
children = [child for child, path in self.paths.items() if path[:-1] == self.paths[view]]
|
|
503
|
+
attributes = [
|
|
504
|
+
*self.columns[view],
|
|
505
|
+
*self.calculations(view),
|
|
506
|
+
*self.relationship_path(children),
|
|
507
|
+
]
|
|
508
|
+
return {
|
|
509
|
+
**entity,
|
|
510
|
+
"attributes": [
|
|
511
|
+
{"name": name, "expr": exp.column(name, view).sql(_DIALECT)} for name in attributes
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
def constellation(self) -> dict[str, Any]:
|
|
516
|
+
"""
|
|
517
|
+
The explore, e.g. for `revenue` joining `accounts` and, through it, `account_facts`:
|
|
518
|
+
|
|
519
|
+
roles: [accounts: REF(accounts_entity), account_facts: ..., pricing_type, ...]
|
|
520
|
+
metrics: [
|
|
521
|
+
revenue__annual_revenue: AGG(revenue.annual_revenue),
|
|
522
|
+
accounts__account_count: AGG(revenue.accounts.account_count),
|
|
523
|
+
...
|
|
524
|
+
]
|
|
525
|
+
assignments: revenue -> [accounts: accounts, account_facts: accounts.account_facts, ...]
|
|
526
|
+
"""
|
|
527
|
+
roles: dict[str, dict[str, Any]] = {}
|
|
528
|
+
assignments: dict[str, exp.Expr] = {}
|
|
529
|
+
metrics = {}
|
|
530
|
+
for view, path in self.paths.items():
|
|
531
|
+
if path:
|
|
532
|
+
target = read_table(self.entity_name(view), _DIALECT, strategy_of(_DIALECT))
|
|
533
|
+
roles[view] = {"datatype": ref_type(target).sql(_DIALECT)}
|
|
534
|
+
assignments[view] = _path(self.relationship_path(path))
|
|
535
|
+
for kind, definition in self.fields(view):
|
|
536
|
+
if kind == "measures" and not isinstance(
|
|
537
|
+
self.result(view, definition["name"]), _Skip
|
|
538
|
+
):
|
|
539
|
+
calculation = self.calculation(view, kind, definition["name"])
|
|
540
|
+
reference = _path([self.base, *self.relationship_path(path), calculation])
|
|
541
|
+
metrics[_metric_name(view, calculation)] = {
|
|
542
|
+
"expr": exp.func("AGG", reference).sql(_DIALECT)
|
|
543
|
+
}
|
|
544
|
+
for kind, definition in self.fields(self.base):
|
|
545
|
+
if kind in ("parameters", "filters", "measures") or isinstance(
|
|
546
|
+
self.result(self.base, definition["name"]), _Skip
|
|
547
|
+
):
|
|
548
|
+
continue
|
|
549
|
+
name = self.calculation(self.base, kind, definition["name"])
|
|
550
|
+
if name in self.paths:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"{self.explore['name']}' field {self.base}.{definition['name']} is named "
|
|
553
|
+
"like a view it joins"
|
|
554
|
+
)
|
|
555
|
+
roles[name] = {}
|
|
556
|
+
assignments[name] = exp.column(name)
|
|
557
|
+
# A filter's SQL can read the view's columns, e.g. `${TABLE}.managers`.
|
|
558
|
+
for column in self.columns[self.base]:
|
|
559
|
+
roles.setdefault(column, {})
|
|
560
|
+
assignments.setdefault(column, exp.column(column))
|
|
561
|
+
return {
|
|
562
|
+
"roles": _named(roles),
|
|
563
|
+
"metrics": _named(metrics),
|
|
564
|
+
"assignments": [
|
|
565
|
+
{
|
|
566
|
+
"dataset": self.base,
|
|
567
|
+
"roles": [
|
|
568
|
+
{"name": role, "expr": expr.sql(_DIALECT)}
|
|
569
|
+
for role, expr in assignments.items()
|
|
570
|
+
],
|
|
571
|
+
}
|
|
572
|
+
],
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
def base_entity(self) -> dict[str, Any] | None:
|
|
576
|
+
"""The base view's rows, by primary key, so a joined view's measures can count theirs."""
|
|
577
|
+
try:
|
|
578
|
+
return self.primary_key_entity()
|
|
579
|
+
except _Skip as skip:
|
|
580
|
+
raise ValueError(f"{self.explore['name']}'s primary key: {skip}") from None
|
|
581
|
+
|
|
582
|
+
def primary_key_entity(self) -> dict[str, Any] | None:
|
|
583
|
+
for _, definition in self.fields(self.base):
|
|
584
|
+
if definition.get("primary_key") == "yes":
|
|
585
|
+
key = definition["name"]
|
|
586
|
+
on = self.inline(self.base, self.base, key).eq(exp.var(f":{key}"))
|
|
587
|
+
return {
|
|
588
|
+
"key": [{"name": key}],
|
|
589
|
+
"sources": [{"dataset": self.base, "on": on.sql(_DIALECT)}],
|
|
590
|
+
}
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
def skipped(self) -> dict[str, list[str]]:
|
|
594
|
+
skipped: dict[str, list[str]] = {}
|
|
595
|
+
for view in self.paths:
|
|
596
|
+
for _, definition in self.fields(view):
|
|
597
|
+
result = self.result(view, definition["name"])
|
|
598
|
+
if isinstance(result, _Skip):
|
|
599
|
+
skipped.setdefault(str(result), []).append(f"{view}.{definition['name']}")
|
|
600
|
+
return skipped
|
|
601
|
+
|
|
602
|
+
def prune(self, view: str, entries: list[str]) -> list[str]:
|
|
603
|
+
"""Drops references to skipped fields from a list of field references."""
|
|
604
|
+
|
|
605
|
+
def is_skipped(entry: str) -> bool:
|
|
606
|
+
target, _, name = entry.lstrip("-").rpartition(".")
|
|
607
|
+
return isinstance(self.results.get((target or view, name)), _Skip)
|
|
608
|
+
|
|
609
|
+
return [entry for entry in entries if not is_skipped(entry)]
|
|
610
|
+
|
|
611
|
+
def prune_lists(self, view: str, node: dict[str, Any]) -> dict[str, Any]:
|
|
612
|
+
return {
|
|
613
|
+
key: self.prune(view, value) if key in _FIELD_LISTS else value
|
|
614
|
+
for key, value in node.items()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
def sql_table_name(self, catalog: str) -> str:
|
|
618
|
+
"""`metrics.public.revenue_explore(since => {% parameter revenue.since %})`"""
|
|
619
|
+
arguments = [
|
|
620
|
+
exp.Kwarg(this=exp.to_identifier(name), expression=exp.var(argument))
|
|
621
|
+
for view in self.paths
|
|
622
|
+
for name, (argument, _) in self.parameters(view).items()
|
|
623
|
+
]
|
|
624
|
+
constellation = exp.to_identifier(_constellation_name(self.explore["name"]))
|
|
625
|
+
table = exp.Table(
|
|
626
|
+
this=(
|
|
627
|
+
exp.Anonymous(this=constellation, expressions=arguments)
|
|
628
|
+
if arguments
|
|
629
|
+
else constellation
|
|
630
|
+
),
|
|
631
|
+
db=exp.to_identifier(DEFAULT_NAMESPACE),
|
|
632
|
+
catalog=exp.to_identifier(catalog),
|
|
633
|
+
)
|
|
634
|
+
# Named, and quoted, as a table is, rather than as a function, e.g. `REVENUE(...)`.
|
|
635
|
+
return table.sql(_LOOKML_DIALECT, normalize_functions=False)
|
|
636
|
+
|
|
637
|
+
def lookml_view(self, view: str) -> dict[str, Any]:
|
|
638
|
+
converted: dict[str, Any] = {"name": view}
|
|
639
|
+
for kind, definition in self.fields(view):
|
|
640
|
+
name = definition["name"]
|
|
641
|
+
if isinstance(self.result(view, name), _Skip):
|
|
642
|
+
continue
|
|
643
|
+
definition = self.prune_lists(view, definition)
|
|
644
|
+
path = _path([view, self.calculation(view, kind, name)])
|
|
645
|
+
match kind:
|
|
646
|
+
case "dimensions" | "dimension_groups":
|
|
647
|
+
definition.pop("case", None)
|
|
648
|
+
definition["sql"] = path.sql(_LOOKML_DIALECT)
|
|
649
|
+
case "measures":
|
|
650
|
+
filters = self.filters(view, definition)
|
|
651
|
+
metric = exp.column(_metric_name(view, self.calculation(view, kind, name)))
|
|
652
|
+
definition = {
|
|
653
|
+
**_without(definition, "filters__all"),
|
|
654
|
+
"type": "number",
|
|
655
|
+
"sql": exp.func("AGG", metric).sql(_LOOKML_DIALECT),
|
|
656
|
+
}
|
|
657
|
+
# The drill link only exists with drill fields, and a measure's own `html`
|
|
658
|
+
# is kept.
|
|
659
|
+
if filters and definition.get("drill_fields") and "html" not in definition:
|
|
660
|
+
definition["html"] = _drill_html(filters)
|
|
661
|
+
case "filters":
|
|
662
|
+
definition["sql"] = self.result(view, name)
|
|
663
|
+
converted.setdefault(kind, []).append(definition)
|
|
664
|
+
sets = [self.prune_lists(view, s) for s in self.views[view].get("sets", [])]
|
|
665
|
+
if sets:
|
|
666
|
+
converted["sets"] = sets
|
|
667
|
+
return converted
|
|
668
|
+
|
|
669
|
+
def lookml_explore(self, catalog: str) -> dict[str, Any]:
|
|
670
|
+
explore = {
|
|
671
|
+
**self.prune_lists(self.base, self.explore),
|
|
672
|
+
"sql_table_name": self.sql_table_name(catalog),
|
|
673
|
+
}
|
|
674
|
+
explore["joins"] = [
|
|
675
|
+
{**self.prune_lists(join["name"], _without(join, "sql_on")), "sql": ""}
|
|
676
|
+
for join in self.joins()
|
|
677
|
+
]
|
|
678
|
+
return explore
|
|
679
|
+
|
|
680
|
+
def skipped_joins(self) -> dict[str, list[str]]:
|
|
681
|
+
skipped: dict[str, list[str]] = {}
|
|
682
|
+
for join in self.explore.get("joins", []):
|
|
683
|
+
if _relationship(join) not in _RELATIONSHIPS:
|
|
684
|
+
skipped.setdefault(f"{_relationship(join)} join", []).append(join["name"])
|
|
685
|
+
return skipped
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _entity_name(view: str) -> str:
|
|
689
|
+
"""It can't share the view's dataset's name."""
|
|
690
|
+
return f"{view}_entity"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _metric_name(view: str, calculation: str) -> str:
|
|
694
|
+
return f"{view}__{calculation}"
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _constellation_name(explore: str) -> str:
|
|
698
|
+
"""It can't share its base view's dataset's name."""
|
|
699
|
+
return f"{explore}_explore"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _distinct_names[K](
|
|
703
|
+
definitions: dict[str, dict[K, Any]], default: Callable[[K], str]
|
|
704
|
+
) -> dict[str, dict[K, str]]:
|
|
705
|
+
"""
|
|
706
|
+
Names what each explore defines, e.g. the entity of each view it joins, `default(key)`, or,
|
|
707
|
+
when an earlier explore defines it differently, after the first explore defining it so:
|
|
708
|
+
|
|
709
|
+
{revenue: {region_history: A}, current_regions: {region_history: B}}
|
|
710
|
+
-> {revenue: {region_history: region_history_entity},
|
|
711
|
+
current_regions: {region_history: current_regions__region_history_entity}}
|
|
712
|
+
"""
|
|
713
|
+
firsts: dict[K, dict[str, str | None]] = {}
|
|
714
|
+
names: dict[str, dict[K, str]] = {explore: {} for explore in definitions}
|
|
715
|
+
for explore, by_key in definitions.items():
|
|
716
|
+
for key, definition in by_key.items():
|
|
717
|
+
seen = firsts.setdefault(key, {})
|
|
718
|
+
first = seen.setdefault(json.dumps(definition), explore if seen else None)
|
|
719
|
+
names[explore][key] = default(key) if first is None else f"{first}__{default(key)}"
|
|
720
|
+
return names
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _merge_member(collection: dict[str, Any], name: str, member: dict[str, Any]) -> None:
|
|
724
|
+
collection[name] = _merged(collection[name], member, name) if name in collection else member
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _merged(old: dict[str, Any], new: dict[str, Any], name: str) -> dict[str, Any]:
|
|
728
|
+
merged = {**old, **new}
|
|
729
|
+
for key in merged:
|
|
730
|
+
if key in _UNITED:
|
|
731
|
+
members = {member["name"]: member for member in old.get(key, [])}
|
|
732
|
+
for member in new.get(key, []):
|
|
733
|
+
if members.setdefault(member["name"], member) != member:
|
|
734
|
+
raise ValueError(
|
|
735
|
+
f"Explores convert `{name}.{key}.{member['name']}` differently"
|
|
736
|
+
)
|
|
737
|
+
merged[key] = list(members.values())
|
|
738
|
+
elif old.get(key) != new.get(key):
|
|
739
|
+
raise ValueError(f"Explores convert `{name}.{key}` differently")
|
|
740
|
+
return merged
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _relationship(join: dict[str, Any]) -> str:
|
|
744
|
+
return str(join.get("relationship", _DEFAULT_RELATIONSHIP))
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _flag(match: re.Match[str]) -> str:
|
|
748
|
+
"""A query flag's parameter, e.g. `region_in_query` for `accounts.region._in_query`."""
|
|
749
|
+
return f"{match[1]}_{match[2]}"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _named(collection: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
753
|
+
"""A fragment's named collection, e.g. `[{name: orders, ...}]` for `{orders: {...}}`."""
|
|
754
|
+
return [{"name": name, **member} for name, member in collection.items()]
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _without(node: dict[str, Any], key: str) -> dict[str, Any]:
|
|
758
|
+
return {k: v for k, v in node.items() if k != key}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _target(view: str, match: re.Match[str]) -> tuple[str, str]:
|
|
762
|
+
"""A reference's view and field: `${accounts.region}`'s, or `${region}`'s on `view`."""
|
|
763
|
+
return (match[1], match[2]) if match[2] else (view, match[1])
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _path(parts: list[str]) -> exp.Expr:
|
|
767
|
+
"""A column through relationships, e.g. `revenue.accounts.region`, of any length."""
|
|
768
|
+
return exp.column(*reversed(parts[:4]), fields=parts[4:] or None)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _is_column(result: _Result, name: str) -> bool:
|
|
772
|
+
"""Whether a field's calculation is just its dataset's column `name`."""
|
|
773
|
+
match result:
|
|
774
|
+
case exp.Column(table="") if result.name == name:
|
|
775
|
+
return True
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _condition(sql: exp.Expr, value: str, datatype: str) -> exp.Expr:
|
|
780
|
+
"""A filter expression on a field of `datatype` as SQL, as Looker writes it for BigQuery:
|
|
781
|
+
`<>0` on a number is `x <> 0`, which drops NULLs, while `-Free_%` on a string keeps them.
|
|
782
|
+
What else Looker's syntax can say is skipped, e.g. a date's `3 months ago`."""
|
|
783
|
+
is_null = sql.is_(exp.null())
|
|
784
|
+
match value.lower():
|
|
785
|
+
case "null":
|
|
786
|
+
return is_null
|
|
787
|
+
case "not null" if datatype == "string":
|
|
788
|
+
raise _Skip("filter on a string")
|
|
789
|
+
case "-null" | "not null":
|
|
790
|
+
return exp.not_(is_null)
|
|
791
|
+
if datatype == "yesno" and value.lower() in ("yes", "no"):
|
|
792
|
+
return sql if value.lower() == "yes" else exp.not_(exp.func("COALESCE", sql, exp.false()))
|
|
793
|
+
if datatype == "number" and (match := _NUMBER_FILTER.fullmatch(value)):
|
|
794
|
+
return _COMPARISONS[match[1] or "="](sql, exp.Literal.number(match[2]))
|
|
795
|
+
if datatype == "string" and not _STRING_SYNTAX.search(value):
|
|
796
|
+
negated = value.startswith("-")
|
|
797
|
+
pattern = exp.Literal.string(value[1:] if negated else value)
|
|
798
|
+
# Case insensitive, as the model sets `case_sensitive: no`.
|
|
799
|
+
like = exp.func("LOWER", sql).like(exp.func("LOWER", pattern))
|
|
800
|
+
return exp.or_(exp.not_(like), is_null) if negated else like
|
|
801
|
+
raise _Skip(f"filter on a {datatype}")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _drill_html(filters: list[tuple[str, str]]) -> str:
|
|
805
|
+
"""A converted measure's value, linked to its drill with its `filters` added, since Looker
|
|
806
|
+
adds an aggregate measure's filters to its drill, and the converted are numbers. The link
|
|
807
|
+
keeps one `f[...]` per field, so a field the query filters too takes the measure's filter as
|
|
808
|
+
an expression, as Looker's drill does, e.g. `matches_filter(${revenue.tier}, `-Free_%`)`.
|
|
809
|
+
Looker's Liquid can't `capture` the expression's parts to join them, so it starts with
|
|
810
|
+
`yes`."""
|
|
811
|
+
fields = "".join(
|
|
812
|
+
f"{{% unless {name}._is_filtered %}}&f[{name}]={quote(value, safe='')}{{% endunless %}}"
|
|
813
|
+
for name, value in filters
|
|
814
|
+
)
|
|
815
|
+
expressions = "".join(
|
|
816
|
+
f"{{% if {name}._is_filtered %}}"
|
|
817
|
+
f"{quote(f' AND matches_filter(${{{name}}}, `{value}`)', safe='')}"
|
|
818
|
+
"{% endif %}"
|
|
819
|
+
for name, value in filters
|
|
820
|
+
)
|
|
821
|
+
any_filtered = " or ".join(f"{name}._is_filtered" for name, _ in filters)
|
|
822
|
+
return (
|
|
823
|
+
f'<a href="{{{{ link }}}}{fields}'
|
|
824
|
+
f"{{% if {any_filtered} %}}&filter_expression=yes{expressions}{{% endif %}}"
|
|
825
|
+
'">{{ rendered_value }}</a>'
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _parameter(definition: dict[str, Any]) -> dict[str, Any]:
|
|
830
|
+
parameter: dict[str, Any] = {"datatype": _PARAMETER_TYPES[definition["type"]]}
|
|
831
|
+
if "default_value" in definition:
|
|
832
|
+
value = definition["default_value"]
|
|
833
|
+
literal = (
|
|
834
|
+
exp.Literal.number(value)
|
|
835
|
+
if definition["type"] == "number"
|
|
836
|
+
else exp.Literal.string(value)
|
|
837
|
+
)
|
|
838
|
+
parameter["default"] = literal.sql(_DIALECT)
|
|
839
|
+
return parameter
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _included(model: Path, patterns: list[str], include: tuple[Path, ...]) -> Iterator[Path]:
|
|
843
|
+
"""The view files `patterns` match, as Looker reads them: `*.view` is `*.view.lkml`, and
|
|
844
|
+
`/x/*` and `//project/x/*` start at the model's and another project's directory."""
|
|
845
|
+
for pattern in patterns:
|
|
846
|
+
directories: tuple[Path, ...] = (model.parent,)
|
|
847
|
+
if pattern.startswith("//"):
|
|
848
|
+
directories, pattern = include, pattern[2:].partition("/")[2]
|
|
849
|
+
if not pattern.endswith(("*", ".lkml")):
|
|
850
|
+
pattern += ".lkml"
|
|
851
|
+
for directory in directories:
|
|
852
|
+
yield from (
|
|
853
|
+
p for p in directory.glob(pattern.lstrip("/")) if p.name.endswith(".view.lkml")
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def convert(
|
|
858
|
+
model: Path,
|
|
859
|
+
explores: list[str],
|
|
860
|
+
*,
|
|
861
|
+
user_attributes: dict[str, str],
|
|
862
|
+
connection: str,
|
|
863
|
+
include: tuple[Path, ...] = (),
|
|
864
|
+
model_name: str | None = None,
|
|
865
|
+
group_label: str | None = None,
|
|
866
|
+
dashboards: tuple[Path, ...] = (),
|
|
867
|
+
catalog: str = "metrics",
|
|
868
|
+
timezone: str | None = None,
|
|
869
|
+
) -> Conversion:
|
|
870
|
+
"""Converts `explores` of `model`, or all of them, reading the views it includes: from the
|
|
871
|
+
model's directory, and another project's, e.g. `//fivetran/*.view.lkml`, from `include`'s.
|
|
872
|
+
`group_label` replaces the explores', e.g. to list them apart from the model's."""
|
|
873
|
+
parsed = lkml.load(model.read_text())
|
|
874
|
+
views = {
|
|
875
|
+
view["name"]: view
|
|
876
|
+
for path in _included(model, parsed.get("includes", []), include)
|
|
877
|
+
for view in lkml.load(path.read_text()).get("views", [])
|
|
878
|
+
}
|
|
879
|
+
for view in views.values():
|
|
880
|
+
for dimension in view.get("dimensions", []):
|
|
881
|
+
# Looker reads a dimension without SQL as its view's column of its name.
|
|
882
|
+
if "sql" not in dimension and "case" not in dimension:
|
|
883
|
+
dimension["sql"] = f"${{TABLE}}.{dimension['name']}"
|
|
884
|
+
selected = [
|
|
885
|
+
definition
|
|
886
|
+
for definition in parsed["explores"]
|
|
887
|
+
if not explores or definition["name"] in explores
|
|
888
|
+
]
|
|
889
|
+
# What each explore's joins are, to name what explores join differently.
|
|
890
|
+
drafts = {
|
|
891
|
+
definition["name"]: _Converter(views, definition, user_attributes, timezone=timezone)
|
|
892
|
+
for definition in selected
|
|
893
|
+
}
|
|
894
|
+
joins = {explore: draft.joins_by_view() for explore, draft in drafts.items()}
|
|
895
|
+
base_entities = {explore: draft.base_entity() for explore, draft in drafts.items()}
|
|
896
|
+
entity_names = _distinct_names(
|
|
897
|
+
{
|
|
898
|
+
explore: {
|
|
899
|
+
**(
|
|
900
|
+
{drafts[explore].base: base_entities[explore]} if base_entities[explore] else {}
|
|
901
|
+
),
|
|
902
|
+
**{view: entity for view, (entity, _) in by_view.items()},
|
|
903
|
+
}
|
|
904
|
+
for explore, by_view in joins.items()
|
|
905
|
+
},
|
|
906
|
+
_entity_name,
|
|
907
|
+
)
|
|
908
|
+
# Relationships to the same view from different datasets don't collide.
|
|
909
|
+
relationship_names = _distinct_names(
|
|
910
|
+
{
|
|
911
|
+
explore: {
|
|
912
|
+
(drafts[explore].parents[view], view): {
|
|
913
|
+
"target": entity_names[explore][view],
|
|
914
|
+
"key": key,
|
|
915
|
+
}
|
|
916
|
+
for view, (_, key) in by_view.items()
|
|
917
|
+
}
|
|
918
|
+
for explore, by_view in joins.items()
|
|
919
|
+
},
|
|
920
|
+
operator.itemgetter(1),
|
|
921
|
+
)
|
|
922
|
+
datasets: dict[str, Any] = {}
|
|
923
|
+
entities: dict[str, Any] = {}
|
|
924
|
+
constellations: dict[str, Any] = {}
|
|
925
|
+
skipped: dict[str, list[str]] = {}
|
|
926
|
+
lookml_explores = []
|
|
927
|
+
lookml_views: dict[str, Any] = {}
|
|
928
|
+
for definition in selected:
|
|
929
|
+
explore = definition["name"]
|
|
930
|
+
converter = _Converter(
|
|
931
|
+
views,
|
|
932
|
+
definition,
|
|
933
|
+
user_attributes,
|
|
934
|
+
relationships={view: name for (_, view), name in relationship_names[explore].items()},
|
|
935
|
+
entities=entity_names[explore],
|
|
936
|
+
timezone=timezone,
|
|
937
|
+
)
|
|
938
|
+
# Converting every field first, so field lists can drop the skipped ones.
|
|
939
|
+
# Explores share views, so a view's skips would otherwise repeat.
|
|
940
|
+
for reason, names in {**converter.skipped(), **converter.skipped_joins()}.items():
|
|
941
|
+
skipped[reason] = list(dict.fromkeys([*skipped.get(reason, []), *names]))
|
|
942
|
+
|
|
943
|
+
if base_entity := base_entities[explore]:
|
|
944
|
+
_merge_member(entities, converter.entity_name(converter.base), base_entity)
|
|
945
|
+
explore_datasets = {view: converter.dataset(view) for view in converter.paths}
|
|
946
|
+
for view, (entity, key) in joins[explore].items():
|
|
947
|
+
name = converter.entity_name(view)
|
|
948
|
+
_merge_member(entities, name, converter.entity(view, entity))
|
|
949
|
+
explore_datasets[converter.parents[view]].setdefault("relationships", []).append(
|
|
950
|
+
{"name": converter.relationship_name(view), "target": name, "key": key}
|
|
951
|
+
)
|
|
952
|
+
for view, dataset in explore_datasets.items():
|
|
953
|
+
_merge_member(datasets, view, dataset)
|
|
954
|
+
constellations[_constellation_name(explore)] = converter.constellation()
|
|
955
|
+
|
|
956
|
+
lookml_explore = converter.lookml_explore(catalog)
|
|
957
|
+
if group_label is not None:
|
|
958
|
+
lookml_explore["group_label"] = group_label
|
|
959
|
+
lookml_explores.append(lookml_explore)
|
|
960
|
+
for view in converter.paths:
|
|
961
|
+
_merge_member(lookml_views, view, converter.lookml_view(view))
|
|
962
|
+
lookml_files = {
|
|
963
|
+
f"{view}.view.lkml": lkml.dump({"views": [lookml_view]}) or ""
|
|
964
|
+
for view, lookml_view in lookml_views.items()
|
|
965
|
+
}
|
|
966
|
+
model_name = model_name or model.name.removesuffix(".model.lkml")
|
|
967
|
+
fragment = {
|
|
968
|
+
"schema_version": "v1",
|
|
969
|
+
"bundle": model_name,
|
|
970
|
+
"dialect": _DIALECT,
|
|
971
|
+
"generation_meta": {"generator_name": __name__},
|
|
972
|
+
"datasets": _named(datasets),
|
|
973
|
+
"entities": _named(entities),
|
|
974
|
+
"constellations": _named(constellations),
|
|
975
|
+
}
|
|
976
|
+
lookml_model = {
|
|
977
|
+
"connection": connection,
|
|
978
|
+
**{
|
|
979
|
+
k: v
|
|
980
|
+
for k, v in parsed.items()
|
|
981
|
+
if k in ("case_sensitive", "fiscal_month_offset", "week_start_day")
|
|
982
|
+
},
|
|
983
|
+
# Only what the conversion writes, so the model can share a project with others.
|
|
984
|
+
"includes": [*lookml_files, *(dashboard.name for dashboard in dashboards)],
|
|
985
|
+
"explores": lookml_explores,
|
|
986
|
+
}
|
|
987
|
+
lookml_files[f"{model_name}.model.lkml"] = lkml.dump(lookml_model) or ""
|
|
988
|
+
# A dashboard's queries on the converted explores read the converted model.
|
|
989
|
+
converted = {explore["name"] for explore in lookml_explores}
|
|
990
|
+
for dashboard in dashboards:
|
|
991
|
+
lookml_files[dashboard.name] = _DASHBOARD_QUERY.sub(
|
|
992
|
+
lambda match: (
|
|
993
|
+
f"{match[1]}{model_name}{match[2]}" if match[3] in converted else match[0]
|
|
994
|
+
),
|
|
995
|
+
dashboard.read_text(),
|
|
996
|
+
)
|
|
997
|
+
return Conversion(fragment=fragment, lookml=lookml_files, skipped=skipped)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def main() -> None:
|
|
1001
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
1002
|
+
parser.add_argument("model", type=Path)
|
|
1003
|
+
parser.add_argument("explores", nargs="*", help="Explores to convert; all when none.")
|
|
1004
|
+
parser.add_argument("--include", type=Path, action="append", default=[])
|
|
1005
|
+
parser.add_argument("--model-name", help="The converted model's name; the model's by default.")
|
|
1006
|
+
parser.add_argument("--group-label", help="The converted explores' group label.")
|
|
1007
|
+
parser.add_argument("--dashboard", type=Path, action="append", default=[])
|
|
1008
|
+
parser.add_argument("--connection", required=True)
|
|
1009
|
+
parser.add_argument("--catalog", default="metrics", help="The catalog the server serves.")
|
|
1010
|
+
parser.add_argument("--user-attribute", action="append", default=[], metavar="NAME=VALUE")
|
|
1011
|
+
parser.add_argument(
|
|
1012
|
+
"--timezone",
|
|
1013
|
+
help="Looker's query timezone, e.g. America/Los_Angeles; none converts nothing.",
|
|
1014
|
+
)
|
|
1015
|
+
parser.add_argument("--out", type=Path, required=True)
|
|
1016
|
+
args = parser.parse_args()
|
|
1017
|
+
|
|
1018
|
+
conversion = convert(
|
|
1019
|
+
args.model,
|
|
1020
|
+
args.explores,
|
|
1021
|
+
include=tuple(args.include),
|
|
1022
|
+
model_name=args.model_name,
|
|
1023
|
+
group_label=args.group_label,
|
|
1024
|
+
dashboards=tuple(args.dashboard),
|
|
1025
|
+
user_attributes=dict(a.split("=", 1) for a in args.user_attribute),
|
|
1026
|
+
connection=args.connection,
|
|
1027
|
+
catalog=args.catalog,
|
|
1028
|
+
timezone=args.timezone,
|
|
1029
|
+
)
|
|
1030
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
1031
|
+
(args.out / "fragment.json").write_text(json.dumps(conversion.fragment, indent=2))
|
|
1032
|
+
for name, text in conversion.lookml.items():
|
|
1033
|
+
(args.out / name).write_text(text)
|
|
1034
|
+
for reason, fields in sorted(conversion.skipped.items(), key=lambda item: -len(item[1])):
|
|
1035
|
+
print(f"{len(fields):5} {reason}")
|
|
1036
|
+
for name in fields:
|
|
1037
|
+
print(f" {name}")
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
if __name__ == "__main__":
|
|
1041
|
+
main()
|