@@ -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+
620781def test_format_model_expressions_normalize_functions ():
621782 """Regression: formatter function-name casing behavior.
622783
0 commit comments