Skip to content

Commit ecc4784

Browse files
committed
fix(format): render warehouse-SQL header properties with the model dialect
#5864 stopped transpiling MODEL/AUDIT/METRIC headers so that SQLMesh's own boolean properties would survive formatting -- on tsql, `allow_partials TRUE` was being rewritten to `(1 = 1)`, which then fails to parse at all and leaves the model file broken. That fix rendered the entire header generically, including the properties whose values are the user's warehouse SQL. Those lose their dialect: `columns (ts DATETIME2(6))` becomes `TIMESTAMP(6)`, and an audit argument such as `CAST('2024-01-01' AS DATETIME2)` is silently downgraded the same way. Split the header per property instead of per expression. The split is derived from the field declarations themselves: expression-typed fields (columns, audits, signals, partitioned_by, physical_properties, ...) hold warehouse SQL and render with the model dialect, while scalar-typed fields (allow_partials, description, kind, ...) are SQLMesh's own semantics and stay dialect-agnostic. Deriving it means the policy stays correct as properties are added, and a field that is missed fails safe -- a keyword is not canonicalized, rather than a user's SQL being corrupted. Covers MODEL, AUDIT and METRIC headers, and the expression properties nested inside `kind` such as `time_data_type` and `unique_key`. Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent b0862ae commit ecc4784

2 files changed

Lines changed: 194 additions & 3 deletions

File tree

‎sqlmesh/core/dialect.py‎

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,52 @@ def parse(self: Parser) -> t.Optional[exp.Expr]:
734734
}
735735

736736

737+
_SQLMESH_META_DIALECT = "sqlmesh_meta_dialect"
738+
739+
740+
def _holds_expression(annotation: t.Any) -> bool:
741+
"""Whether a declared field type bottoms out in a SQLGlot expression.
742+
743+
Covers List[exp.Expr], Optional[Dict[str, exp.DataType]], Optional[exp.Tuple] and
744+
the nested Tuple[str, Dict[str, exp.Expr]] shape used by audits/signals.
745+
"""
746+
if isinstance(annotation, type) and issubclass(annotation, exp.Expr):
747+
return True
748+
return any(_holds_expression(arg) for arg in t.get_args(annotation))
749+
750+
751+
@functools.lru_cache(maxsize=1)
752+
def _meta_render_policy() -> t.Dict[str, bool]:
753+
"""Map header property name -> whether its value is warehouse SQL.
754+
755+
Derived from the field declarations themselves, so it stays correct as properties
756+
are added: expression-typed values (columns, audits, physical_properties, ...) are
757+
the user's warehouse SQL and must render in the model's dialect, while scalar-typed
758+
values (allow_partials, description, kind, ...) are SQLMesh's own semantics and must
759+
stay dialect-agnostic -- transpiling those is what corrupts `allow_partials TRUE`
760+
into tsql's unparseable `(1 = 1)`.
761+
"""
762+
import inspect
763+
764+
from sqlmesh.core.audit.definition import ModelAudit
765+
from sqlmesh.core.metric.definition import MetricMeta
766+
from sqlmesh.core.model import kind as kind_module
767+
from sqlmesh.core.model.meta import ModelMeta
768+
769+
sources: t.List[t.Any] = [ModelMeta, ModelAudit, MetricMeta]
770+
sources.extend(
771+
obj
772+
for name, obj in vars(kind_module).items()
773+
if inspect.isclass(obj) and hasattr(obj, "model_fields") and name.endswith("Kind")
774+
)
775+
776+
policy: t.Dict[str, bool] = {}
777+
for source in sources:
778+
for name, field in source.model_fields.items():
779+
policy.setdefault((field.alias or name).lower(), _holds_expression(field.annotation))
780+
return policy
781+
782+
737783
def _props_sql(self: Generator, expressions: t.List[exp.Expr]) -> str:
738784
props = []
739785
size = len(expressions)
@@ -742,7 +788,31 @@ def _props_sql(self: Generator, expressions: t.List[exp.Expr]) -> str:
742788
if isinstance(prop, MacroFunc):
743789
sql = self.indent(self.sql(prop, comment=False))
744790
else:
745-
sql = self.indent(f"{prop.name} {self.sql(prop, 'value')}")
791+
value = prop.args.get("value")
792+
parent = prop.parent
793+
meta_dialect = parent.meta.get(_SQLMESH_META_DIALECT) if parent else None
794+
795+
if (
796+
meta_dialect
797+
and isinstance(value, exp.Expr)
798+
and _meta_render_policy().get(prop.name.lower())
799+
):
800+
value_sql = value.sql(
801+
dialect=meta_dialect,
802+
pretty=self.pretty,
803+
identify=self.identify,
804+
normalize=self.normalize,
805+
pad=self.pad,
806+
indent=self._indent,
807+
normalize_functions=self.normalize_functions,
808+
leading_comma=self.leading_comma,
809+
max_text_width=self.max_text_width,
810+
comments=self.comments,
811+
)
812+
else:
813+
value_sql = self.sql(prop, "value")
814+
815+
sql = self.indent(f"{prop.name} {value_sql}")
746816

747817
if i < size - 1:
748818
sql += ","
@@ -853,11 +923,29 @@ def format_model_expressions(
853923
Returns:
854924
A string representing the formatted model.
855925
"""
926+
927+
def tag_meta_dialect(expression: exp.Expr) -> exp.Expr:
928+
"""Record the model dialect on meta nodes so `_props_sql` can render the
929+
warehouse-SQL properties (columns, audits, physical_properties, ...) with it
930+
while the SQLMesh-owned ones stay dialect-agnostic. Tags nested ModelKind
931+
nodes too, since kinds carry expression properties of their own such as
932+
`time_data_type` and `unique_key`."""
933+
if not dialect or not is_meta_expression(expression):
934+
return expression
935+
936+
expression = expression.copy()
937+
for node in expression.find_all(Model, Audit, Metric, ModelKind):
938+
node.meta[_SQLMESH_META_DIALECT] = dialect
939+
expression.meta[_SQLMESH_META_DIALECT] = dialect
940+
return expression
941+
856942
if len(expressions) == 1 and is_meta_expression(expressions[0]):
857943
# Meta expressions (MODEL/AUDIT/METRIC) are SQLMesh DDL, not standard SQL,
858944
# so they must never be transpiled to the target dialect (e.g. tsql would
859945
# rewrite a boolean property like `allow_partials TRUE` to `(1 = 1)`).
860-
return expressions[0].sql(
946+
# Individual properties whose values *are* warehouse SQL still render with
947+
# the model dialect -- see `_props_sql` / `_meta_render_policy`.
948+
return tag_meta_dialect(expressions[0]).sql(
861949
pretty=True, dialect=None, normalize_functions=normalize_functions
862950
)
863951

@@ -893,7 +981,7 @@ def cast_to_colon(node: exp.Expr) -> exp.Expr:
893981
return ";\n\n".join(
894982
# Meta expressions (MODEL/AUDIT/METRIC) are SQLMesh DDL and must stay
895983
# dialect-agnostic; only the actual query/statement expressions transpile.
896-
expression.sql(
984+
tag_meta_dialect(expression).sql(
897985
pretty=True,
898986
dialect=None if is_meta_expression(expression) else dialect,
899987
normalize_functions=normalize_functions,

‎tests/core/test_dialect.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,109 @@ def test_format_model_expressions():
342342
)
343343

344344

345+
@pytest.mark.parametrize(
346+
"dialect,audit_type,int_type",
347+
# These dialects spell the same types differently -- fabric renders a bare DATETIME2
348+
# with its default precision and keeps INT, tsql does the reverse. The point of the
349+
# test is that each keeps *its own* spelling rather than being flattened.
350+
[("tsql", "DATETIME2", "INTEGER"), ("fabric", "DATETIME2(6)", "INT")],
351+
)
352+
def test_format_model_expressions_meta_render_policy(dialect: str, audit_type: str, int_type: str):
353+
"""Header properties whose values are warehouse SQL render with the model dialect,
354+
while SQLMesh's own properties stay dialect-agnostic.
355+
356+
Rendering the whole header with the dialect corrupts SQLMesh DDL (tsql turns
357+
`allow_partials TRUE` into the unparseable `(1 = 1)`), but rendering all of it
358+
generically discards dialect-specific values the user authored, such as the
359+
`DATETIME2` types below. The split is derived from the field declarations, so it
360+
covers `columns`, `audits`, `physical_properties` and the expression properties
361+
nested inside `kind` alike.
362+
"""
363+
formatted = format_model_expressions(
364+
parse(
365+
f"""
366+
MODEL (
367+
name a.b,
368+
dialect {dialect},
369+
kind SCD_TYPE_2_BY_TIME (
370+
unique_key id,
371+
time_data_type DATETIME2(6)
372+
),
373+
allow_partials true,
374+
description 'my description',
375+
columns (
376+
ts DATETIME2(6)
377+
),
378+
audits (
379+
my_audit(threshold := CAST('2024-01-01' AS DATETIME2))
380+
),
381+
physical_properties (
382+
labels = (('env', 'prod'))
383+
)
384+
);
385+
386+
SELECT CAST(x AS INT) AS y FROM t
387+
"""
388+
),
389+
dialect=dialect,
390+
)
391+
392+
assert (
393+
formatted
394+
== f"""MODEL (
395+
name a.b,
396+
dialect {dialect},
397+
kind SCD_TYPE_2_BY_TIME (
398+
unique_key id,
399+
time_data_type DATETIME2(6)
400+
),
401+
allow_partials TRUE,
402+
description 'my description',
403+
columns (
404+
ts DATETIME2(6)
405+
),
406+
audits (
407+
my_audit(threshold := '2024-01-01'::{audit_type})
408+
),
409+
physical_properties (
410+
labels = (
411+
('env', 'prod')
412+
)
413+
)
414+
);
415+
416+
SELECT
417+
x::{int_type} AS y
418+
FROM t"""
419+
)
420+
421+
422+
def test_format_audit_expressions_meta_render_policy():
423+
"""AUDIT headers have their own meta model, and get the same split: `blocking` is
424+
SQLMesh's own boolean and must not become tsql's `(1 = 0)`, while `defaults` holds
425+
user expressions and keeps its dialect-specific type."""
426+
formatted = format_model_expressions(
427+
parse(
428+
"""
429+
AUDIT (
430+
name my_audit,
431+
dialect tsql,
432+
blocking false,
433+
defaults (
434+
cutoff := CAST('2024-01-01' AS DATETIME2)
435+
)
436+
);
437+
438+
SELECT * FROM t WHERE x > 0
439+
"""
440+
),
441+
dialect="tsql",
442+
)
443+
444+
assert "blocking FALSE" in formatted
445+
assert "cutoff := '2024-01-01'::DATETIME2" in formatted
446+
447+
345448
def test_format_model_expressions_normalize_functions():
346449
"""Regression: formatter function-name casing behavior.
347450

0 commit comments

Comments
 (0)