Skip to content

Commit ae92536

Browse files
committed
fix(format): recurse into nested Pydantic models and preserve macro property comments
Two gaps in the header-property dialect-render policy from the previous fix: - `_holds_expression` only checked the outer type annotation and typing generics (`Optional`, `List`, ...), so a nested Pydantic model wrapping an expression field -- `TimeColumn` on `IncrementalByTimeRangeKind.time_column` -- was misclassified as a scalar property and fell back to generic rendering, losing dialect-specific identifier quoting (tsql `[end]` became ANSI `"end"`). Recurse into `model_fields` for any type that exposes them, guarded by a visited set. - The `MacroFunc` dialect-render branch passed `comments=False` into `render_with_model_dialect`, which threads it to `Expression.sql()`'s fresh per-call `Generator` constructor -- a generator-wide flag that disables every comment in the subtree, not just the redundant outer `maybe_comment` call. Comments inside macro header-properties (e.g. `@my_prop(cutoff := ... /* note */)`) were silently dropped whenever the model declared a `dialect`. Render a copy of the property with its own top-level comments cleared instead, leaving `.this`'s comments -- which `_macro_func_sql` already attaches -- untouched. Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent ea0a0d1 commit ae92536

2 files changed

Lines changed: 121 additions & 12 deletions

File tree

sqlmesh/core/dialect.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -737,15 +737,28 @@ def parse(self: Parser) -> t.Optional[exp.Expr]:
737737
_SQLMESH_META_DIALECT = "sqlmesh_meta_dialect"
738738

739739

740-
def _holds_expression(annotation: t.Any) -> bool:
740+
def _holds_expression(annotation: t.Any, _visited: t.Optional[t.FrozenSet[t.Any]] = None) -> bool:
741741
"""Whether a declared field type bottoms out in a SQLGlot expression.
742742
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.
743+
Covers List[exp.Expr], Optional[Dict[str, exp.DataType]], Optional[exp.Tuple], the
744+
nested Tuple[str, Dict[str, exp.Expr]] shape used by audits/signals, and nested
745+
Pydantic models that themselves wrap an expression field, such as `TimeColumn`
746+
(IncrementalByTimeRangeKind.time_column).
745747
"""
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))
748+
if isinstance(annotation, type):
749+
if issubclass(annotation, exp.Expr):
750+
return True
751+
visited = _visited or frozenset()
752+
if annotation in visited:
753+
return False
754+
if hasattr(annotation, "model_fields"):
755+
visited = visited | {annotation}
756+
return any(
757+
_holds_expression(field.annotation, visited)
758+
for field in annotation.model_fields.values()
759+
)
760+
return False
761+
return any(_holds_expression(arg, _visited) for arg in t.get_args(annotation))
749762

750763

751764
@functools.lru_cache(maxsize=1)
@@ -806,12 +819,19 @@ def render_with_model_dialect(node: exp.Expr, **overrides: t.Any) -> str:
806819

807820
if isinstance(prop, MacroFunc):
808821
# A macro in property position wraps user-authored arguments, so it carries
809-
# warehouse SQL the same way `columns` or `audits` do.
810-
sql = self.indent(
811-
render_with_model_dialect(prop, comments=False)
812-
if meta_dialect
813-
else self.sql(prop, comment=False)
814-
)
822+
# warehouse SQL the same way `columns` or `audits` do. Clear the outer node's
823+
# own comments (not `.this`'s, which `_macro_func_sql` already attaches)
824+
# before rendering with the model dialect, mirroring what `comment=False`
825+
# does for the non-dialect path below -- passing `comments=False` here
826+
# instead would build a fresh Generator with comments globally disabled,
827+
# silently dropping every comment in the subtree rather than just the
828+
# redundant outer one.
829+
if meta_dialect:
830+
prop_for_render = prop.copy()
831+
prop_for_render.comments = None
832+
sql = self.indent(render_with_model_dialect(prop_for_render))
833+
else:
834+
sql = self.indent(self.sql(prop, comment=False))
815835
else:
816836
value = prop.args.get("value")
817837

tests/core/test_dialect.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,95 @@ def test_format_audit_expressions_meta_render_policy():
473473
assert "cutoff := '2024-01-01'::DATETIME2" in formatted
474474

475475

476+
def test_format_model_expressions_time_column_dialect():
477+
"""`time_column` is a nested Pydantic model (`TimeColumn`) wrapping an expression, not
478+
an `exp.Expr` annotation itself, so the render-policy reflection must recurse into
479+
nested Pydantic models to classify it as warehouse SQL. Otherwise it falls back to
480+
generic rendering and loses dialect-specific identifier quoting: tsql's `[end]`
481+
becomes ANSI `"end"`, even though the same identifier in the query body is correctly
482+
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_macro_property_comments_preserved_with_dialect():
520+
"""Comments inside a macro header-property must survive formatting when the model
521+
has a `dialect` set.
522+
523+
The dialect-render path goes through `Expression.sql(dialect=...)`, which builds a
524+
fresh `Generator` with `comments` as a constructor flag: passing `comments=False`
525+
there disables comment rendering for the *entire* subtree, rather than just
526+
suppressing the redundant outer-level `maybe_comment` call the way `comment=False`
527+
does for `Generator.sql()`. That previously caused comments like `/* inline note */`
528+
to be silently dropped whenever the model declared a `dialect`.
529+
"""
530+
formatted = format_model_expressions(
531+
parse(
532+
"""
533+
MODEL (
534+
name a.b,
535+
dialect tsql,
536+
@my_prop(cutoff := CAST('2024-01-01' AS DATETIME2) /* inline note */)
537+
);
538+
539+
SELECT 1 AS x
540+
""",
541+
default_dialect="tsql",
542+
),
543+
dialect="tsql",
544+
)
545+
546+
assert "/* inline note */" in formatted
547+
assert (
548+
formatted
549+
== """MODEL (
550+
name a.b,
551+
dialect tsql,
552+
@my_prop(cutoff := '2024-01-01'::DATETIME2 /* inline note */)
553+
);
554+
555+
SELECT
556+
1 AS x"""
557+
)
558+
559+
# Idempotency: formatting an already-formatted macro property must not duplicate or
560+
# drop the comment on a second pass.
561+
twice = format_model_expressions(parse(formatted, default_dialect="tsql"), dialect="tsql")
562+
assert formatted == twice
563+
564+
476565
def test_format_model_expressions_normalize_functions():
477566
"""Regression: formatter function-name casing behavior.
478567

0 commit comments

Comments
 (0)