Skip to content

Commit f05f5b3

Browse files
mday-ioclaude
andcommitted
fix(format): render header list properties as dialect-native arrays
Dialect-agnostic header properties that hold a list (`tags`, `ignored_rules`) were rendered by the base generator as `ARRAY(...)`. On BigQuery `ARRAY(` is a subquery constructor, so a multi-element `tags ['a', 'b']` reformatted to `ARRAY('a', 'b')` failed to reload. Render these as `[...]` when the model dialect itself spells arrays with brackets; dialects that use `[`/`]` for identifier quoting (tsql, sqlite, ...) keep `ARRAY(...)`. Elements stay dialect-agnostic. Also give `grain` and `table_properties` the render policy of `grains` / `physical_properties`: they are renamed in `ModelMeta._pre_root_validator` rather than via Pydantic aliases, so reflection missed them and they lost dialect-specific rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjeuMzK7crECuSLbXAXjGx Signed-off-by: mday-io <mdaytn@gmail.com>
1 parent ebe539c commit f05f5b3

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

‎sqlmesh/core/dialect.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,9 +804,44 @@ def _meta_render_policy() -> t.Dict[str, bool]:
804804
for source in sources:
805805
for name, field in source.model_fields.items():
806806
policy.setdefault((field.alias or name).lower(), _holds_expression(field.annotation))
807+
808+
# `ModelMeta._pre_root_validator` (sqlmesh/core/model/meta.py) renames these two
809+
# user-facing property names to their target field before Pydantic validation, so
810+
# they never surface as a `Field(alias=...)` for the reflection above to find. Give
811+
# each the render policy of the field it is renamed to.
812+
pre_validator_aliases = {
813+
"grain": "grains",
814+
"table_properties": "physical_properties",
815+
}
816+
for alias, target in pre_validator_aliases.items():
817+
if target in policy:
818+
policy[alias] = policy[target]
819+
807820
return policy
808821

809822

823+
@functools.lru_cache(maxsize=None)
824+
def _dialect_renders_array_as_brackets(dialect_name: t.Optional[str]) -> bool:
825+
"""Whether `dialect_name`'s own generator spells an array literal as `[a, b]`.
826+
827+
Checked by actually rendering a sample `exp.Array` with that dialect, rather than
828+
inspecting `Dialect.ARRAY_SIZE_NAME` or similar generator flags, because the
829+
generator is the single source of truth for what a dialect's array syntax looks
830+
like and there is no single shared flag for it across dialects. This also covers
831+
dialects (tsql, sqlite, tableau, exasol, fabric) that reuse `[`/`]` for identifier
832+
quoting and therefore render arrays as `ARRAY(...)` instead: rewriting their
833+
`tags`/`ignored_rules` value to `[a, b]` would not be an array literal in their
834+
grammar at all, so it silently reparses as one bracket-quoted identifier and
835+
corrupts the value. An unrecognized dialect name renders with the generic
836+
generator, which itself does not use brackets, so it falls back to `False`.
837+
"""
838+
try:
839+
sample = exp.Array(expressions=[exp.Literal.string("x")])
840+
return sample.sql(dialect=dialect_name).startswith("[")
841+
except Exception:
842+
return False
843+
844+
810845
def _props_sql(self: Generator, expressions: t.List[exp.Expr]) -> str:
811846
props = []
812847
size = len(expressions)
@@ -855,6 +890,26 @@ def render_with_model_dialect(node: exp.Expr, **overrides: t.Any) -> str:
855890
and _meta_render_policy().get(prop.name.lower())
856891
):
857892
value_sql = render_with_model_dialect(value)
893+
elif (
894+
meta_dialect
895+
and isinstance(value, exp.Array)
896+
and _dialect_renders_array_as_brackets(meta_dialect)
897+
):
898+
# Dialect-agnostic properties (e.g. `tags`, `ignored_rules`) that hold a
899+
# list still go through the base (dialect=None) generator, which renders
900+
# an `exp.Array` as `ARRAY(...)`. On BigQuery `ARRAY(` is parsed as a
901+
# subquery constructor, so a multi-element `ARRAY('a', 'b')` fails to
902+
# reparse ("Required keyword: 'value' missing for Property"). Render it
903+
# as a bracketed list literal instead -- but only for dialects that
904+
# actually spell arrays that way; dialects that reuse `[`/`]` for
905+
# identifier quoting (tsql, sqlite, ...) keep the generic `ARRAY(...)`
906+
# form, which they parse back correctly. The elements themselves stay on
907+
# the dialect-agnostic path (`self.expressions`, not
908+
# `render_with_model_dialect`): these are SQLMesh's own scalar values
909+
# (tag/rule name strings), not user warehouse SQL, so they must not be
910+
# transpiled with the model dialect (e.g. tsql boolean literals turning
911+
# into `(1 = 1)`).
912+
value_sql = f"[{self.expressions(value, flat=True)}]"
858913
else:
859914
value_sql = self.sql(prop, "value")
860915

‎tests/core/test_dialect.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,167 @@ def test_format_model_expressions_macro_property_comments_preserved_with_dialect
617617
assert formatted == twice
618618

619619

620+
@pytest.mark.parametrize("dialect", ["bigquery", "duckdb", "snowflake"])
621+
@pytest.mark.parametrize("prop_name", ["tags", "ignored_rules"])
622+
def test_format_model_expressions_list_property_array_literal(dialect: str, prop_name: str):
623+
"""Dialect-agnostic header properties that hold a list (`tags`, `ignored_rules`) must
624+
render as a bracketed list literal (`[a, b]`) on dialects that spell arrays that way,
625+
not the base generator's `ARRAY(a, b)`.
626+
627+
On BigQuery, `ARRAY(` is parsed as a subquery constructor, so a multi-element
628+
`ARRAY('C1', 'c2')` fails to reparse with `Required keyword: 'value' missing for
629+
Property`. This previously affected any dialect using this generator, since these
630+
properties render generically (the `dialect=None` path) regardless of the model's
631+
own dialect. Only bigquery/duckdb/snowflake-like dialects are covered here;
632+
dialects whose own array syntax is not brackets (postgres' `ARRAY[...]`,
633+
databricks' `ARRAY(...)`) or that reuse `[`/`]` for identifier quoting (tsql,
634+
sqlite, ...) are covered by
635+
`test_format_model_expressions_list_property_dialects_without_bracket_arrays`.
636+
"""
637+
source = f"""MODEL (
638+
name a.b,
639+
dialect {dialect},
640+
{prop_name} ['C1', 'c2']
641+
);
642+
SELECT 1 AS x"""
643+
644+
formatted = format_model_expressions(parse(source, default_dialect=dialect), dialect=dialect)
645+
646+
assert f"{prop_name} ['C1', 'c2']" in formatted
647+
648+
# Reparses cleanly with the model's own dialect.
649+
reparsed = parse(formatted, default_dialect=dialect)
650+
651+
# Idempotent: formatting an already-formatted model is a no-op.
652+
twice = format_model_expressions(reparsed, dialect=dialect)
653+
assert formatted == twice
654+
655+
model = load_sql_based_model(parse(formatted, default_dialect=dialect), dialect=dialect)
656+
if prop_name == "tags":
657+
assert model.tags == ["C1", "c2"]
658+
else:
659+
assert model.ignored_rules == {"c1", "c2"}
660+
661+
662+
def test_format_model_expressions_array_property_no_dialect_unchanged():
663+
"""Regression guard: with no model dialect, list-valued header properties must keep
664+
rendering through the base generator (`ARRAY(...)`), exactly as pinned by
665+
`test_format_model_expressions`. The `[...]` rewrite only applies once a model
666+
dialect is present (gated on `meta_dialect`)."""
667+
formatted = format_model_expressions(
668+
parse("MODEL (name a.b, tags ['C1', 'c2']); SELECT 1 AS x")
669+
)
670+
671+
assert "tags ARRAY('C1', 'c2')" in formatted
672+
673+
674+
@pytest.mark.parametrize("dialect", ["tsql", "sqlite", "postgres", "databricks"])
675+
@pytest.mark.parametrize("prop_name", ["tags", "ignored_rules"])
676+
def test_format_model_expressions_list_property_dialects_without_bracket_arrays(
677+
dialect: str, prop_name: str
678+
):
679+
"""Regression: dialects whose own generator does not spell an `exp.Array` as
680+
`[a, b]` must NOT get the bracket-list rewrite from
681+
`test_format_model_expressions_list_property_array_literal`, and must keep the
682+
generic `ARRAY(...)` form.
683+
684+
This matters most for tsql and sqlite (also true of tableau, exasol, fabric), which
685+
reuse `[`/`]` for identifier quoting: `['a', 'b']` is not an array literal in their
686+
grammar at all, so rewriting `tags` or `ignored_rules` to that form reparses as a
687+
single bracket-quoted identifier, silently collapsing two values into one and
688+
corrupting the tag/rule names -- even though this exact source formatted correctly
689+
on `main` before bracket rendering was introduced. postgres (`ARRAY[...]`) and
690+
databricks (`ARRAY(...)`) are not corrupted by the bracket form, but should still
691+
keep rendering with their own generator's spelling rather than a generic bracket
692+
literal that is not how either dialect writes arrays.
693+
"""
694+
source = f"""MODEL (
695+
name a.b,
696+
dialect {dialect},
697+
{prop_name} ARRAY('C1', 'c2')
698+
);
699+
SELECT 1 AS x"""
700+
701+
formatted = format_model_expressions(parse(source, default_dialect=dialect), dialect=dialect)
702+
703+
prop_line = formatted.split(f"{prop_name} ")[1].split("\n")[0]
704+
assert prop_line.startswith("ARRAY")
705+
assert "[" not in prop_line
706+
707+
reparsed = parse(formatted, default_dialect=dialect)
708+
twice = format_model_expressions(reparsed, dialect=dialect)
709+
assert formatted == twice
710+
711+
model = load_sql_based_model(parse(formatted, default_dialect=dialect), dialect=dialect)
712+
if prop_name == "tags":
713+
assert model.tags == ["C1", "c2"]
714+
else:
715+
assert model.ignored_rules == {"c1", "c2"}
716+
717+
718+
@pytest.mark.parametrize("dialect", ["bigquery", "duckdb", "snowflake", "postgres"])
719+
def test_format_model_expressions_grain_alias_render_policy(dialect: str):
720+
"""`grain` is renamed to `grains` in `ModelMeta._pre_root_validator`, not via a
721+
Pydantic alias, so `_meta_render_policy` must special-case it to inherit `grains`'
722+
render policy (warehouse SQL). Otherwise a multi-column `grain [id, id2]` falls back
723+
to the generic, dialect-agnostic path and (via the base generator) becomes
724+
`ARRAY(id, id2)`, which fails to reparse on BigQuery.
725+
"""
726+
source = f"""MODEL (
727+
name a.b,
728+
dialect {dialect},
729+
grain [id, id2]
730+
);
731+
SELECT 1 AS x, 2 AS id, 3 AS id2"""
732+
733+
formatted = format_model_expressions(parse(source, default_dialect=dialect), dialect=dialect)
734+
735+
# Rendered with the model's own dialect (e.g. postgres' native `ARRAY[...]`), never
736+
# the base generator's `ARRAY(id, id2)`, which fails to reparse on BigQuery.
737+
assert "ARRAY(id, id2)" not in formatted
738+
739+
twice = format_model_expressions(parse(formatted, default_dialect=dialect), dialect=dialect)
740+
assert formatted == twice
741+
742+
# `grain [id, id2]` parses to a single composite grain wrapping both columns
743+
# (independent of this fix); what matters here is that it survives a dialect-
744+
# specific round trip rather than being flattened to the generic `ARRAY(...)`.
745+
model = load_sql_based_model(parse(formatted, default_dialect=dialect), dialect=dialect)
746+
assert len(model.grains) == 1
747+
assert {c.name for c in model.grains[0].find_all(exp.Column)} == {"id", "id2"}
748+
749+
750+
def test_format_model_expressions_table_properties_alias_render_policy():
751+
"""`table_properties` is the deprecated alias for `physical_properties`, renamed in
752+
`ModelMeta._pre_root_validator`, not via a Pydantic alias. It must inherit
753+
`physical_properties`' render policy (warehouse SQL) so dialect-specific values
754+
inside it, such as tsql's `DATETIME2`, are not flattened to the generic generator's
755+
`TIMESTAMP` spelling.
756+
"""
757+
formatted = format_model_expressions(
758+
parse(
759+
"""
760+
MODEL (
761+
name a.b,
762+
dialect tsql,
763+
table_properties (
764+
x = CAST('2024-01-01' AS DATETIME2)
765+
)
766+
);
767+
768+
SELECT 1 AS x
769+
""",
770+
default_dialect="tsql",
771+
),
772+
dialect="tsql",
773+
)
774+
775+
assert "x = '2024-01-01'::DATETIME2" in formatted
776+
777+
twice = format_model_expressions(parse(formatted, default_dialect="tsql"), dialect="tsql")
778+
assert formatted == twice
779+
780+
620781
def test_format_model_expressions_normalize_functions():
621782
"""Regression: formatter function-name casing behavior.
622783

0 commit comments

Comments
 (0)