metricflow-sql 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → metricflow_sql-0.2.0}/PKG-INFO +1 -1
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/pyproject.toml +1 -1
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/server/__init__.py +3 -2
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/server/app.py +9 -3
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/server/compile.py +14 -2
- metricflow_sql-0.2.0/src/metricflow_sql/server/http.py +44 -0
- metricflow_sql-0.2.0/src/metricflow_sql/server/registry.py +173 -0
- metricflow_sql-0.2.0/src/metricflow_sql/server/serde.py +247 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/server/serve.py +5 -3
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/sql.py +13 -1
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/.gitignore +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/LICENSE +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/PYPI.md +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/README.md +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/cluster.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/information_schema.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/metadata.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/passthrough.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/protocols.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/semantic.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/compiler.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/expand_constellations.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/expand_datasets.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/expand_metadata.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/isolate_fragments.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/passthrough_tables.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/table_arguments.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/converters/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/converters/lookml.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/base.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/constellation.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/dataset.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/entity.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/reference.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/objects/wiki.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/py.typed +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/memory.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/operation.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/reference_resolution.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/registry.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/type_resolver.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/bundle.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/fragment.schema.json +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/generate.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/load.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/v1/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/v1/fragment.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/server/errors.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/__init__.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/composed_set.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/normalize.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/ordered_set.py +0 -0
- {metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/utils/template.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metricflow-sql"
|
|
3
3
|
# A placeholder: a release sets its tag's version, with scripts/set_version.py.
|
|
4
|
-
version = "0.
|
|
4
|
+
version = "0.2.0"
|
|
5
5
|
description = "MetricFlow's core semantic layer engine"
|
|
6
6
|
# Its PyPI page: README.md is for this repository.
|
|
7
7
|
readme = "PYPI.md"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""An HTTP server that compiles semantic SQL, e.g. for dbt-metricflow-sql's models
|
|
1
|
+
"""An HTTP server that compiles semantic SQL, e.g. for dbt-metricflow-sql's models, and serves
|
|
2
|
+
the JSON API reading its objects.
|
|
2
3
|
|
|
3
|
-
It requires the `server` extra (`metricflow-sql[server]`), except `errors`.
|
|
4
|
+
It requires the `server` extra (`metricflow-sql[server]`), except `errors` and `serde`.
|
|
4
5
|
"""
|
|
@@ -6,14 +6,18 @@ from starlette.applications import Starlette
|
|
|
6
6
|
from starlette.concurrency import run_in_threadpool
|
|
7
7
|
|
|
8
8
|
from metricflow_sql.compiler.compiler import Compiler, Plan
|
|
9
|
+
from metricflow_sql.registry.registry import Registry
|
|
9
10
|
from metricflow_sql.server.compile import compile_routes
|
|
11
|
+
from metricflow_sql.server.registry import registry_routes
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
def create_compile_app(compiler: Compiler) -> Starlette:
|
|
16
|
+
def create_compile_app(compiler: Compiler, registry: Registry | None = None) -> Starlette:
|
|
15
17
|
"""
|
|
16
|
-
A server of `/v1/compile`
|
|
18
|
+
A server of `/v1/compile` (see `metricflow_sql.server.compile`), with no warehouse, and of
|
|
19
|
+
`/v1/search` and `/v1/objects` over `registry`, the objects `compiler` compiles against (see
|
|
20
|
+
`metricflow_sql.server.registry`).
|
|
17
21
|
|
|
18
22
|
It compiles with `compiler` on Starlette's threadpool, so concurrent compiles share it, and
|
|
19
23
|
its registry must be safe to read from several threads, e.g. an `InMemoryRegistry` that's
|
|
@@ -30,4 +34,6 @@ def create_compile_app(compiler: Compiler) -> Starlette:
|
|
|
30
34
|
with_tables=True,
|
|
31
35
|
)
|
|
32
36
|
|
|
33
|
-
return Starlette(
|
|
37
|
+
return Starlette(
|
|
38
|
+
routes=[*compile_routes(lambda request: compile_sql), *registry_routes(registry)]
|
|
39
|
+
)
|
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
"input_dialect": "bigquery",
|
|
6
6
|
"output_dialect": "bigquery"
|
|
7
7
|
}
|
|
8
|
-
-> 200 {
|
|
8
|
+
-> 200 {
|
|
9
|
+
"sql": "SELECT SUM(amount) ...",
|
|
10
|
+
"tables": [["warehouse", "main", "sales"]],
|
|
11
|
+
"explain": false
|
|
12
|
+
}
|
|
9
13
|
or 400 {"error": "..."}
|
|
10
14
|
|
|
11
15
|
`input_dialect` is the SQL's, and `output_dialect` the compiled SQL's, e.g. both a warehouse's
|
|
12
16
|
for dbt: no Trino in the path. `tables` are the physical tables the compiled SQL reads, by
|
|
13
17
|
their parts as written, e.g. for dbt to order the model after the models that build them.
|
|
18
|
+
`explain` is true for `EXPLAIN <query>`, whose `sql` is the query's compiled SQL: its result,
|
|
19
|
+
rather than SQL to run.
|
|
14
20
|
"""
|
|
15
21
|
|
|
16
22
|
from __future__ import annotations
|
|
@@ -64,6 +70,12 @@ def compile_routes(compile_for: CompileFor) -> list[BaseRoute]:
|
|
|
64
70
|
return JSONResponse({"error": str(e)}, status_code=400)
|
|
65
71
|
logger.info("Compiled:\n%s\n=>\n%s", body["sql"].strip(), plan.out_sql)
|
|
66
72
|
assert plan.tables is not None, "compiled `with_tables`"
|
|
67
|
-
return JSONResponse(
|
|
73
|
+
return JSONResponse(
|
|
74
|
+
{
|
|
75
|
+
"sql": plan.out_sql,
|
|
76
|
+
"tables": [list(table) for table in plan.tables],
|
|
77
|
+
"explain": plan.explain,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
68
80
|
|
|
69
81
|
return [Route("/v1/compile", post_compile, methods=["POST"])]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Helpers shared by the JSON API's routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import msgspec
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
from starlette.responses import Response
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
Handler = Callable[[Request], Awaitable[Response]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiError(Exception):
|
|
20
|
+
"""A request error, rendered as a JSON error response by `endpoint`."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status_code: int, message: str):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.message = message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def endpoint(handler: Handler) -> Handler:
|
|
29
|
+
"""Renders `ApiError`s raised by `handler` as JSON error responses."""
|
|
30
|
+
|
|
31
|
+
@functools.wraps(handler)
|
|
32
|
+
async def wrapped(request: Request) -> Response:
|
|
33
|
+
try:
|
|
34
|
+
return await handler(request)
|
|
35
|
+
except ApiError as error:
|
|
36
|
+
return json_response({"message": error.message}, status_code=error.status_code)
|
|
37
|
+
|
|
38
|
+
return wrapped
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_response(payload: Any, *, status_code: int = 200) -> Response:
|
|
42
|
+
return Response(
|
|
43
|
+
content=msgspec.json.encode(payload), status_code=status_code, media_type="application/json"
|
|
44
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The JSON API reading a registry's objects: searching them, and looking them up by id.
|
|
2
|
+
|
|
3
|
+
GET /v1/search?q=return+rate&limit=10
|
|
4
|
+
-> 200 {"results": [{"id": "f:...", "section": [...], "text": "...", "score": 1.0}]}
|
|
5
|
+
|
|
6
|
+
GET /v1/objects?id=metric:sales:revenue&id=p:homepage
|
|
7
|
+
-> 200 {"objects": {"metric:sales:revenue": {...}, "p:homepage": null}}
|
|
8
|
+
|
|
9
|
+
Each handler opens the registry a `RequestRegistryFactory` selects for the request, e.g. the
|
|
10
|
+
environment its caller is admitted to, off the event loop, as registries block. Errors are
|
|
11
|
+
`{"message": "..."}`, with a 400 for a bad request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
18
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from starlette.concurrency import run_in_threadpool
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.responses import Response
|
|
24
|
+
from starlette.routing import BaseRoute, Route
|
|
25
|
+
|
|
26
|
+
from metricflow_sql.objects.base import Id
|
|
27
|
+
from metricflow_sql.registry.registry import Registry
|
|
28
|
+
from metricflow_sql.server.http import ApiError, Handler, endpoint, json_response
|
|
29
|
+
from metricflow_sql.server.serde import hit_to_json, object_to_json
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
RegistryFactory = Callable[[], AbstractContextManager[Registry]]
|
|
34
|
+
RequestRegistryFactory = Callable[[Request], RegistryFactory]
|
|
35
|
+
|
|
36
|
+
DEFAULT_SEARCH_LIMIT = 10
|
|
37
|
+
MAX_SEARCH_LIMIT = 100
|
|
38
|
+
MAX_BATCH_IDS = 100
|
|
39
|
+
|
|
40
|
+
type BatchLookup[T] = Callable[[Registry, Sequence[Id]], Mapping[Id, T | None]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def read_registry[T](registry_factory: RegistryFactory, read: Callable[[Registry], T]) -> T:
|
|
44
|
+
"""Runs `read` against a freshly opened registry, off the event loop - registries block."""
|
|
45
|
+
|
|
46
|
+
def run() -> T:
|
|
47
|
+
with registry_factory() as registry:
|
|
48
|
+
return read(registry)
|
|
49
|
+
|
|
50
|
+
return await run_in_threadpool(run)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def read_batch[T](
|
|
54
|
+
registry_factory: RegistryFactory,
|
|
55
|
+
keys: list[str],
|
|
56
|
+
lookup: BatchLookup[T],
|
|
57
|
+
to_json: Callable[[T, str], Any],
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""
|
|
60
|
+
Looks up the ids `keys` name in one batch, as JSON keyed like the input.
|
|
61
|
+
|
|
62
|
+
Keys are what the caller passed, e.g. an alias like `metric:a:b`, which can differ from the
|
|
63
|
+
id it names, and whose names are read as the registry normalizes names. Values are None
|
|
64
|
+
where `lookup` has none. Raises `ValueError` for a key that isn't an id.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def read(registry: Registry) -> dict[str, Any]:
|
|
68
|
+
dialect = registry.dialect
|
|
69
|
+
ids_by_key = {
|
|
70
|
+
key: Id.resolve(key, dialect, registry.normalization_strategy) for key in keys
|
|
71
|
+
}
|
|
72
|
+
values = lookup(registry, list(dict.fromkeys(ids_by_key.values())))
|
|
73
|
+
return {
|
|
74
|
+
key: None if (value := values[object_id]) is None else to_json(value, dialect)
|
|
75
|
+
for key, object_id in ids_by_key.items()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return await read_registry(registry_factory, read)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def batch_endpoint[T](
|
|
82
|
+
result_key: str,
|
|
83
|
+
lookup: BatchLookup[T],
|
|
84
|
+
to_json: Callable[[T, str], Any],
|
|
85
|
+
registry_factory: RequestRegistryFactory,
|
|
86
|
+
) -> Handler:
|
|
87
|
+
"""
|
|
88
|
+
A handler looking up the ids given as repeated `id` query parameters, e.g.
|
|
89
|
+
`?id=metric:sales:revenue&id=p:homepage`, returning `{result_key: {id: value or null}}`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
@endpoint
|
|
93
|
+
async def handler(request: Request) -> Response:
|
|
94
|
+
keys = request.query_params.getlist("id")
|
|
95
|
+
if not 1 <= len(keys) <= MAX_BATCH_IDS:
|
|
96
|
+
raise ApiError(400, f"Pass between 1 and {MAX_BATCH_IDS} `id` query parameters")
|
|
97
|
+
try:
|
|
98
|
+
values = await read_batch(registry_factory(request), keys, lookup, to_json)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
raise ApiError(400, str(error)) from error
|
|
101
|
+
return json_response({result_key: values})
|
|
102
|
+
|
|
103
|
+
return handler
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def objects_endpoint(registry_factory: RequestRegistryFactory) -> Handler:
|
|
107
|
+
"""Object lookup with the registry selected per request (see `batch_endpoint`)."""
|
|
108
|
+
return batch_endpoint(
|
|
109
|
+
"objects", lambda registry, ids: registry.get_objects(ids), object_to_json, registry_factory
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def search_endpoint(registry_factory: RequestRegistryFactory) -> Handler:
|
|
114
|
+
"""Search, `?q=<query>&limit=<n>`, with the registry selected per request."""
|
|
115
|
+
|
|
116
|
+
@endpoint
|
|
117
|
+
async def handler(request: Request) -> Response:
|
|
118
|
+
query = request.query_params.get("q", "").strip()
|
|
119
|
+
if not query:
|
|
120
|
+
raise ApiError(400, "Missing query parameter `q`")
|
|
121
|
+
try:
|
|
122
|
+
limit = int(request.query_params.get("limit", DEFAULT_SEARCH_LIMIT))
|
|
123
|
+
except ValueError as error:
|
|
124
|
+
raise ApiError(400, "Query parameter `limit` must be an integer") from error
|
|
125
|
+
if not 1 <= limit <= MAX_SEARCH_LIMIT:
|
|
126
|
+
raise ApiError(400, f"Query parameter `limit` must be between 1 and {MAX_SEARCH_LIMIT}")
|
|
127
|
+
|
|
128
|
+
results = await read_registry(
|
|
129
|
+
registry_factory(request),
|
|
130
|
+
lambda registry: [
|
|
131
|
+
hit_to_json(hit, registry.dialect) for hit in registry.search(query, limit)
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
return json_response({"results": results})
|
|
135
|
+
|
|
136
|
+
return handler
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def registry_routes(registry: Registry | None) -> list[BaseRoute]:
|
|
140
|
+
"""
|
|
141
|
+
`/v1/search` and `/v1/objects` over one `registry`, which must be safe to read from several
|
|
142
|
+
threads, e.g. an `InMemoryRegistry` that's no longer written. Without one, e.g. for a dbt
|
|
143
|
+
project that declares no semantics, search finds nothing and every id names nothing.
|
|
144
|
+
"""
|
|
145
|
+
if registry is None:
|
|
146
|
+
return [
|
|
147
|
+
Route("/v1/search", _empty_search, methods=["GET"]),
|
|
148
|
+
Route("/v1/objects", _no_objects, methods=["GET"]),
|
|
149
|
+
]
|
|
150
|
+
served = registry
|
|
151
|
+
|
|
152
|
+
def factory(request: Request) -> RegistryFactory:
|
|
153
|
+
return lambda: nullcontext(served)
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
Route("/v1/search", search_endpoint(factory), methods=["GET"]),
|
|
157
|
+
Route("/v1/objects", objects_endpoint(factory), methods=["GET"]),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@endpoint
|
|
162
|
+
async def _empty_search(request: Request) -> Response:
|
|
163
|
+
if not request.query_params.get("q", "").strip():
|
|
164
|
+
raise ApiError(400, "Missing query parameter `q`")
|
|
165
|
+
return json_response({"results": []})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@endpoint
|
|
169
|
+
async def _no_objects(request: Request) -> Response:
|
|
170
|
+
keys = request.query_params.getlist("id")
|
|
171
|
+
if not 1 <= len(keys) <= MAX_BATCH_IDS:
|
|
172
|
+
raise ApiError(400, f"Pass between 1 and {MAX_BATCH_IDS} `id` query parameters")
|
|
173
|
+
return json_response({"objects": dict.fromkeys(keys)})
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON representation of registry objects for the API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, TypedDict, assert_never
|
|
10
|
+
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
|
|
13
|
+
from metricflow_sql.objects.base import Backlink, BaseObject, Link
|
|
14
|
+
from metricflow_sql.objects.constellation import (
|
|
15
|
+
Assignment,
|
|
16
|
+
Constellation,
|
|
17
|
+
Exclude,
|
|
18
|
+
ImportRule,
|
|
19
|
+
Include,
|
|
20
|
+
IncludeAll,
|
|
21
|
+
Metric,
|
|
22
|
+
Role,
|
|
23
|
+
)
|
|
24
|
+
from metricflow_sql.objects.dataset import (
|
|
25
|
+
Calculation,
|
|
26
|
+
Column,
|
|
27
|
+
DatasetKey,
|
|
28
|
+
KeyCondition,
|
|
29
|
+
Parameter,
|
|
30
|
+
Relationship,
|
|
31
|
+
Table,
|
|
32
|
+
View,
|
|
33
|
+
)
|
|
34
|
+
from metricflow_sql.objects.entity import Attribute, Entity, Source
|
|
35
|
+
from metricflow_sql.objects.wiki import Page
|
|
36
|
+
from metricflow_sql.registry.registry import SearchHit
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ObjectDoc(TypedDict):
|
|
42
|
+
id: str
|
|
43
|
+
type: str
|
|
44
|
+
label: str | None
|
|
45
|
+
description: str | None
|
|
46
|
+
meta: dict[str, Any] | None
|
|
47
|
+
spec: dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LinkDoc(TypedDict):
|
|
51
|
+
target: str
|
|
52
|
+
label: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BackLinkDoc(TypedDict):
|
|
56
|
+
source: str
|
|
57
|
+
label: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SearchHitDoc(TypedDict):
|
|
61
|
+
id: str
|
|
62
|
+
section: list[str]
|
|
63
|
+
text: str
|
|
64
|
+
score: float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def object_to_json(obj: BaseObject, dialect: str) -> ObjectDoc:
|
|
68
|
+
"""`obj` with its type-specific `spec`, its names written in the registry's `dialect`."""
|
|
69
|
+
return {
|
|
70
|
+
"id": obj.id.text(),
|
|
71
|
+
"type": type(obj).__name__,
|
|
72
|
+
"label": obj.label,
|
|
73
|
+
"description": obj.description,
|
|
74
|
+
"meta": obj.meta,
|
|
75
|
+
"spec": _Spec(dialect).to_json(obj),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def links_to_json(links: list[Link], dialect: str) -> list[LinkDoc]:
|
|
80
|
+
return [{"target": link.target.text(), "label": link.label} for link in links]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def backlinks_to_json(backlinks: list[Backlink], dialect: str) -> list[BackLinkDoc]:
|
|
84
|
+
return [{"source": backlink.source.text(), "label": backlink.label} for backlink in backlinks]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hit_to_json(hit: SearchHit, dialect: str) -> SearchHitDoc:
|
|
88
|
+
return {
|
|
89
|
+
"id": hit.id.text(),
|
|
90
|
+
"section": list(hit.section),
|
|
91
|
+
"text": hit.text,
|
|
92
|
+
"score": hit.score,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class _Spec:
|
|
98
|
+
dialect: str
|
|
99
|
+
|
|
100
|
+
def name(self, name: exp.Table | exp.Identifier) -> str:
|
|
101
|
+
return name.sql(self.dialect)
|
|
102
|
+
|
|
103
|
+
def sql(self, expression: exp.Expr | None) -> str | None:
|
|
104
|
+
"""The expression's source text when it was parsed from one, else generated SQL."""
|
|
105
|
+
if expression is None:
|
|
106
|
+
return None
|
|
107
|
+
match expression.meta:
|
|
108
|
+
case {"sql": str(sql)}:
|
|
109
|
+
return sql
|
|
110
|
+
case _:
|
|
111
|
+
return expression.sql(self.dialect)
|
|
112
|
+
|
|
113
|
+
def import_rule(self, rule: ImportRule) -> str:
|
|
114
|
+
"""The fragment spelling of `rule` - the inverse of `import_rule_from_str`."""
|
|
115
|
+
match rule:
|
|
116
|
+
case IncludeAll():
|
|
117
|
+
return "*"
|
|
118
|
+
case Include(field=field):
|
|
119
|
+
return self.name(field)
|
|
120
|
+
case Exclude(field=field):
|
|
121
|
+
return f"!{self.name(field)}"
|
|
122
|
+
case _:
|
|
123
|
+
assert_never(rule)
|
|
124
|
+
|
|
125
|
+
def parameters(self, parameters: dict[exp.Identifier, Parameter]) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
self.name(name): {
|
|
128
|
+
"datatype": self.sql(parameter.datatype),
|
|
129
|
+
"default": self.sql(parameter.default),
|
|
130
|
+
}
|
|
131
|
+
for name, parameter in parameters.items()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def key(self, key: DatasetKey | None) -> list[Any]:
|
|
135
|
+
"""The fragment spelling of `key`, e.g. `['user_id', {'params': [...], 'on': '...'}]`."""
|
|
136
|
+
parts: list[Any] = []
|
|
137
|
+
for part in key.parts if key is not None else ():
|
|
138
|
+
match part:
|
|
139
|
+
case exp.Identifier():
|
|
140
|
+
parts.append(self.name(part))
|
|
141
|
+
case KeyCondition():
|
|
142
|
+
params = [
|
|
143
|
+
{"name": self.name(name), "datatype": self.sql(parameter.datatype)}
|
|
144
|
+
for name, parameter in part.parameters.items()
|
|
145
|
+
]
|
|
146
|
+
parts.append({"params": params, "on": self.sql(part.on)})
|
|
147
|
+
return parts
|
|
148
|
+
|
|
149
|
+
def to_json(self, obj: BaseObject) -> dict[str, Any]:
|
|
150
|
+
name, sql = self.name, self.sql
|
|
151
|
+
match obj:
|
|
152
|
+
case Table():
|
|
153
|
+
return {
|
|
154
|
+
"name": name(obj.name),
|
|
155
|
+
"location": sql(obj.location),
|
|
156
|
+
"parameters": self.parameters(obj.parameters),
|
|
157
|
+
"key": self.key(obj.key),
|
|
158
|
+
}
|
|
159
|
+
case View():
|
|
160
|
+
return {
|
|
161
|
+
"name": name(obj.name),
|
|
162
|
+
"sql": obj.sql,
|
|
163
|
+
"dialect": obj.dialect,
|
|
164
|
+
"parameters": self.parameters(obj.parameters),
|
|
165
|
+
"key": self.key(obj.key),
|
|
166
|
+
}
|
|
167
|
+
case Column():
|
|
168
|
+
return {
|
|
169
|
+
"dataset": name(obj.dataset),
|
|
170
|
+
"name": name(obj.name),
|
|
171
|
+
"datatype": sql(obj.datatype),
|
|
172
|
+
}
|
|
173
|
+
case Calculation():
|
|
174
|
+
return {
|
|
175
|
+
"dataset": name(obj.dataset),
|
|
176
|
+
"name": name(obj.name),
|
|
177
|
+
"expr": sql(obj.expr),
|
|
178
|
+
"datatype": sql(obj.datatype),
|
|
179
|
+
}
|
|
180
|
+
case Relationship():
|
|
181
|
+
return {
|
|
182
|
+
"dataset": name(obj.dataset),
|
|
183
|
+
"name": name(obj.name),
|
|
184
|
+
"target": name(obj.target),
|
|
185
|
+
"key": {name(key): sql(expr) for key, expr in obj.key.items()},
|
|
186
|
+
"cardinality": obj.cardinality,
|
|
187
|
+
}
|
|
188
|
+
case Entity():
|
|
189
|
+
return {
|
|
190
|
+
"name": name(obj.name),
|
|
191
|
+
"key": {
|
|
192
|
+
name(key): {"datatype": sql(column.datatype), "optional": column.optional}
|
|
193
|
+
for key, column in obj.key.items()
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
case Source():
|
|
197
|
+
return {
|
|
198
|
+
"dataset": name(obj.dataset),
|
|
199
|
+
"entity": name(obj.entity),
|
|
200
|
+
"on": sql(obj.on),
|
|
201
|
+
}
|
|
202
|
+
case Attribute():
|
|
203
|
+
return {
|
|
204
|
+
"entity": name(obj.entity),
|
|
205
|
+
"name": name(obj.name),
|
|
206
|
+
"expr": sql(obj.expr),
|
|
207
|
+
"datatype": sql(obj.datatype),
|
|
208
|
+
}
|
|
209
|
+
case Constellation():
|
|
210
|
+
return {
|
|
211
|
+
"name": name(obj.name),
|
|
212
|
+
"imports": [
|
|
213
|
+
{
|
|
214
|
+
"constellation": name(import_.constellation),
|
|
215
|
+
"fields": [self.import_rule(rule) for rule in import_.fields],
|
|
216
|
+
}
|
|
217
|
+
for import_ in obj.imports
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
case Role():
|
|
221
|
+
return {
|
|
222
|
+
"constellation": name(obj.constellation),
|
|
223
|
+
"name": name(obj.name),
|
|
224
|
+
"datatype": sql(obj.datatype),
|
|
225
|
+
}
|
|
226
|
+
case Metric():
|
|
227
|
+
return {
|
|
228
|
+
"constellation": name(obj.constellation),
|
|
229
|
+
"name": name(obj.name),
|
|
230
|
+
"expr": sql(obj.expr),
|
|
231
|
+
"datatype": sql(obj.datatype),
|
|
232
|
+
}
|
|
233
|
+
case Assignment():
|
|
234
|
+
return {
|
|
235
|
+
"constellation": name(obj.constellation),
|
|
236
|
+
"dataset": name(obj.dataset),
|
|
237
|
+
"role": name(obj.role),
|
|
238
|
+
"expr": sql(obj.expr),
|
|
239
|
+
}
|
|
240
|
+
case Page():
|
|
241
|
+
return {
|
|
242
|
+
"name": obj.name,
|
|
243
|
+
"content": obj.content,
|
|
244
|
+
"content_type": obj.content_type,
|
|
245
|
+
}
|
|
246
|
+
case _:
|
|
247
|
+
raise ValueError(f"Unknown object type: {type(obj).__name__}")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Compiles semantic SQL over bundle directories,
|
|
1
|
+
"""Compiles semantic SQL over bundle directories, and searches and reads their objects, run by a
|
|
2
|
+
process manager, e.g. dbt's.
|
|
2
3
|
|
|
3
4
|
metricflow-serve --bundle target/metricflow --catalog jaffle_shop
|
|
4
5
|
|
|
@@ -8,7 +9,8 @@ closes, e.g. when its manager exits, however it does.
|
|
|
8
9
|
|
|
9
10
|
The bundles' objects are in one semantic catalog, `--catalog`, e.g. a dbt project's name, so
|
|
10
11
|
unqualified tables resolve to it. It only compiles, and each compile names its dialects: see
|
|
11
|
-
`metricflow_sql.server.compile`.
|
|
12
|
+
`metricflow_sql.server.compile`. It also serves `/v1/search` and `/v1/objects` over them: see
|
|
13
|
+
`metricflow_sql.server.registry`.
|
|
12
14
|
|
|
13
15
|
It requires the `server` extra (`metricflow-sql[server]`).
|
|
14
16
|
"""
|
|
@@ -145,7 +147,7 @@ def main() -> None:
|
|
|
145
147
|
sys.exit(f"Invalid bundle: {error}")
|
|
146
148
|
|
|
147
149
|
logger.info("Serving them as catalog `%s`", args.catalog)
|
|
148
|
-
app = create_compile_app(_compiler(registry, args.catalog))
|
|
150
|
+
app = create_compile_app(_compiler(registry, args.catalog), registry)
|
|
149
151
|
# No logging config of uvicorn's own, which logs requests to stdout: its loggers propagate to
|
|
150
152
|
# the root's, on stderr, as stdout is for the address.
|
|
151
153
|
server = _Server(
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import itertools
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
from collections.abc import Iterator
|
|
10
11
|
from typing import overload
|
|
11
12
|
|
|
@@ -24,6 +25,11 @@ logger = logging.getLogger(__name__)
|
|
|
24
25
|
UNKNOWN = exp.DType.UNKNOWN.into_expr()
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
# Reference datatypes stored before `REF(name)` are spelled `TABLE('name')`, or earlier
|
|
29
|
+
# `entity('name')`: bundles and registry rows hold them for good, e.g. `TABLE('item')` -> `item`.
|
|
30
|
+
_LEGACY_REF_DATATYPE = re.compile(r"^(?:table|entity)\('(?P<target>[^']+)'\)$", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
|
|
27
33
|
def type_or_unknown(datatype: exp.DataType | None) -> exp.DataType:
|
|
28
34
|
"""Return a copy of the datatype, defaulting to UNKNOWN."""
|
|
29
35
|
return (UNKNOWN if datatype is None else datatype).copy()
|
|
@@ -62,7 +68,13 @@ def parse_sql_with_source(
|
|
|
62
68
|
datatype's. The text and dialect kept are the original, so it can be parsed again with
|
|
63
69
|
another strategy.
|
|
64
70
|
"""
|
|
65
|
-
|
|
71
|
+
legacy = _LEGACY_REF_DATATYPE.match(text) if into is exp.DataType else None
|
|
72
|
+
# The original text is kept below, so the stored form round-trips unchanged.
|
|
73
|
+
expression = (
|
|
74
|
+
parse_one(text, dialect=dialect, into=into)
|
|
75
|
+
if legacy is None
|
|
76
|
+
else ref_type(exp.to_table(legacy["target"], dialect=dialect))
|
|
77
|
+
)
|
|
66
78
|
if normalization_strategy is not None:
|
|
67
79
|
if into is exp.DataType:
|
|
68
80
|
# Normalize REF targets, including ARRAY<REF(Customer)>, but preserve physical
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/__init__.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/cluster.py
RENAMED
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/metadata.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/passthrough.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/protocols.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/catalog/semantic.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/expand_datasets.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/expand_metadata.py
RENAMED
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/rules/isolate_fragments.py
RENAMED
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/compiler/table_arguments.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/registry/reference_resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metricflow_sql-0.1.1 → metricflow_sql-0.2.0}/src/metricflow_sql/schemas/fragment.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|