metricflow-sql 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. metricflow_sql/__init__.py +8 -0
  2. metricflow_sql/compiler/__init__.py +0 -0
  3. metricflow_sql/compiler/catalog/__init__.py +0 -0
  4. metricflow_sql/compiler/catalog/cluster.py +152 -0
  5. metricflow_sql/compiler/catalog/information_schema.py +47 -0
  6. metricflow_sql/compiler/catalog/metadata.py +65 -0
  7. metricflow_sql/compiler/catalog/passthrough.py +184 -0
  8. metricflow_sql/compiler/catalog/protocols.py +67 -0
  9. metricflow_sql/compiler/catalog/semantic.py +59 -0
  10. metricflow_sql/compiler/compiler.py +297 -0
  11. metricflow_sql/compiler/rules/__init__.py +0 -0
  12. metricflow_sql/compiler/rules/expand_constellations.py +790 -0
  13. metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
  14. metricflow_sql/compiler/rules/expand_metadata.py +88 -0
  15. metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
  16. metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
  17. metricflow_sql/compiler/table_arguments.py +35 -0
  18. metricflow_sql/converters/__init__.py +0 -0
  19. metricflow_sql/converters/lookml.py +1041 -0
  20. metricflow_sql/objects/__init__.py +0 -0
  21. metricflow_sql/objects/base.py +308 -0
  22. metricflow_sql/objects/constellation.py +210 -0
  23. metricflow_sql/objects/dataset.py +275 -0
  24. metricflow_sql/objects/entity.py +131 -0
  25. metricflow_sql/objects/reference.py +35 -0
  26. metricflow_sql/objects/wiki.py +101 -0
  27. metricflow_sql/py.typed +0 -0
  28. metricflow_sql/registry/__init__.py +0 -0
  29. metricflow_sql/registry/memory.py +409 -0
  30. metricflow_sql/registry/operation.py +25 -0
  31. metricflow_sql/registry/reference_resolution.py +68 -0
  32. metricflow_sql/registry/registry.py +225 -0
  33. metricflow_sql/registry/type_resolver.py +460 -0
  34. metricflow_sql/schemas/__init__.py +0 -0
  35. metricflow_sql/schemas/bundle.py +327 -0
  36. metricflow_sql/schemas/fragment.schema.json +1165 -0
  37. metricflow_sql/schemas/generate.py +70 -0
  38. metricflow_sql/schemas/load.py +333 -0
  39. metricflow_sql/schemas/v1/__init__.py +51 -0
  40. metricflow_sql/schemas/v1/fragment.py +544 -0
  41. metricflow_sql/server/__init__.py +4 -0
  42. metricflow_sql/server/app.py +33 -0
  43. metricflow_sql/server/compile.py +69 -0
  44. metricflow_sql/server/errors.py +10 -0
  45. metricflow_sql/server/serve.py +161 -0
  46. metricflow_sql/utils/__init__.py +0 -0
  47. metricflow_sql/utils/composed_set.py +32 -0
  48. metricflow_sql/utils/normalize.py +74 -0
  49. metricflow_sql/utils/ordered_set.py +24 -0
  50. metricflow_sql/utils/sql.py +226 -0
  51. metricflow_sql/utils/template.py +57 -0
  52. metricflow_sql-0.1.1.dist-info/METADATA +30 -0
  53. metricflow_sql-0.1.1.dist-info/RECORD +56 -0
  54. metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
  55. metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
  56. metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,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()