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,8 @@
1
+ """Core SQL library for MetricFlow."""
2
+
3
+ from importlib import metadata
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ # The installed package's, which a release sets from its tag.
8
+ __version__ = metadata.version("metricflow-sql")
File without changes
File without changes
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from typing import TYPE_CHECKING, override
5
+
6
+ from sqlglot import Dialect, Schema
7
+ from sqlglot import expressions as exp
8
+ from sqlglot.dialects.dialect import DialectType, NormalizationStrategy
9
+ from sqlglot.errors import OptimizeError, SchemaError
10
+ from sqlglot.optimizer.normalize_identifiers import normalize_identifiers
11
+
12
+ from metricflow_sql.compiler.catalog.protocols import CatalogLike, ColumnLike, TableLike
13
+ from metricflow_sql.registry.registry import ConstellationProxy, DatasetProxy, Registry
14
+ from metricflow_sql.utils.normalize import exact_identifier, normalizing_dialect
15
+
16
+ if TYPE_CHECKING:
17
+ from sqlglot.schema import ColumnMapping
18
+
19
+
20
+ class Cluster(Schema):
21
+ """
22
+ Collection of catalogs.
23
+
24
+ This serves as the database schema for the compiler and sqlglot optimizer.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ catalogs: dict[str, CatalogLike] | None = None,
30
+ dialect: DialectType = None,
31
+ fallback: Callable[[str], CatalogLike | None] | None = None,
32
+ normalization_strategy: NormalizationStrategy | None = None,
33
+ ):
34
+ """
35
+ `catalogs` maps names to catalogs. A name matches a query's once both are normalized with
36
+ `normalization_strategy`, e.g. `env_1` matches `ENV_1` if names are uppercase. Give it
37
+ the registry's strategy, as the compiler's.
38
+
39
+ `fallback` builds a catalog for any other name, e.g. a warehouse catalog.
40
+ """
41
+ self.catalogs = catalogs or {}
42
+ self._dialect = dialect
43
+ self._normalizing = (
44
+ normalizing_dialect(None, normalization_strategy) if normalization_strategy else None
45
+ )
46
+ self._fallback = fallback
47
+ self._fallen_back: dict[str, CatalogLike | None] = {}
48
+ self._normalized_names: dict[str, str] = {}
49
+
50
+ def get_catalog(self, name: str) -> CatalogLike | None:
51
+ """The catalog named `name`, which is normalized: one of `catalogs`, else the fallback's."""
52
+ catalog = next(
53
+ (own for key, own in self.catalogs.items() if self._normalized(key) == name), None
54
+ )
55
+ if catalog is not None or self._fallback is None or not name:
56
+ return catalog
57
+ # Built once, so it looks up each table once.
58
+ if name not in self._fallen_back:
59
+ self._fallen_back[name] = self._fallback(name)
60
+ return self._fallen_back[name]
61
+
62
+ def _normalized(self, name: str) -> str:
63
+ if name not in self._normalized_names:
64
+ identifier = exp.to_identifier(name)
65
+ self._normalized_names[name] = normalize_identifiers(
66
+ identifier, dialect=self._normalizing
67
+ ).name
68
+ return self._normalized_names[name]
69
+
70
+ def find_table(self, table: exp.Table | str) -> TableLike[ColumnLike] | None:
71
+ catalog, name = self._resolve(table)
72
+ return catalog.get_table(name)
73
+
74
+ def get_table(self, table: exp.Table | str) -> TableLike[ColumnLike]:
75
+ catalog, name = self._resolve(table)
76
+ table_obj = catalog.get_table(name)
77
+ if not table_obj:
78
+ catalog_name = exp.to_table(table, dialect=self._dialect, copy=False).args["catalog"]
79
+ name.set("catalog", catalog_name.copy())
80
+ raise SchemaError(f"Unknown table: {name.sql(self._dialect)}")
81
+ return table_obj
82
+
83
+ def get_dataset(self, table: exp.Table | str) -> tuple[Registry, DatasetProxy] | None:
84
+ catalog, name = self._resolve(table)
85
+ return catalog.get_dataset(name)
86
+
87
+ def get_constellation(
88
+ self, table: exp.Table | str
89
+ ) -> tuple[Registry, ConstellationProxy] | None:
90
+ catalog, name = self._resolve(table)
91
+ return catalog.get_constellation(name)
92
+
93
+ def _resolve(self, table: exp.Table | str) -> tuple[CatalogLike, exp.Table]:
94
+ """The table's catalog, and its name in it."""
95
+ table = exp.to_table(table, dialect=self._dialect, copy=False)
96
+ if not table.catalog:
97
+ raise OptimizeError(f"Expected table to have been qualified with catalog: {table}")
98
+ if not table.db:
99
+ raise OptimizeError(f"Expected table to have been qualified with db: {table}")
100
+ catalog = self.get_catalog(table.catalog)
101
+ if not catalog:
102
+ raise SchemaError(f"Unknown catalog: {table.sql(self._dialect)}")
103
+ # Not `exp.table_`, which a table function's name, e.g. `f('x')`, can't be passed to.
104
+ return catalog, exp.Table(this=table.this.copy(), db=table.args["db"].copy())
105
+
106
+ @property
107
+ @override
108
+ def empty(self) -> bool:
109
+ # Tables load lazily, so there's no mapping to show; to sqlglot, an empty schema knows no
110
+ # columns, and an alias shadows one, e.g. `b` in `SELECT CONCAT(a, b) AS b ... WHERE b = 1`.
111
+ return not self.catalogs
112
+
113
+ @property
114
+ @override
115
+ def dialect(self) -> Dialect | None:
116
+ return Dialect.get_or_raise(self._dialect)
117
+
118
+ @override
119
+ def add_table(
120
+ self,
121
+ table: exp.Table | str,
122
+ column_mapping: ColumnMapping | None = None,
123
+ dialect: DialectType = None,
124
+ normalize: bool | None = None,
125
+ match_depth: bool = True,
126
+ ) -> None:
127
+ raise NotImplementedError
128
+
129
+ @override
130
+ def column_names(
131
+ self,
132
+ table: exp.Table | str,
133
+ only_visible: bool = False,
134
+ dialect: DialectType = None,
135
+ normalize: bool | None = None,
136
+ ) -> Sequence[str]:
137
+ return [name.name for name in self.get_table(table).column_names()]
138
+
139
+ @override
140
+ def get_column_type(
141
+ self,
142
+ table: exp.Table | str,
143
+ column: exp.Column | str,
144
+ dialect: DialectType = None,
145
+ normalize: bool | None = None,
146
+ ) -> exp.DataType:
147
+ return self.get_table(table).get_column_type(exact_identifier(column))
148
+
149
+ @property
150
+ @override
151
+ def supported_table_args(self) -> tuple[str, ...]:
152
+ return "this", "db", "catalog"
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from sqlglot import exp
6
+
7
+ from metricflow_sql.compiler.catalog.metadata import MetadataColumn, MetadataTable
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Trino's public information_schema.tables columns, all with SQL type VARCHAR.
12
+ TABLE_COLUMNS = (
13
+ "table_catalog",
14
+ "table_schema",
15
+ "table_name",
16
+ "table_type",
17
+ )
18
+
19
+ # The information_schema.columns columns clients read, e.g. Looker, all VARCHAR.
20
+ COLUMN_COLUMNS = (
21
+ "table_catalog",
22
+ "table_schema",
23
+ "table_name",
24
+ "column_name",
25
+ "data_type",
26
+ )
27
+
28
+
29
+ def table_metadata(source: exp.Query, *, engine: str) -> MetadataTable:
30
+ """information_schema.tables, whose rows `source` produces on `engine`."""
31
+ return _varchar_table("tables", TABLE_COLUMNS, source, engine)
32
+
33
+
34
+ def column_metadata(source: exp.Query, *, engine: str) -> MetadataTable:
35
+ """information_schema.columns, whose rows `source` produces on `engine`."""
36
+ return _varchar_table("columns", COLUMN_COLUMNS, source, engine)
37
+
38
+
39
+ def _varchar_table(
40
+ name: str, columns: tuple[str, ...], source: exp.Query, engine: str
41
+ ) -> MetadataTable:
42
+ return MetadataTable(
43
+ name=name,
44
+ columns={column: MetadataColumn(exp.DType.VARCHAR.into_expr()) for column in columns},
45
+ source=source,
46
+ engine=engine,
47
+ )
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable, Collection, Mapping, Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from sqlglot import exp
8
+ from sqlglot.errors import SchemaError
9
+
10
+ from metricflow_sql.compiler.catalog.protocols import CatalogLike, ColumnLike, TableLike
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # The db a semantic catalog serves its metadata tables under.
15
+ INFORMATION_SCHEMA = "information_schema"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MetadataColumn:
20
+ datatype: exp.DataType
21
+ description: str | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class MetadataTable:
26
+ name: str
27
+ columns: dict[str, MetadataColumn]
28
+
29
+ # The query that replaces the table, or a function that builds it when it's read
30
+ source: exp.Query | Callable[[], exp.Query]
31
+
32
+ # The engine that runs queries over the table, e.g. "metadata"
33
+ engine: str
34
+
35
+ def query(self) -> exp.Query:
36
+ """A copy of the query that replaces the table."""
37
+ return self.source() if callable(self.source) else self.source.copy()
38
+
39
+ def column_names(self) -> Collection[exp.Identifier]:
40
+ return [exp.to_identifier(name) for name in self.columns]
41
+
42
+ # information_schema's names are case-insensitive.
43
+ def get_column(self, name: exp.Identifier) -> MetadataColumn | None:
44
+ return self.columns.get(name.name.lower())
45
+
46
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType:
47
+ column = self.get_column(name)
48
+ if column is None:
49
+ raise SchemaError(f"Unknown column: {self.name}.{name.name}")
50
+ return column.datatype
51
+
52
+
53
+ class MetadataCatalog(CatalogLike):
54
+ """A catalog of only metadata tables, e.g. a Trino client's `system.runtime.nodes`."""
55
+
56
+ def __init__(self, tables: Mapping[str, Sequence[MetadataTable]]) -> None:
57
+ """`tables` are the catalog's metadata tables, by db."""
58
+ self._tables = {
59
+ (db.lower(), table.name.lower()): table
60
+ for db, db_tables in tables.items()
61
+ for table in db_tables
62
+ }
63
+
64
+ def get_table(self, table: exp.Table) -> TableLike[ColumnLike] | None:
65
+ return self._tables.get((table.db.lower(), table.name.lower()))
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from typing import Any, Protocol, override
7
+
8
+ from sqlglot import exp
9
+ from sqlglot.dialects.dialect import DialectType
10
+ from sqlglot.errors import ParseError, SchemaError
11
+ from sqlglot.optimizer.normalize_identifiers import normalize_identifiers
12
+
13
+ from metricflow_sql.compiler.catalog.metadata import MetadataColumn
14
+ from metricflow_sql.compiler.catalog.protocols import CatalogLike, TableLike
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PhysicalTable:
21
+ """A warehouse table, named as the warehouse names it."""
22
+
23
+ # Quoted, e.g. `"ANALYTICS"."SALES"."ORDERS"`
24
+ table: exp.Table
25
+ # In the table's order
26
+ columns: Mapping[str, MetadataColumn]
27
+
28
+
29
+ class TableLoader(Protocol):
30
+ """Looks up warehouse tables for a `PassthroughCatalog`."""
31
+
32
+ def find_tables(self, tables: Sequence[exp.Table]) -> Sequence[PhysicalTable | None]:
33
+ """
34
+ The warehouse tables named `tables`, in order, with None for each not found. Names are
35
+ normalized, so they match the warehouse's, e.g. `ANALYTICS.SALES.ORDERS` on Snowflake.
36
+
37
+ Raises `SchemaError` if more than one table matches a name.
38
+ """
39
+ ...
40
+
41
+ def list_catalogs(self) -> Sequence[str]:
42
+ """The warehouse's catalog names."""
43
+ ...
44
+
45
+
46
+ def choose_table(
47
+ table: exp.Table, found: Iterable[PhysicalTable], dialect: DialectType = None
48
+ ) -> PhysicalTable | None:
49
+ """
50
+ The table in `found` named `table`, or None. Names match exactly, or ignoring case if
51
+ `dialect` ignores case. Raises `SchemaError` if more than one matches.
52
+ """
53
+ candidates = [
54
+ physical
55
+ for physical in found
56
+ if all(
57
+ _normalized(getattr(physical.table, part), dialect) == getattr(table, part)
58
+ for part in ("catalog", "db", "name")
59
+ )
60
+ ]
61
+ if len(candidates) > 1:
62
+ names = ", ".join(sorted(physical.table.sql() for physical in candidates))
63
+ raise SchemaError(f"Ambiguous table: {table.sql()} could be any of {names}")
64
+ return candidates[0] if candidates else None
65
+
66
+
67
+ def find_in_information_schema(
68
+ table: exp.Table, run: Callable[[str], Iterable[Sequence[Any]]], dialect: DialectType
69
+ ) -> PhysicalTable | None:
70
+ """
71
+ Look up `table` in the warehouse's `information_schema.columns`, as `TableLoader.find_tables`
72
+ does. `run` runs a query in `dialect` and returns its rows.
73
+ """
74
+ query = (
75
+ exp.select("table_catalog", "table_schema", "table_name", "column_name", "data_type")
76
+ .from_(exp.to_table("information_schema.columns"))
77
+ .where(
78
+ # Match ignoring case; `choose_table` picks the right one.
79
+ exp.Lower(this=exp.column("table_catalog")).eq(
80
+ exp.Literal.string(table.catalog.lower())
81
+ ),
82
+ exp.Lower(this=exp.column("table_schema")).eq(exp.Literal.string(table.db.lower())),
83
+ exp.Lower(this=exp.column("table_name")).eq(exp.Literal.string(table.name.lower())),
84
+ )
85
+ .order_by("table_catalog", "table_schema", "table_name", "ordinal_position")
86
+ )
87
+ tables: dict[tuple[str, str, str], dict[str, MetadataColumn]] = {}
88
+ for catalog_name, schema, name, column, datatype in run(query.sql(dialect=dialect)):
89
+ tables.setdefault((catalog_name, schema, name), {})[column] = MetadataColumn(
90
+ _datatype(datatype, dialect)
91
+ )
92
+ return choose_table(
93
+ table,
94
+ (
95
+ PhysicalTable(
96
+ exp.table_(
97
+ exp.to_identifier(name, quoted=True),
98
+ db=exp.to_identifier(schema, quoted=True),
99
+ catalog=exp.to_identifier(catalog_name, quoted=True),
100
+ ),
101
+ columns,
102
+ )
103
+ for (catalog_name, schema, name), columns in tables.items()
104
+ ),
105
+ dialect,
106
+ )
107
+
108
+
109
+ def _datatype(datatype: str, dialect: DialectType) -> exp.DataType:
110
+ """A column's type, or `UNKNOWN` if sqlglot can't parse it."""
111
+ try:
112
+ return exp.DataType.build(datatype, dialect=dialect)
113
+ except ParseError:
114
+ return exp.DType.UNKNOWN.into_expr()
115
+
116
+
117
+ class PassthroughTable(TableLike[MetadataColumn]):
118
+ """A warehouse table, with its column names normalized as a query's are."""
119
+
120
+ def __init__(self, physical: PhysicalTable, dialect: DialectType) -> None:
121
+ self.physical = physical
122
+ self._columns = {
123
+ _normalized(name, dialect): column for name, column in physical.columns.items()
124
+ }
125
+
126
+ def column_names(self) -> Collection[exp.Identifier]:
127
+ return [exp.to_identifier(name, quoted=True) for name in self._columns]
128
+
129
+ def get_column(self, name: exp.Identifier) -> MetadataColumn | None:
130
+ return self._columns.get(name.name)
131
+
132
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType:
133
+ column = self.get_column(name)
134
+ if column is None:
135
+ raise SchemaError(f"Unknown column: {self.physical.table.sql()}.{name.name}")
136
+ return column.datatype
137
+
138
+
139
+ class PassthroughCatalog(CatalogLike):
140
+ """
141
+ A warehouse catalog the semantic layer doesn't model, e.g. `analytics` in
142
+ `analytics.sales.orders`. `loader` looks up its tables, in a batch with `load`, or one by
143
+ one as queries read them.
144
+
145
+ It caches tables, so build one per compile.
146
+ """
147
+
148
+ def __init__(self, loader: TableLoader, dialect: DialectType, catalog: str) -> None:
149
+ """`dialect` normalizes column names as the compiler does: give it the registry's
150
+ normalization strategy."""
151
+ self.loader = loader
152
+ self.dialect = dialect
153
+ self.catalog = catalog
154
+ self._tables: dict[tuple[str, str], PassthroughTable | None] = {}
155
+
156
+ @override
157
+ def get_table(self, table: exp.Table) -> PassthroughTable | None:
158
+ # More than three parts, which sqlglot reads as a `Dot`
159
+ if not isinstance(table.this, exp.Identifier):
160
+ return None
161
+ self.load([table])
162
+ return self._tables[(table.db, table.name)]
163
+
164
+ def load(self, tables: Iterable[exp.Table]) -> None:
165
+ """Look up those of `tables` not looked up yet, in one batch."""
166
+ missing = {
167
+ (table.db, table.name): exp.table_(
168
+ table.this, db=table.args["db"], catalog=exp.to_identifier(self.catalog)
169
+ )
170
+ for table in tables
171
+ if isinstance(table.this, exp.Identifier) and (table.db, table.name) not in self._tables
172
+ }
173
+ if not missing:
174
+ return
175
+ found = self.loader.find_tables(list(missing.values()))
176
+ for key, physical in zip(missing, found, strict=True):
177
+ self._tables[key] = (
178
+ PassthroughTable(physical, self.dialect) if physical is not None else None
179
+ )
180
+
181
+
182
+ def _normalized(name: str, dialect: DialectType) -> str:
183
+ """`name` as the compiler normalizes it, quoted."""
184
+ return normalize_identifiers(exp.to_identifier(name, quoted=True), dialect=dialect).name
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Collection
4
+ from typing import TYPE_CHECKING, Protocol
5
+
6
+ from sqlglot import expressions as exp
7
+
8
+ if TYPE_CHECKING:
9
+ from metricflow_sql.registry.registry import ConstellationProxy, DatasetProxy, Registry
10
+
11
+
12
+ class ColumnLike(Protocol):
13
+ """
14
+ Protocol for objects that can behave like a table column.
15
+
16
+ Multiple semantic object types act like columns: `Calculation`, `Relationship`, `Metric`,
17
+ `Role`, etc.
18
+ But this also works for non-semantic objects, e.g. `MetadataColumn`.
19
+ """
20
+
21
+ @property
22
+ def description(self) -> str | None: ...
23
+
24
+ @property
25
+ def datatype(self) -> exp.DataType | None: ...
26
+
27
+
28
+ class TableLike[C: ColumnLike](Protocol):
29
+ """
30
+ Protocol for objects that can behave like a table.
31
+
32
+ Multiple semantic object types act like tables: `Table`, `Registry`, etc.
33
+ But this also works for non-semantic objects, e.g. `MetadataTable`.
34
+ """
35
+
36
+ def column_names(self) -> Collection[exp.Identifier]: ...
37
+
38
+ def get_column(self, name: exp.Identifier) -> C | None: ...
39
+
40
+ def get_column_type(self, name: exp.Identifier) -> exp.DataType: ...
41
+
42
+
43
+ class CatalogLike(Protocol):
44
+ """
45
+ Protocol for objects that can behave like a catalog.
46
+
47
+ `Registry` acts like a catalog.
48
+ But this also works for non-semantic objects, e.g. `MetadataCatalog`.
49
+ """
50
+
51
+ def get_table(self, table: exp.Table) -> TableLike[ColumnLike] | None: ...
52
+
53
+ def get_dataset(self, table: exp.Table) -> tuple[Registry, DatasetProxy] | None:
54
+ """
55
+ Compiler rules need to map tables to semantic datasets.
56
+
57
+ This defaults to `None` for non-semantic catalogs.
58
+ """
59
+ return None
60
+
61
+ def get_constellation(self, table: exp.Table) -> tuple[Registry, ConstellationProxy] | None:
62
+ """
63
+ Compiler rules need to map tables to semantic constellations.
64
+
65
+ This defaults to `None` for non-semantic catalogs.
66
+ """
67
+ return None
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Sequence
5
+ from typing import override
6
+
7
+ from sqlglot import exp
8
+ from sqlglot.errors import SchemaError
9
+ from sqlglot.optimizer.qualify_columns import quote_identifiers
10
+
11
+ from metricflow_sql.compiler.catalog.metadata import INFORMATION_SCHEMA, MetadataTable
12
+ from metricflow_sql.compiler.catalog.protocols import CatalogLike, ColumnLike, TableLike
13
+ from metricflow_sql.registry.registry import ConstellationProxy, DatasetProxy, Registry
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SemanticCatalog(CatalogLike):
19
+ """
20
+ Adapts a `Registry` so it can serve as a catalog in a sqlglot `Schema`.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ registry: Registry,
26
+ dialect: str | None = None,
27
+ *,
28
+ metadata_tables: Sequence[MetadataTable] = (),
29
+ ) -> None:
30
+ """`metadata_tables` are this catalog's information_schema, e.g. `tables`."""
31
+ self._metadata_tables = {table.name: table for table in metadata_tables}
32
+
33
+ self.registry = registry
34
+ self.dialect = dialect
35
+
36
+ @override
37
+ def get_table(self, table: exp.Table) -> TableLike[ColumnLike] | None:
38
+ if table.db.lower() == INFORMATION_SCHEMA:
39
+ return self._metadata_tables.get(table.name.lower())
40
+
41
+ name = self._name(table)
42
+ found = self.registry.get_constellation(name) or self.registry.get_dataset(name)
43
+ if found is None:
44
+ raise SchemaError(f"Unknown table: {name.sql(self.registry.dialect)}")
45
+ return found
46
+
47
+ @override
48
+ def get_dataset(self, table: exp.Table) -> tuple[Registry, DatasetProxy] | None:
49
+ dataset = self.registry.get_dataset(self._name(table))
50
+ return (self.registry, dataset) if dataset else None
51
+
52
+ @override
53
+ def get_constellation(self, table: exp.Table) -> tuple[Registry, ConstellationProxy] | None:
54
+ constellation = self.registry.get_constellation(self._name(table))
55
+ return (self.registry, constellation) if constellation else None
56
+
57
+ def _name(self, table: exp.Table) -> exp.Table:
58
+ """A query's table, which sqlglot has normalized, quoted as the registry's names are."""
59
+ return quote_identifiers(table.copy(), dialect=self.registry.dialect)