metricflow-sql 0.1.1__tar.gz → 0.3.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.
Files changed (60) hide show
  1. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/PKG-INFO +1 -1
  2. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/pyproject.toml +1 -1
  3. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/compiler.py +10 -12
  4. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/expand_constellations.py +3 -2
  5. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/expand_datasets.py +4 -3
  6. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/server/__init__.py +3 -2
  7. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/server/app.py +9 -3
  8. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/server/compile.py +21 -2
  9. metricflow_sql-0.3.0/src/metricflow_sql/server/http.py +44 -0
  10. metricflow_sql-0.3.0/src/metricflow_sql/server/registry.py +173 -0
  11. metricflow_sql-0.3.0/src/metricflow_sql/server/serde.py +247 -0
  12. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/server/serve.py +5 -3
  13. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/normalize.py +10 -4
  14. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/sql.py +13 -1
  15. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/.gitignore +0 -0
  16. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/LICENSE +0 -0
  17. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/PYPI.md +0 -0
  18. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/README.md +0 -0
  19. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/__init__.py +0 -0
  20. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/__init__.py +0 -0
  21. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/__init__.py +0 -0
  22. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/cluster.py +0 -0
  23. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/information_schema.py +0 -0
  24. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/metadata.py +0 -0
  25. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/passthrough.py +0 -0
  26. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/protocols.py +0 -0
  27. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/catalog/semantic.py +0 -0
  28. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/__init__.py +0 -0
  29. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/expand_metadata.py +0 -0
  30. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/isolate_fragments.py +0 -0
  31. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/rules/passthrough_tables.py +0 -0
  32. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/compiler/table_arguments.py +0 -0
  33. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/converters/__init__.py +0 -0
  34. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/converters/lookml.py +0 -0
  35. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/__init__.py +0 -0
  36. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/base.py +0 -0
  37. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/constellation.py +0 -0
  38. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/dataset.py +0 -0
  39. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/entity.py +0 -0
  40. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/reference.py +0 -0
  41. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/objects/wiki.py +0 -0
  42. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/py.typed +0 -0
  43. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/__init__.py +0 -0
  44. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/memory.py +0 -0
  45. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/operation.py +0 -0
  46. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/reference_resolution.py +0 -0
  47. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/registry.py +0 -0
  48. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/registry/type_resolver.py +0 -0
  49. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/__init__.py +0 -0
  50. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/bundle.py +0 -0
  51. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/fragment.schema.json +0 -0
  52. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/generate.py +0 -0
  53. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/load.py +0 -0
  54. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/v1/__init__.py +0 -0
  55. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/schemas/v1/fragment.py +0 -0
  56. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/server/errors.py +0 -0
  57. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/__init__.py +0 -0
  58. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/composed_set.py +0 -0
  59. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/ordered_set.py +0 -0
  60. {metricflow_sql-0.1.1 → metricflow_sql-0.3.0}/src/metricflow_sql/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: metricflow-sql
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: MetricFlow's core semantic layer engine
5
5
  Project-URL: Documentation, https://congenial-bassoon-7zkjv8w.pages.github.io/
6
6
  License-Expression: LicenseRef-Fivetran
@@ -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.1.1"
4
+ version = "0.3.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"
@@ -28,6 +28,7 @@ from metricflow_sql.compiler.rules.expand_metadata import ENGINE, ExpandMetadata
28
28
  from metricflow_sql.compiler.rules.isolate_fragments import isolate_fragments
29
29
  from metricflow_sql.compiler.rules.passthrough_tables import name_passthrough_tables
30
30
  from metricflow_sql.compiler.table_arguments import bind_table_arguments
31
+ from metricflow_sql.utils.normalize import normalizing_dialect
31
32
 
32
33
 
33
34
  def qualify_tables(
@@ -97,8 +98,8 @@ class Compiler:
97
98
  planner_rules: Sequence[OptimizerFn] = PLANNER_RULES,
98
99
  catalog: str | None = None,
99
100
  db: str | None = None,
100
- input_dialect: str = "",
101
- output_dialect: str = "",
101
+ input_dialect: DialectType = None,
102
+ output_dialect: DialectType = None,
102
103
  engine_dialects: Mapping[str, str] | None = None,
103
104
  normalization_strategy: NormalizationStrategy | None = None,
104
105
  ) -> None:
@@ -122,8 +123,8 @@ class Compiler:
122
123
  sql: str,
123
124
  *,
124
125
  read_only: bool = False,
125
- input_dialect: str | None = None,
126
- output_dialect: str | None = None,
126
+ input_dialect: DialectType = None,
127
+ output_dialect: DialectType = None,
127
128
  positional_columns: bool = False,
128
129
  with_tables: bool = False,
129
130
  db: str | None = None,
@@ -150,10 +151,7 @@ class Compiler:
150
151
  # dialect and normalization strategy.
151
152
  default_db = self.db if db is None else exp.to_identifier(db).sql(dialect=input_dialect)
152
153
  if self.normalization_strategy is not None:
153
- input_dialect = (
154
- f"{input_dialect}, normalization_strategy = "
155
- f"{self.normalization_strategy.value.lower()}"
156
- )
154
+ input_dialect = normalizing_dialect(input_dialect, self.normalization_strategy)
157
155
  expressions = parse(sql, read=input_dialect)
158
156
  explain = False
159
157
  match expressions:
@@ -211,7 +209,7 @@ class Compiler:
211
209
  tables=tuple(tables) if with_tables else None,
212
210
  )
213
211
 
214
- def _apply_rules(self, expression: exp.Expr, dialect: str, db: str | None) -> exp.Expr:
212
+ def _apply_rules(self, expression: exp.Expr, dialect: DialectType, db: str | None) -> exp.Expr:
215
213
  arguments = {
216
214
  "schema": self.cluster,
217
215
  "cluster": self.cluster,
@@ -226,7 +224,7 @@ class Compiler:
226
224
  break
227
225
  return expression
228
226
 
229
- def _dialect(self, engine: str | None, output_dialect: str) -> str:
227
+ def _dialect(self, engine: str | None, output_dialect: DialectType) -> DialectType:
230
228
  if engine is None:
231
229
  return output_dialect
232
230
  if engine not in self.engine_dialects:
@@ -234,7 +232,7 @@ class Compiler:
234
232
  return self.engine_dialects[engine]
235
233
 
236
234
  def _prepare(
237
- self, expression: exp.Expr, dialect: str, db: str | None, *, read_only: bool
235
+ self, expression: exp.Expr, dialect: DialectType, db: str | None, *, read_only: bool
238
236
  ) -> None:
239
237
  for node in expression.walk():
240
238
  match node:
@@ -243,7 +241,7 @@ class Compiler:
243
241
  case exp.Into() if read_only:
244
242
  raise ValueError("Only one query statement is supported")
245
243
 
246
- def _is_dataset(self, table: exp.Table, dialect: str, db: str | None) -> bool:
244
+ def _is_dataset(self, table: exp.Table, dialect: DialectType, db: str | None) -> bool:
247
245
  """Nothing is loaded yet, so a constellation counts too."""
248
246
  # Not normalized yet, so normalize as `qualify` will. `normalize_identifiers` changes its
249
247
  # argument, so the query's own catalog is copied.
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
5
5
  from typing import NoReturn
6
6
 
7
7
  from sqlglot import exp
8
+ from sqlglot.dialects.dialect import DialectType
8
9
  from sqlglot.helper import find_new_name
9
10
  from sqlglot.optimizer.qualify import qualify
10
11
  from sqlglot.optimizer.qualify_columns import quote_identifiers
@@ -104,7 +105,7 @@ class ConstellationContext:
104
105
  # Whether the query has no GROUP BY or AGG, so it reads the datasets' rows, stacked.
105
106
  stack: bool = False
106
107
  # The query's dialect, used to qualify branches, normalize metrics and print errors.
107
- dialect: str | None = None
108
+ dialect: DialectType = None
108
109
 
109
110
 
110
111
  class ExpandConstellationsRule:
@@ -148,7 +149,7 @@ class ExpandConstellationsRule:
148
149
  raise ValueError(message)
149
150
 
150
151
  def __call__(
151
- self, expression: exp.Expr, cluster: Cluster, dialect: str | None = None
152
+ self, expression: exp.Expr, cluster: Cluster, dialect: DialectType = None
152
153
  ) -> exp.Expr:
153
154
  for scope, select, table in table_scopes(expression):
154
155
  if not (registry_and_constellation := cluster.get_constellation(table)):
@@ -7,6 +7,7 @@ from itertools import count
7
7
  from typing import NoReturn
8
8
 
9
9
  from sqlglot import Expr, exp
10
+ from sqlglot.dialects.dialect import DialectType
10
11
  from sqlglot.errors import SchemaError
11
12
  from sqlglot.helper import find_new_name
12
13
  from sqlglot.optimizer.helpers import projection_has_aggregate
@@ -76,7 +77,7 @@ class ExpandContext:
76
77
  # One-to-many joins, keyed by alias, mapped to the alias of the join they fan out from.
77
78
  fanout_parents: dict[str, str] = field(default_factory=dict)
78
79
  aggregate_grains: dict[int, str] = field(default_factory=dict)
79
- dialect: str | None = None
80
+ dialect: DialectType = None
80
81
  # The root table's table function arguments, passed to every dataset its relationships reach.
81
82
  arguments: dict[str, exp.Expr] = field(default_factory=dict)
82
83
 
@@ -95,7 +96,7 @@ class ExpandDatasetsRule:
95
96
  message = f"{message}: {expr.sql(dialect=ctx.dialect)}"
96
97
  raise ValueError(message)
97
98
 
98
- def __call__(self, expression: Expr, cluster: Cluster, dialect: str | None = None) -> Expr:
99
+ def __call__(self, expression: Expr, cluster: Cluster, dialect: DialectType = None) -> Expr:
99
100
  for scope, select, table in table_scopes(expression):
100
101
  registry_and_dataset = cluster.get_dataset(table)
101
102
  if not registry_and_dataset:
@@ -274,7 +275,7 @@ class ExpandDatasetsRule:
274
275
  add_generated(alias)
275
276
  return ordered
276
277
 
277
- def _join_terms(self, expression: exp.Expr, dialect: str | None) -> set[exp.Expr]:
278
+ def _join_terms(self, expression: exp.Expr, dialect: DialectType) -> set[exp.Expr]:
278
279
  normalized = simplify(expression, dialect=dialect).unnest()
279
280
  if isinstance(normalized, exp.And):
280
281
  return set(normalized.flatten())
@@ -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` alone (see `metricflow_sql.server.compile`), with no warehouse.
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(routes=compile_routes(lambda request: compile_sql))
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 {"sql": "SELECT SUM(amount) ...", "tables": [["warehouse", "main", "sales"]]}
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
@@ -19,6 +25,7 @@ import logging
19
25
  from collections.abc import Awaitable, Callable
20
26
  from typing import Protocol
21
27
 
28
+ from sqlglot.dialects.dialect import Dialect
22
29
  from sqlglot.errors import SqlglotError
23
30
  from starlette.requests import Request
24
31
  from starlette.responses import JSONResponse
@@ -51,6 +58,12 @@ def compile_routes(compile_for: CompileFor) -> list[BaseRoute]:
51
58
  async def post_compile(request: Request) -> JSONResponse:
52
59
  compile_sql = compile_for(request)
53
60
  body = await request.json()
61
+ for key in ("input_dialect", "output_dialect"):
62
+ try:
63
+ # Checked as sqlglot reads it, then passed on as text.
64
+ Dialect.get_or_raise(body.get(key))
65
+ except ValueError as e:
66
+ return JSONResponse({"error": f"Invalid `{key}`: {e}"}, status_code=400)
54
67
  try:
55
68
  plan = await compile_sql(
56
69
  body["sql"],
@@ -64,6 +77,12 @@ def compile_routes(compile_for: CompileFor) -> list[BaseRoute]:
64
77
  return JSONResponse({"error": str(e)}, status_code=400)
65
78
  logger.info("Compiled:\n%s\n=>\n%s", body["sql"].strip(), plan.out_sql)
66
79
  assert plan.tables is not None, "compiled `with_tables`"
67
- return JSONResponse({"sql": plan.out_sql, "tables": [list(table) for table in plan.tables]})
80
+ return JSONResponse(
81
+ {
82
+ "sql": plan.out_sql,
83
+ "tables": [list(table) for table in plan.tables],
84
+ "explain": plan.explain,
85
+ }
86
+ )
68
87
 
69
88
  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, run by a process manager, e.g. dbt's.
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(
@@ -7,11 +7,12 @@ way.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import copy
10
11
  import logging
11
12
  from collections.abc import Iterable
12
13
 
13
14
  from sqlglot import exp
14
- from sqlglot.dialects.dialect import Dialect, NormalizationStrategy
15
+ from sqlglot.dialects.dialect import Dialect, DialectType, NormalizationStrategy
15
16
  from sqlglot.optimizer.normalize_identifiers import normalize_identifiers
16
17
  from sqlglot.optimizer.qualify_columns import quote_identifiers
17
18
 
@@ -25,9 +26,14 @@ def strategy_of(dialect: str | None) -> NormalizationStrategy:
25
26
  return Dialect.get_or_raise(dialect).normalization_strategy
26
27
 
27
28
 
28
- def normalizing_dialect(dialect: str | None, strategy: NormalizationStrategy) -> str:
29
- """`dialect`, reading names as `strategy` normalizes them, e.g. for sqlglot's `dialect=`."""
30
- return f"{dialect or ''}, normalization_strategy = {strategy.value.lower()}"
29
+ def normalizing_dialect(dialect: DialectType, strategy: NormalizationStrategy) -> Dialect:
30
+ """
31
+ `dialect`, reading names as `strategy` normalizes them, e.g. for sqlglot's `dialect=`. Its
32
+ other settings, e.g. its version, carry over; a `Dialect` given is copied, not changed.
33
+ """
34
+ normalizing = copy.copy(Dialect.get_or_raise(dialect))
35
+ normalizing.normalization_strategy = strategy
36
+ return normalizing
31
37
 
32
38
 
33
39
  def canonical[E: exp.Expr](
@@ -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
- expression = parse_one(text, dialect=dialect, into=into)
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