lexis-cli 0.4.2__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.
- lexis/__init__.py +10 -0
- lexis/_vendor/ossie/NOTICE.md +14 -0
- lexis/_vendor/ossie/__init__.py +44 -0
- lexis/_vendor/ossie/models.py +224 -0
- lexis/cli.py +298 -0
- lexis/demo_data.py +87 -0
- lexis/dispatch.py +66 -0
- lexis/mcp_server.py +158 -0
- lexis/parser.py +19 -0
- lexis/resolved_model.py +128 -0
- lexis/retail_demo_data.py +615 -0
- lexis/sml/__init__.py +10 -0
- lexis/sml/_common.py +224 -0
- lexis/sml/emit.py +403 -0
- lexis/sml/models.py +172 -0
- lexis/sml/parse.py +382 -0
- lexis/transpilers/base.py +11 -0
- lexis/transpilers/cube.py +119 -0
- lexis/transpilers/dbt_ossie.py +67 -0
- lexis/transpilers/mcp.py +182 -0
- lexis/transpilers/snowflake_semantic_view.py +126 -0
- lexis/transpilers/sql/__init__.py +22 -0
- lexis/transpilers/sql/base.py +172 -0
- lexis/transpilers/sql/bigquery.py +8 -0
- lexis/transpilers/sql/databricks.py +8 -0
- lexis/transpilers/sql/duckdb.py +8 -0
- lexis/transpilers/sql/postgres.py +10 -0
- lexis/transpilers/sql/snowflake.py +8 -0
- lexis_cli-0.4.2.dist-info/METADATA +883 -0
- lexis_cli-0.4.2.dist-info/RECORD +33 -0
- lexis_cli-0.4.2.dist-info/WHEEL +4 -0
- lexis_cli-0.4.2.dist-info/entry_points.txt +2 -0
- lexis_cli-0.4.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Package an OssieDocument for dbt-core 1.12+'s native Ossie ingestion.
|
|
2
|
+
|
|
3
|
+
dbt-core >=1.12 parses raw Ossie JSON files dropped in a project's `osi/` directory (or a
|
|
4
|
+
configured `osi-paths` — dbt kept this config key and default directory name lowercase
|
|
5
|
+
`osi` even after the upstream spec's Apache rename) directly into its manifest — no
|
|
6
|
+
Ossie->dbt-YAML conversion needed. Per dbt's docs
|
|
7
|
+
(docs.getdbt.com/docs/build/ossie-semantic-models, checked 2026-09-04), two hard
|
|
8
|
+
constraints apply that our own Ossie documents don't satisfy by default, so this module
|
|
9
|
+
adjusts/validates rather than passing the document through verbatim:
|
|
10
|
+
|
|
11
|
+
1. dbt only accepts `version: "0.1.0"` or `"0.1.1"` — any other version string is a
|
|
12
|
+
parse error. Our vendored OssieDocument defaults to `"0.2.0.dev0"` (the current core
|
|
13
|
+
spec draft version), so the emitted copy's version is overridden to `"0.1.1"`; the
|
|
14
|
+
document shape (datasets/fields/relationships/metrics) is otherwise unchanged.
|
|
15
|
+
2. Each dataset's `source` must be `database.schema.alias`, fully qualified to a dbt
|
|
16
|
+
model *in the target dbt project* (not a source/seed/snapshot/external table). We
|
|
17
|
+
can't verify this against a project we don't have, so we only check the shape
|
|
18
|
+
(three dot-separated parts) and surface a warning for anything that doesn't match.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from lexis._vendor.ossie import OssieDocument
|
|
25
|
+
from lexis.transpilers.base import Artifact
|
|
26
|
+
|
|
27
|
+
DBT_SUPPORTED_OSSIE_VERSIONS = ("0.1.0", "0.1.1")
|
|
28
|
+
DBT_EMIT_VERSION = "0.1.1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DbtOssieResult:
|
|
33
|
+
artifact: Artifact
|
|
34
|
+
warnings: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _source_shape_warnings(document: OssieDocument) -> list[str]:
|
|
38
|
+
warnings = []
|
|
39
|
+
for semantic_model in document.semantic_model:
|
|
40
|
+
for dataset in semantic_model.datasets:
|
|
41
|
+
parts = dataset.source.split(".")
|
|
42
|
+
if len(parts) != 3:
|
|
43
|
+
warnings.append(
|
|
44
|
+
f"dataset {dataset.name!r} source {dataset.source!r} is not "
|
|
45
|
+
"'database.schema.alias' — dbt requires it resolve to a dbt model "
|
|
46
|
+
"in the target project; this dataset will likely fail to parse "
|
|
47
|
+
"under dbt-core's Ossie ingestion until corrected."
|
|
48
|
+
)
|
|
49
|
+
return warnings
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def emit_dbt_ossie_document(document: OssieDocument, filename: str = "osi/lexis_model.json") -> DbtOssieResult:
|
|
53
|
+
"""Render `document` as a dbt-core-1.12+-compatible Ossie JSON artifact."""
|
|
54
|
+
data = document.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
55
|
+
data["version"] = DBT_EMIT_VERSION
|
|
56
|
+
|
|
57
|
+
warnings = list(_source_shape_warnings(document))
|
|
58
|
+
if document.version not in DBT_SUPPORTED_OSSIE_VERSIONS:
|
|
59
|
+
warnings.append(
|
|
60
|
+
f"source document version {document.version!r} is not dbt-supported "
|
|
61
|
+
f"({DBT_SUPPORTED_OSSIE_VERSIONS!r}); emitted copy overrides version to "
|
|
62
|
+
f"{DBT_EMIT_VERSION!r} — verify the document's constructs are still valid "
|
|
63
|
+
"under the 0.1.x Ossie schema before relying on this in dbt."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
content = json.dumps(data, indent=2) + "\n"
|
|
67
|
+
return DbtOssieResult(artifact=Artifact(filename=filename, content=content), warnings=warnings)
|
lexis/transpilers/mcp.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Ossie -> MCP tool manifest / LLM function-calling schema emitter.
|
|
2
|
+
|
|
3
|
+
This is Lexis's differentiator: turn a metric's `ai_context` (instructions,
|
|
4
|
+
synonyms, examples) into a grounded tool description, and constrain `group_by` to an
|
|
5
|
+
explicit enum of real dataset.field refs — so an agent gets a governed query tool
|
|
6
|
+
instead of having to guess joins/columns/synonyms from a bare warehouse schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from lexis._vendor.ossie import OssieAIContextObject, OssieDataType, OssieDialect
|
|
12
|
+
from lexis.resolved_model import ResolvedModel
|
|
13
|
+
from lexis.transpilers.sql.base import SqlDialectEmitter
|
|
14
|
+
|
|
15
|
+
#: Grains a `query_<metric>` tool call may bucket by (see `time_grain` below).
|
|
16
|
+
#: Kept in sync with `SqlDialectEmitter.TIME_GRAINS`, ordered fine -> coarse for the
|
|
17
|
+
#: tool schema (an agent picking "the smallest useful bucket" reads top-down).
|
|
18
|
+
TIME_GRAINS = ["day", "week", "month", "quarter", "year"]
|
|
19
|
+
assert set(TIME_GRAINS) == set(SqlDialectEmitter.TIME_GRAINS)
|
|
20
|
+
|
|
21
|
+
_DATE_DATATYPES = frozenset(
|
|
22
|
+
{OssieDataType.DATE, OssieDataType.DATE_TIME, OssieDataType.DATE_TIME_TZ}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def time_axis_refs(model: ResolvedModel) -> list[str]:
|
|
27
|
+
"""`dataset.field` refs usable as the time axis of a `DATE_TRUNC`-bucketed
|
|
28
|
+
query: fields whose Ossie `datatype` is a date/timestamp. Falls back to any
|
|
29
|
+
`is_time` dimension when the model declares no temporal datatypes at all, so
|
|
30
|
+
hand-written models that skip `datatype` still get weekly/monthly grains."""
|
|
31
|
+
dated: list[str] = []
|
|
32
|
+
is_time: list[str] = []
|
|
33
|
+
for dataset_name, dataset in model.datasets.items():
|
|
34
|
+
for f in dataset.fields or []:
|
|
35
|
+
ref = f"{dataset_name}.{f.name}"
|
|
36
|
+
if f.datatype in _DATE_DATATYPES:
|
|
37
|
+
dated.append(ref)
|
|
38
|
+
elif f.is_time_dimension():
|
|
39
|
+
is_time.append(ref)
|
|
40
|
+
return dated or is_time
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_time_axis(model: ResolvedModel, time_field: str | None) -> tuple[str, str]:
|
|
44
|
+
"""`(time_dataset, time_field)` for a `time_grain` query. Uses `time_field` when
|
|
45
|
+
given (validated against `time_axis_refs`), otherwise the model's sole time axis.
|
|
46
|
+
Raises `ValueError` (a clean MCP tool error) when there's no time axis, more than
|
|
47
|
+
one and none was chosen, or an unknown one was passed."""
|
|
48
|
+
refs = time_axis_refs(model)
|
|
49
|
+
if not refs:
|
|
50
|
+
raise ValueError("this model has no date field to bucket by, so time_grain is not supported")
|
|
51
|
+
if time_field is None:
|
|
52
|
+
if len(refs) > 1:
|
|
53
|
+
raise ValueError(f"time_field is required when time_grain is set; choose one of {refs}")
|
|
54
|
+
time_field = refs[0]
|
|
55
|
+
elif time_field not in refs:
|
|
56
|
+
raise ValueError(f"unknown time_field {time_field!r}; choose one of {refs}")
|
|
57
|
+
dataset, _, column = time_field.partition(".")
|
|
58
|
+
return dataset, column
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def model_instructions(model: ResolvedModel) -> str:
|
|
62
|
+
"""The model-level `ai_context` text (instructions / synonyms / examples), if any -
|
|
63
|
+
surfaced to MCP clients as the server's `instructions` and in `list_metrics`, so a
|
|
64
|
+
model can e.g. tell an agent its weeks are Sunday-Saturday retail weeks rather than
|
|
65
|
+
ISO Monday weeks. Returns "" when the model has no `ai_context` (the model
|
|
66
|
+
`description` is carried separately, by `list_models`)."""
|
|
67
|
+
return _describe_ai_context(None, model.semantic_model.ai_context)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _describe_ai_context(base_description: str | None, ai_context) -> str:
|
|
71
|
+
parts = [base_description] if base_description else []
|
|
72
|
+
if ai_context is None:
|
|
73
|
+
pass
|
|
74
|
+
elif isinstance(ai_context, str):
|
|
75
|
+
parts.append(ai_context)
|
|
76
|
+
elif isinstance(ai_context, OssieAIContextObject):
|
|
77
|
+
if ai_context.instructions:
|
|
78
|
+
parts.append(ai_context.instructions)
|
|
79
|
+
if ai_context.synonyms:
|
|
80
|
+
parts.append("Also known as: " + ", ".join(ai_context.synonyms) + ".")
|
|
81
|
+
if ai_context.examples:
|
|
82
|
+
parts.append("Example questions: " + " | ".join(ai_context.examples))
|
|
83
|
+
return " ".join(p.strip() for p in parts if p and p.strip())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dimension_refs(model: ResolvedModel) -> list[str]:
|
|
87
|
+
refs = []
|
|
88
|
+
for dataset_name, dataset in model.datasets.items():
|
|
89
|
+
for f in dataset.fields or []:
|
|
90
|
+
refs.append(f"{dataset_name}.{f.name}")
|
|
91
|
+
return refs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _time_bucketing_properties(time_refs: list[str]) -> dict:
|
|
95
|
+
"""The `time_grain` / `time_field` input-schema properties, added to every
|
|
96
|
+
`query_<metric>` tool when the model has at least one usable time axis."""
|
|
97
|
+
default_hint = (
|
|
98
|
+
f" Defaults to {time_refs[0]!r}." if len(time_refs) == 1
|
|
99
|
+
else " Required when time_grain is set (the model has more than one time field)."
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
"time_grain": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"enum": TIME_GRAINS,
|
|
105
|
+
"description": (
|
|
106
|
+
"Return one row per consecutive time period instead of a single total - "
|
|
107
|
+
"e.g. 'week' for a week-by-week trend. Periods are ISO 8601 calendar "
|
|
108
|
+
"periods and weeks start Monday; if this model's instructions define a "
|
|
109
|
+
"fiscal or retail week, label the results accordingly. Cannot be combined "
|
|
110
|
+
"with group_by."
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
"time_field": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"enum": time_refs,
|
|
116
|
+
"description": "Which date field to bucket by when time_grain is set." + default_hint,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_metric_tool_specs(model: ResolvedModel) -> list[dict]:
|
|
122
|
+
"""One `{"name", "description", "inputSchema"}` dict per Ossie metric — the bare MCP
|
|
123
|
+
tool schema, shared by the static manifest below and the live MCP server
|
|
124
|
+
(`lexis.mcp_server`), which additionally needs plain schema dicts it can turn
|
|
125
|
+
into `mcp.types.Tool` objects (no extra `_lexis`-style fields)."""
|
|
126
|
+
dimension_refs = _dimension_refs(model)
|
|
127
|
+
time_refs = time_axis_refs(model)
|
|
128
|
+
time_properties = _time_bucketing_properties(time_refs) if time_refs else {}
|
|
129
|
+
specs = []
|
|
130
|
+
|
|
131
|
+
for metric in model.metrics.values():
|
|
132
|
+
description = _describe_ai_context(metric.description, metric.ai_context)
|
|
133
|
+
specs.append(
|
|
134
|
+
{
|
|
135
|
+
"name": f"query_{metric.name}",
|
|
136
|
+
"description": description or f"Query the {metric.name!r} metric.",
|
|
137
|
+
"inputSchema": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"group_by": {
|
|
141
|
+
"type": "array",
|
|
142
|
+
"items": {"type": "string", "enum": dimension_refs},
|
|
143
|
+
"description": (
|
|
144
|
+
"Zero or more dataset.field references to group results by."
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
**time_properties,
|
|
148
|
+
},
|
|
149
|
+
"additionalProperties": False,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return specs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_mcp_tool_manifest(model: ResolvedModel) -> dict:
|
|
158
|
+
"""Build an MCP-style `{"tools": [...]}` manifest, one tool per Ossie metric."""
|
|
159
|
+
tools = []
|
|
160
|
+
|
|
161
|
+
for metric, spec in zip(model.metrics.values(), build_metric_tool_specs(model), strict=True):
|
|
162
|
+
try:
|
|
163
|
+
expr = model.resolve_expression(metric.expression, OssieDialect.ANSI_SQL)
|
|
164
|
+
except Exception:
|
|
165
|
+
expr = None
|
|
166
|
+
|
|
167
|
+
tools.append({**spec, "_lexis": {"metric": metric.name, "expression": expr}})
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"model": {
|
|
171
|
+
"name": model.semantic_model.name,
|
|
172
|
+
"description": _describe_ai_context(
|
|
173
|
+
model.semantic_model.description, model.semantic_model.ai_context
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
"tools": tools,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def emit_mcp_tool_manifest(model: ResolvedModel) -> str:
|
|
181
|
+
"""Render the MCP tool manifest as JSON text."""
|
|
182
|
+
return json.dumps(build_mcp_tool_manifest(model), indent=2) + "\n"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Ossie -> Snowflake `CREATE SEMANTIC VIEW` DDL emitter.
|
|
2
|
+
|
|
3
|
+
Snowflake's native semantic view (Cortex Analyst) DDL groups a model into five
|
|
4
|
+
clauses - TABLES, RELATIONSHIPS, FACTS, DIMENSIONS, METRICS - which line up
|
|
5
|
+
closely with Ossie's own datasets/relationships/fields/metrics:
|
|
6
|
+
|
|
7
|
+
- Each OssieDataset becomes a TABLES entry (`<dataset> AS <source>`), carrying
|
|
8
|
+
over PRIMARY KEY, WITH SYNONYMS (from ai_context), and COMMENT (description).
|
|
9
|
+
- Each OssieRelationship becomes a RELATIONSHIPS entry using Snowflake's
|
|
10
|
+
`<alias> AS <from>(<cols>) REFERENCES <to>(<cols>)` shape.
|
|
11
|
+
- Ossie fields split into FACTS vs DIMENSIONS the same way the bundled TPC-DS
|
|
12
|
+
fixture models it: a field carrying a `dimension` block is a grouping/
|
|
13
|
+
filtering attribute (DIMENSIONS); a field with no `dimension` block is
|
|
14
|
+
treated as a row-level numeric fact (FACTS), since Snowflake facts are the
|
|
15
|
+
row-level building blocks aggregate METRICS are written against.
|
|
16
|
+
- Ossie semantic-model-level metrics become METRICS entries. Snowflake requires
|
|
17
|
+
each metric to be qualified by a single table alias, so - as with the Cube
|
|
18
|
+
emitter - a metric expression is attached to the first dataset it
|
|
19
|
+
references (falling back to an arbitrary dataset if it references none).
|
|
20
|
+
|
|
21
|
+
Expressions prefer the SNOWFLAKE dialect where the source model provides one,
|
|
22
|
+
falling back to ANSI_SQL like every other target (`ResolvedModel.resolve_expression`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from lexis._vendor.ossie import OssieAIContext, OssieAIContextObject, OssieDialect
|
|
26
|
+
from lexis.resolved_model import ResolvedModel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _quote_literal(text: str) -> str:
|
|
30
|
+
return text.replace("'", "''")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _synonyms_clause(ai_context: OssieAIContext | None) -> str:
|
|
34
|
+
if isinstance(ai_context, OssieAIContextObject) and ai_context.synonyms:
|
|
35
|
+
rendered = ", ".join(f"'{_quote_literal(s)}'" for s in ai_context.synonyms)
|
|
36
|
+
return f" WITH SYNONYMS ({rendered})"
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _comment_clause(text: str | None) -> str:
|
|
41
|
+
return f" COMMENT = '{_quote_literal(text)}'" if text else ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _table_entries(model: ResolvedModel) -> list[str]:
|
|
45
|
+
entries = []
|
|
46
|
+
for name, dataset in model.datasets.items():
|
|
47
|
+
entry = f"{name} AS {dataset.source}"
|
|
48
|
+
if dataset.primary_key:
|
|
49
|
+
entry += f" PRIMARY KEY ({', '.join(dataset.primary_key)})"
|
|
50
|
+
entry += _synonyms_clause(dataset.ai_context)
|
|
51
|
+
entry += _comment_clause(dataset.description)
|
|
52
|
+
entries.append(entry)
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _relationship_entries(model: ResolvedModel) -> list[str]:
|
|
57
|
+
entries = []
|
|
58
|
+
for rel in model.relationships:
|
|
59
|
+
from_cols = ", ".join(rel.from_columns)
|
|
60
|
+
to_cols = ", ".join(rel.to_columns)
|
|
61
|
+
entries.append(
|
|
62
|
+
f"{rel.name} AS {rel.from_dataset}({from_cols}) REFERENCES {rel.to}({to_cols})"
|
|
63
|
+
)
|
|
64
|
+
return entries
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _field_entries(model: ResolvedModel, *, dimensions: bool) -> list[str]:
|
|
68
|
+
entries = []
|
|
69
|
+
for dataset_name, dataset in model.datasets.items():
|
|
70
|
+
for f in dataset.fields or []:
|
|
71
|
+
if (f.dimension is not None) != dimensions:
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
expr = model.resolve_expression(f.expression, OssieDialect.SNOWFLAKE)
|
|
75
|
+
except Exception:
|
|
76
|
+
continue
|
|
77
|
+
entry = f"{dataset_name}.{f.name} AS {expr}"
|
|
78
|
+
entry += _synonyms_clause(f.ai_context)
|
|
79
|
+
entry += _comment_clause(f.description)
|
|
80
|
+
entries.append(entry)
|
|
81
|
+
return entries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _metric_entries(model: ResolvedModel) -> list[str]:
|
|
85
|
+
entries = []
|
|
86
|
+
for metric in model.metrics.values():
|
|
87
|
+
expr = model.resolve_expression(metric.expression, OssieDialect.SNOWFLAKE)
|
|
88
|
+
referenced = model.referenced_datasets(expr)
|
|
89
|
+
dataset_name = referenced[0] if referenced else next(iter(model.datasets))
|
|
90
|
+
entry = f"{dataset_name}.{metric.name} AS {expr}"
|
|
91
|
+
entry += _synonyms_clause(metric.ai_context)
|
|
92
|
+
entry += _comment_clause(metric.description)
|
|
93
|
+
entries.append(entry)
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _clause(keyword: str, entries: list[str]) -> str:
|
|
98
|
+
body = ",\n".join(f" {entry}" for entry in entries)
|
|
99
|
+
return f" {keyword} (\n{body}\n )"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def emit_snowflake_semantic_view(model: ResolvedModel) -> str:
|
|
103
|
+
"""Render `model` as a Snowflake `CREATE SEMANTIC VIEW` DDL statement."""
|
|
104
|
+
clauses = [_clause("TABLES", _table_entries(model))]
|
|
105
|
+
|
|
106
|
+
relationships = _relationship_entries(model)
|
|
107
|
+
if relationships:
|
|
108
|
+
clauses.append(_clause("RELATIONSHIPS", relationships))
|
|
109
|
+
|
|
110
|
+
facts = _field_entries(model, dimensions=False)
|
|
111
|
+
if facts:
|
|
112
|
+
clauses.append(_clause("FACTS", facts))
|
|
113
|
+
|
|
114
|
+
dims = _field_entries(model, dimensions=True)
|
|
115
|
+
if dims:
|
|
116
|
+
clauses.append(_clause("DIMENSIONS", dims))
|
|
117
|
+
|
|
118
|
+
metrics = _metric_entries(model)
|
|
119
|
+
if metrics:
|
|
120
|
+
clauses.append(_clause("METRICS", metrics))
|
|
121
|
+
|
|
122
|
+
lines = [f"CREATE OR REPLACE SEMANTIC VIEW {model.semantic_model.name}", *clauses]
|
|
123
|
+
comment = _comment_clause(model.semantic_model.description).strip()
|
|
124
|
+
if comment:
|
|
125
|
+
lines.append(f" {comment}")
|
|
126
|
+
return "\n".join(lines) + ";\n"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .bigquery import BigQueryEmitter
|
|
2
|
+
from .databricks import DatabricksEmitter
|
|
3
|
+
from .duckdb import DuckDBEmitter
|
|
4
|
+
from .postgres import PostgresEmitter
|
|
5
|
+
from .snowflake import SnowflakeEmitter
|
|
6
|
+
|
|
7
|
+
EMITTERS = {
|
|
8
|
+
"duckdb": DuckDBEmitter,
|
|
9
|
+
"postgres": PostgresEmitter,
|
|
10
|
+
"bigquery": BigQueryEmitter,
|
|
11
|
+
"databricks": DatabricksEmitter,
|
|
12
|
+
"snowflake": SnowflakeEmitter,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BigQueryEmitter",
|
|
17
|
+
"DatabricksEmitter",
|
|
18
|
+
"DuckDBEmitter",
|
|
19
|
+
"PostgresEmitter",
|
|
20
|
+
"SnowflakeEmitter",
|
|
21
|
+
"EMITTERS",
|
|
22
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared SQL-dialect emitter: builds a metric query from a ResolvedModel.
|
|
2
|
+
|
|
3
|
+
Per-dialect subclasses only need to set `dialect` and `quote_char` — the join-graph
|
|
4
|
+
resolution, dialect-expression fallback, and SELECT/FROM/JOIN/GROUP BY assembly are
|
|
5
|
+
identical across warehouses (per Ossie's converters/index.md mapping guidance).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from lexis._vendor.ossie import OssieDialect
|
|
11
|
+
from lexis.resolved_model import ResolvedModel
|
|
12
|
+
|
|
13
|
+
_SIMPLE_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
14
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SqlDialectEmitter:
|
|
18
|
+
dialect: OssieDialect
|
|
19
|
+
quote_char: str = '"'
|
|
20
|
+
|
|
21
|
+
#: Grains supported by `emit_timeseries_query`, coarsest first. `week` is an
|
|
22
|
+
#: ISO 8601 week (Monday start) - what `DATE_TRUNC('week', ...)` returns natively
|
|
23
|
+
#: on DuckDB / Postgres / Snowflake (default `WEEK_START`) / Spark. A model whose
|
|
24
|
+
#: business calendar uses a different week (e.g. US retail Sunday-Saturday) should
|
|
25
|
+
#: say so in its `ai_context` so consumers interpret weekly rows accordingly.
|
|
26
|
+
TIME_GRAINS = ("year", "quarter", "month", "week", "day")
|
|
27
|
+
|
|
28
|
+
def quote(self, identifier: str) -> str:
|
|
29
|
+
return f"{self.quote_char}{identifier}{self.quote_char}"
|
|
30
|
+
|
|
31
|
+
def _resolve_field_expr(self, model: ResolvedModel, dataset_name: str, field_name: str) -> str:
|
|
32
|
+
dataset = model.datasets[dataset_name]
|
|
33
|
+
matching = [f for f in (dataset.fields or []) if f.name == field_name]
|
|
34
|
+
if not matching:
|
|
35
|
+
raise ValueError(f"Unknown field {dataset_name}.{field_name!r}")
|
|
36
|
+
field_expr = model.resolve_expression(matching[0].expression, self.dialect)
|
|
37
|
+
# Field expressions (unlike metrics) are unqualified column refs/scalar
|
|
38
|
+
# expressions scoped to their own dataset. Qualify the common case (a bare
|
|
39
|
+
# column name) with the dataset alias; multi-column scalar expressions
|
|
40
|
+
# (e.g. `first_name || ' ' || last_name`) are left as-is and rely on SQL's
|
|
41
|
+
# automatic unambiguous-column resolution across the joined tables.
|
|
42
|
+
if _SIMPLE_IDENTIFIER_RE.match(field_expr):
|
|
43
|
+
field_expr = f"{self.quote(dataset_name)}.{field_expr}"
|
|
44
|
+
return field_expr
|
|
45
|
+
|
|
46
|
+
def _from_and_joins(self, model: ResolvedModel, all_datasets: list[str]) -> list[str]:
|
|
47
|
+
"""FROM + JOIN lines connecting `all_datasets` (first element is the base
|
|
48
|
+
table); shared by `emit_metric_query` and `emit_timeseries_query` so the
|
|
49
|
+
join-path traversal/direction logic only lives in one place."""
|
|
50
|
+
joins = model.join_path(all_datasets)
|
|
51
|
+
base_name = all_datasets[0]
|
|
52
|
+
base_dataset = model.datasets[base_name]
|
|
53
|
+
lines = [f"FROM {base_dataset.source} AS {self.quote(base_name)}"]
|
|
54
|
+
|
|
55
|
+
joined = {base_name}
|
|
56
|
+
for rel in joins:
|
|
57
|
+
if rel.from_dataset in joined and rel.to not in joined:
|
|
58
|
+
left, right, left_cols, right_cols = (
|
|
59
|
+
rel.from_dataset,
|
|
60
|
+
rel.to,
|
|
61
|
+
rel.from_columns,
|
|
62
|
+
rel.to_columns,
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
left, right, left_cols, right_cols = (
|
|
66
|
+
rel.to,
|
|
67
|
+
rel.from_dataset,
|
|
68
|
+
rel.to_columns,
|
|
69
|
+
rel.from_columns,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
right_dataset = model.datasets[right]
|
|
73
|
+
on_clause = " AND ".join(
|
|
74
|
+
f"{self.quote(left)}.{lc} = {self.quote(right)}.{rc}"
|
|
75
|
+
for lc, rc in zip(left_cols, right_cols)
|
|
76
|
+
)
|
|
77
|
+
lines.append(
|
|
78
|
+
f"JOIN {right_dataset.source} AS {self.quote(right)} ON {on_clause}"
|
|
79
|
+
)
|
|
80
|
+
joined.add(right)
|
|
81
|
+
|
|
82
|
+
return lines
|
|
83
|
+
|
|
84
|
+
def emit_metric_query(
|
|
85
|
+
self,
|
|
86
|
+
model: ResolvedModel,
|
|
87
|
+
metric_name: str,
|
|
88
|
+
group_by: list[str] | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Render a runnable SELECT for `metric_name`, optionally grouped by field refs.
|
|
91
|
+
|
|
92
|
+
`group_by` entries are `"dataset.field"` references, e.g. `"item.i_category"`.
|
|
93
|
+
"""
|
|
94
|
+
metric = model.metrics[metric_name]
|
|
95
|
+
metric_expr = model.resolve_expression(metric.expression, self.dialect)
|
|
96
|
+
|
|
97
|
+
group_exprs: list[str] = []
|
|
98
|
+
group_datasets: list[str] = []
|
|
99
|
+
for ref in group_by or []:
|
|
100
|
+
dataset_name, field_name = ref.split(".", 1)
|
|
101
|
+
field_expr = self._resolve_field_expr(model, dataset_name, field_name)
|
|
102
|
+
group_exprs.append(f"{field_expr} AS {self.quote(field_name)}")
|
|
103
|
+
group_datasets.append(dataset_name)
|
|
104
|
+
|
|
105
|
+
referenced = model.referenced_datasets(metric_expr)
|
|
106
|
+
all_datasets = list(dict.fromkeys(referenced + group_datasets))
|
|
107
|
+
if not all_datasets:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Metric {metric_name!r} expression references no known dataset"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
select_list = [*group_exprs, f"{metric_expr} AS {self.quote(metric.name)}"]
|
|
113
|
+
lines = [
|
|
114
|
+
f"SELECT {', '.join(select_list)}",
|
|
115
|
+
*self._from_and_joins(model, all_datasets),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if group_exprs:
|
|
119
|
+
positions = ", ".join(str(i + 1) for i in range(len(group_exprs)))
|
|
120
|
+
lines.append(f"GROUP BY {positions}")
|
|
121
|
+
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
def emit_timeseries_query(
|
|
125
|
+
self,
|
|
126
|
+
model: ResolvedModel,
|
|
127
|
+
metric_name: str,
|
|
128
|
+
time_dataset: str,
|
|
129
|
+
time_field: str,
|
|
130
|
+
grain: str,
|
|
131
|
+
filter_grain: str | None = None,
|
|
132
|
+
filter_value: str | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Render a SELECT grouping `metric_name` by `DATE_TRUNC(grain, time_field)`,
|
|
135
|
+
optionally restricted to one coarser period (`filter_grain`/`filter_value`,
|
|
136
|
+
an ISO `YYYY-MM-DD` date) - the drill-down case, where `filter_value` is the
|
|
137
|
+
period boundary of the row the caller drilled into.
|
|
138
|
+
|
|
139
|
+
Only meaningful for dialects that share Postgres-style
|
|
140
|
+
`DATE_TRUNC('unit', expr)` syntax (DuckDB, Postgres, Databricks, Snowflake) -
|
|
141
|
+
not BigQuery, which takes `DATE_TRUNC(expr, UNIT)` with no quoting.
|
|
142
|
+
`grain`/`filter_grain` are validated against `TIME_GRAINS` and `filter_value`
|
|
143
|
+
against a strict date-shaped regex before being embedded, since they're
|
|
144
|
+
interpolated directly into the returned SQL text rather than bound as
|
|
145
|
+
parameters (this method returns a fully-formed, displayable query, matching
|
|
146
|
+
`emit_metric_query`'s contract).
|
|
147
|
+
"""
|
|
148
|
+
if grain not in self.TIME_GRAINS:
|
|
149
|
+
raise ValueError(f"Unsupported time grain: {grain!r}")
|
|
150
|
+
|
|
151
|
+
metric = model.metrics[metric_name]
|
|
152
|
+
metric_expr = model.resolve_expression(metric.expression, self.dialect)
|
|
153
|
+
time_expr = self._resolve_field_expr(model, time_dataset, time_field)
|
|
154
|
+
|
|
155
|
+
all_datasets = list(dict.fromkeys([*model.referenced_datasets(metric_expr), time_dataset]))
|
|
156
|
+
|
|
157
|
+
period_expr = f"DATE_TRUNC('{grain}', {time_expr})"
|
|
158
|
+
lines = [
|
|
159
|
+
f"SELECT {period_expr} AS {self.quote('period')}, {metric_expr} AS {self.quote(metric.name)}",
|
|
160
|
+
*self._from_and_joins(model, all_datasets),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
if filter_grain is not None:
|
|
164
|
+
if filter_grain not in self.TIME_GRAINS:
|
|
165
|
+
raise ValueError(f"Unsupported time grain: {filter_grain!r}")
|
|
166
|
+
if not filter_value or not _ISO_DATE_RE.match(filter_value):
|
|
167
|
+
raise ValueError(f"filter_value must be an ISO date (YYYY-MM-DD): {filter_value!r}")
|
|
168
|
+
lines.append(f"WHERE DATE_TRUNC('{filter_grain}', {time_expr}) = DATE '{filter_value}'")
|
|
169
|
+
|
|
170
|
+
lines.append("GROUP BY 1")
|
|
171
|
+
lines.append("ORDER BY 1")
|
|
172
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from lexis._vendor.ossie import OssieDialect
|
|
2
|
+
|
|
3
|
+
from .base import SqlDialectEmitter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PostgresEmitter(SqlDialectEmitter):
|
|
7
|
+
# Ossie defines no dedicated POSTGRES dialect; Postgres is ANSI_SQL-compliant enough
|
|
8
|
+
# that the ANSI_SQL expression text is used directly.
|
|
9
|
+
dialect = OssieDialect.ANSI_SQL
|
|
10
|
+
quote_char = '"'
|