metricflow-sql 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metricflow_sql/__init__.py +8 -0
- metricflow_sql/compiler/__init__.py +0 -0
- metricflow_sql/compiler/catalog/__init__.py +0 -0
- metricflow_sql/compiler/catalog/cluster.py +152 -0
- metricflow_sql/compiler/catalog/information_schema.py +47 -0
- metricflow_sql/compiler/catalog/metadata.py +65 -0
- metricflow_sql/compiler/catalog/passthrough.py +184 -0
- metricflow_sql/compiler/catalog/protocols.py +67 -0
- metricflow_sql/compiler/catalog/semantic.py +59 -0
- metricflow_sql/compiler/compiler.py +297 -0
- metricflow_sql/compiler/rules/__init__.py +0 -0
- metricflow_sql/compiler/rules/expand_constellations.py +790 -0
- metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
- metricflow_sql/compiler/rules/expand_metadata.py +88 -0
- metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
- metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
- metricflow_sql/compiler/table_arguments.py +35 -0
- metricflow_sql/converters/__init__.py +0 -0
- metricflow_sql/converters/lookml.py +1041 -0
- metricflow_sql/objects/__init__.py +0 -0
- metricflow_sql/objects/base.py +308 -0
- metricflow_sql/objects/constellation.py +210 -0
- metricflow_sql/objects/dataset.py +275 -0
- metricflow_sql/objects/entity.py +131 -0
- metricflow_sql/objects/reference.py +35 -0
- metricflow_sql/objects/wiki.py +101 -0
- metricflow_sql/py.typed +0 -0
- metricflow_sql/registry/__init__.py +0 -0
- metricflow_sql/registry/memory.py +409 -0
- metricflow_sql/registry/operation.py +25 -0
- metricflow_sql/registry/reference_resolution.py +68 -0
- metricflow_sql/registry/registry.py +225 -0
- metricflow_sql/registry/type_resolver.py +460 -0
- metricflow_sql/schemas/__init__.py +0 -0
- metricflow_sql/schemas/bundle.py +327 -0
- metricflow_sql/schemas/fragment.schema.json +1165 -0
- metricflow_sql/schemas/generate.py +70 -0
- metricflow_sql/schemas/load.py +333 -0
- metricflow_sql/schemas/v1/__init__.py +51 -0
- metricflow_sql/schemas/v1/fragment.py +544 -0
- metricflow_sql/server/__init__.py +4 -0
- metricflow_sql/server/app.py +33 -0
- metricflow_sql/server/compile.py +69 -0
- metricflow_sql/server/errors.py +10 -0
- metricflow_sql/server/serve.py +161 -0
- metricflow_sql/utils/__init__.py +0 -0
- metricflow_sql/utils/composed_set.py +32 -0
- metricflow_sql/utils/normalize.py +74 -0
- metricflow_sql/utils/ordered_set.py +24 -0
- metricflow_sql/utils/sql.py +226 -0
- metricflow_sql/utils/template.py +57 -0
- metricflow_sql-0.1.1.dist-info/METADATA +30 -0
- metricflow_sql-0.1.1.dist-info/RECORD +56 -0
- metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
- metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
- metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
|
File without changes
|
|
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)
|