Skip to content

Commit 68da7f4

Browse files
committed
fix(format): keep boolean literals stable in dialect-rendered header values
On tsql, a boolean inside a header value rendered with the model dialect, such as `audits (my_audit(flag := true))`, was written as `(1 = 1)` and then wrapped across lines on the next run, so `sqlmesh format --check` flagged an already-formatted model. Keep boolean literals as `TRUE`/`FALSE` there, as before, since the value is transpiled with the model dialect when it is used. Also add a regression test that repeated formatting keeps a model's declared column types (BigQuery `DATETIME`, tsql `DATETIME2`), and merge the `time_column` kind test into the equivalent scalar-sibling test. Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent f7de34b commit 68da7f4

2 files changed

Lines changed: 45 additions & 50 deletions

File tree

‎sqlmesh/core/dialect.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,19 @@ def render_with_model_dialect(node: exp.Expr, **overrides: t.Any) -> str:
864864
"comments": self.comments,
865865
}
866866
opts.update(overrides)
867-
return node.sql(**opts)
867+
868+
# Keep boolean literals anywhere in the value (audit args, physical_properties,
869+
# merge_filter, ...) as `TRUE`/`FALSE`: tsql would otherwise emit `(1 = 1)`,
870+
# which reformats differently on the next pass. The value is transpiled with
871+
# the model dialect anyway when it is used, e.g. in the rendered audit query.
872+
def keep_boolean_literal(n: exp.Expr) -> exp.Expr:
873+
if not isinstance(n, exp.Boolean):
874+
return n
875+
literal = exp.var("TRUE" if n.this else "FALSE")
876+
literal.comments = n.comments
877+
return literal
878+
879+
return node.transform(keep_boolean_literal).sql(**opts)
868880

869881
if isinstance(prop, MacroFunc):
870882
# A macro in property position wraps user-authored arguments, so it carries

‎tests/core/test_dialect.py‎

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -422,8 +422,8 @@ def test_format_model_expressions_meta_render_policy(dialect: str, audit_type: s
422422
@pytest.mark.parametrize(
423423
"header",
424424
[
425-
"columns (ts DATETIME2(6))",
426425
"audits (my_audit(t := CAST('2024-01-01' AS DATETIME2)))",
426+
"audits (my_audit(flag := true))",
427427
"kind SCD_TYPE_2_BY_COLUMN(unique_key id, columns (a, b), time_data_type DATETIME2(6))",
428428
"physical_properties (labels = (('env', 'prod')))",
429429
"allow_partials true, description 'my description'",
@@ -447,6 +447,28 @@ def test_format_model_expressions_is_idempotent(header: str):
447447
assert once == twice
448448

449449

450+
@pytest.mark.parametrize(
451+
"dialect,column_type",
452+
[("bigquery", "DATETIME"), ("tsql", "DATETIME2(6)")],
453+
)
454+
def test_format_model_expressions_preserves_column_types(dialect: str, column_type: str):
455+
"""Repeated `sqlmesh format` runs must not change a model's declared column types.
456+
457+
Rendering `columns` with the generic generator rewrote them: BigQuery `DATETIME`
458+
became `TIMESTAMP` and then `TIMESTAMPTZ`, tsql `DATETIME2` became `TIMESTAMP` and
459+
then `VARBINARY`.
460+
"""
461+
expected = exp.DataType.build(column_type, dialect=dialect)
462+
formatted = f"MODEL (name a.b, dialect {dialect}, columns (ts {column_type}));\nSELECT 1 AS ts"
463+
464+
for _ in range(2):
465+
formatted = format_model_expressions(
466+
parse(formatted, default_dialect=dialect), dialect=dialect
467+
)
468+
model = load_sql_based_model(parse(formatted, default_dialect=dialect), dialect=dialect)
469+
assert model.columns_to_types == {"ts": expected}
470+
471+
450472
def test_format_audit_expressions_meta_render_policy():
451473
"""AUDIT headers have their own meta model, and get the same split: `blocking` is
452474
SQLMesh's own boolean and must not become tsql's `(1 = 0)`, while `defaults` holds
@@ -473,60 +495,21 @@ def test_format_audit_expressions_meta_render_policy():
473495
assert "cutoff := '2024-01-01'::DATETIME2" in formatted
474496

475497

476-
def test_format_model_expressions_time_column_dialect():
477-
"""`time_column` is a nested Pydantic model (`TimeColumn`) wrapping an expression, not
498+
def test_format_model_expressions_kind_time_column_dialect():
499+
"""Expression-bearing properties nested inside `kind` render with the model dialect,
500+
while their scalar siblings stay dialect-agnostic.
501+
502+
`time_column` is a nested Pydantic model (`TimeColumn`) wrapping an expression, not
478503
an `exp.Expr` annotation itself, so the render-policy reflection must recurse into
479504
nested Pydantic models to classify it as warehouse SQL. Otherwise it falls back to
480505
generic rendering and loses dialect-specific identifier quoting: tsql's `[end]`
481506
becomes ANSI `"end"`, even though the same identifier in the query body is correctly
482507
kept as `[end]`.
483-
"""
484-
formatted = format_model_expressions(
485-
parse(
486-
"""
487-
MODEL (
488-
name a.b,
489-
dialect tsql,
490-
kind INCREMENTAL_BY_TIME_RANGE (
491-
time_column [end]
492-
)
493-
);
494-
495-
SELECT 1 AS x, [end] FROM t
496-
""",
497-
default_dialect="tsql",
498-
),
499-
dialect="tsql",
500-
)
501-
502-
assert (
503-
formatted
504-
== """MODEL (
505-
name a.b,
506-
dialect tsql,
507-
kind INCREMENTAL_BY_TIME_RANGE (
508-
time_column [end]
509-
)
510-
);
511-
512-
SELECT
513-
1 AS x,
514-
[end]
515-
FROM t"""
516-
)
517-
518-
519-
def test_format_model_expressions_kind_scalar_sibling_dialect():
520-
"""A scalar sibling property of an expression-bearing property inside `kind` (e.g.
521-
`forward_only` next to `time_column`) must stay dialect-agnostic even though the
522-
render policy correctly marks `kind` as containing an expression-holding field
523-
somewhere in the `ModelKind` union.
524508
525-
Regression: recursing into nested Pydantic models to fix `time_column` (see
526-
`test_format_model_expressions_time_column_dialect`) made `_holds_expression` also
527-
match on `kind` itself, since *some* member of the `ModelKind` union
528-
(`IncrementalByTimeRangeKind.time_column`) holds an expression. That routed the
529-
entire `kind (...)` subtree through a dialect-specific generator, so tsql's
509+
Regression: recursing into nested Pydantic models to fix `time_column` made
510+
`_holds_expression` also match on `kind` itself, since *some* member of the
511+
`ModelKind` union (`IncrementalByTimeRangeKind.time_column`) holds an expression.
512+
That routed the entire `kind (...)` subtree through a dialect-specific generator, so tsql's
530513
boolean-literal preprocessing rewrote `forward_only TRUE` into `forward_only (1 = 1)`.
531514
That reparses without error, but `str_to_bool` on `Paren(EQ(1, 1)).name` (`""`)
532515
evaluates to `False`, so the value silently flips on reload.

0 commit comments

Comments
 (0)