Conversation
…alect SQLMesh#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>
Rendering a dialect-specific type with the generic generator compounds across runs rather than merely looking different: tsql `DATETIME2` renders as `TIMESTAMP`, and tsql parses `TIMESTAMP` as ROWVERSION, so a second pass writes `VARBINARY`. Two runs of `sqlmesh format` silently turned a datetime into a binary type -- and for an SCD kind's `time_data_type` that is the physical type of the valid_from/valid_to columns. Covers columns, audits, nested kind properties, physical_properties and the SQLMesh-owned scalars. Signed-off-by: mday-io <mdaytn@gmail.com>
…lect A macro in property position wraps user-authored arguments, so it carries warehouse SQL the same way `columns` or `audits` do. It took a separate branch in _props_sql and kept rendering generically, which left it on the compounding path: DATETIME2 -> TIMESTAMP -> VARBINARY across two format runs. Signed-off-by: mday-io <mdaytn@gmail.com>
…roperty 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>
…blings Recursing _holds_expression into nested Pydantic models to correctly classify TimeColumn (IncrementalByTimeRangeKind.time_column) as warehouse SQL had the side effect of also matching ModelMeta.kind itself, since some member of the ModelKind union holds an expression field. That routed the entire kind (...) subtree through a dialect-specific generator, so on tsql a scalar sibling like forward_only TRUE was rewritten to (1 = 1) -- which reparses fine but silently evaluates to False on reload via str_to_bool. kind's own nested properties are already independently dialect-tagged via the ModelKind expression node's own meta when _props_sql recurses into them, so the outer kind property's policy should never route its subtree through render_with_model_dialect. Stop _holds_expression at _ModelKind subclasses to restore that. Signed-off-by: mday-io <mdaytn@gmail.com>
29065a9 to
157e38b
Compare
|
@cmgoffena13 was waiting on the reporter to confirm he could test it via Slack - never got back. Seems unrelated to 6035 though |
|
@cmgoffena13 regarding 6035 This PR fixes the part of #6035 that actually breaks model loading - It doesn't cover the other symptom in that issue: So, should have a separate fix than this PR. |
|
@mday-io the issue I reported on there broke due to |
|
Hey @mday-io |
|
I just tested this branch against our project (~2k models, BigQuery dialect).
It also rewrote a string literal inside a query body: |
|
Thank you very much. Looking into this ASAP. |
|
Hi @mday-io |
f05f5b3 to
8bd94ab
Compare
Signed-off-by: mday-io <mdaytn@gmail.com>
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.
Signed-off-by: mday-io <mdaytn@gmail.com>
Add a Context-level test that formats a BigQuery model with `tags`, `ignored_rules`, `grain` and `partitioned_by`, then reloads it. Trim the header-list unit tests and drop the postgres/databricks cases, which passed with or without the fix. Signed-off-by: mday-io <mdaytn@gmail.com>
311a4b5 to
f7de34b
Compare
|
@albertosuman-1k5 thanks for testing on your models. I pushed a fix for the tags ['C1'] → ARRAY('C1') problem: list properties like tags, ignored_rules and grain now stay [...] on BigQuery, and the formatted models load again. There's a new test that runs sqlmesh format on a BigQuery model. Could you re-run the branch on your models? The JSON path problem ('$."28d_click"' → '$.28d_click') comes from sqlglot, not this PR. That will need to be handled separately. @cmgoffena13 I was wrong earlier to call the ignored_rules → ARRAY(...) rewrite cosmetic: with more than one value it broke loading on BigQuery. That's fixed now, along with tags. |
|
@mday-io -- is this ready for review? I can take a look |
|
@cmgoffena13 plz |
cmgoffena13
left a comment
There was a problem hiding this comment.
@mday-io -- Small comments, main one is testing that the data types don't change for regression testing against the unexpected bug. For release notes, I mentioned the first PR did introduce bugs in data types due to changing them to a different data type. If we release this, it won't format the data types back, so we should put a disclaimer on what happened and how they can manually fix it if they are affected. Something like below:
Fix: sqlmesh format silently changed column types in model headers (v0.236.0 and later)
Starting in v0.236.0, sqlmesh format wrote MODEL and AUDIT headers without applying the model's dialect. Some warehouse-specific types were rewritten to a spelling that the warehouse reads as a different, valid type. SQLMesh casts query results to the types declared in the header, so no error was raised. The model was rebuilt and the data was converted to the new type.
This release formats header properties that contain warehouse SQL (columns, kind properties such as time_data_type, audits, physical_properties, etc.) using the model's dialect again.
Affected types
| Dialect | Declared type | Silently became | Result |
|---|---|---|---|
| T-SQL (SQL Server, Azure SQL) | NVARCHAR, NCHAR (including NVARCHAR(MAX)) |
VARCHAR, CHAR |
Characters outside the column's code page are stored as ? |
| T-SQL | DATETIME2, SMALLDATETIME |
VARBINARY (after two format runs) |
Datetimes are stored as raw bytes |
| T-SQL, Fabric | ROWVERSION / TIMESTAMP |
VARBINARY |
Column is no longer a row version |
| BigQuery | DATETIME |
TIMESTAMP |
Local wall-clock times are stored as UTC timestamps |
| MySQL | LONGTEXT, MEDIUMTEXT |
TEXT |
Values over 64 KB are truncated or rejected, depending on sql_mode |
| Postgres, Redshift | REAL |
DOUBLE PRECISION |
Column widens from 4 to 8 bytes |
This affects any header property that declares a type: columns, time_data_type on SCD Type 2 models (the valid_from and valid_to columns), and casts inside audits (...).
What to do
- Check whether you're affected. You're affected if you ran
sqlmesh format(or a pre-commit hook, or format-on-save in the UI) on v0.236.0 or later, and a model header used one of the types above. In git history, the format commit changes a header type. Insqlmesh plan, the model showed as modified even though its query didn't change. - Restore the original types from git. Re-running
sqlmesh formatwon't restore them, because the header now contains a valid type and the formatter can't tell it was changed. - Upgrade and apply a plan. The plan detects the restored types and rebuilds the affected models. Review the backfill before you apply it. Data written while the wrong type was in place, such as Unicode text stored as
?, can only be recovered by rebuilding the model from its source.
| @@ -342,6 +342,389 @@ def test_format_model_expressions(): | |||
| ) | |||
There was a problem hiding this comment.
We should add in a regression test for the bug that was introduced as well. Having format change the data type after multiple formats occurred. Something like this:
@pytest.mark.parametrize(
"dialect,column_type",
[
("bigquery", "DATETIME"),
("tsql", "NVARCHAR(100)"),
],
)
def test_format_model_expressions_preserves_column_types(dialect: str, column_type: str):
"""Formatting must not change a declared column type.
Rendering these generically produced a different type that the dialect still accepts
(BigQuery `DATETIME` -> `TIMESTAMP`, tsql `NVARCHAR` -> `VARCHAR`), so the model loaded
without error and the physical table was rebuilt with the wrong type.
"""
source = f"MODEL (name a.b, kind FULL, dialect {dialect}, columns (c {column_type})); SELECT 1 AS c"
declared = load_sql_based_model(
parse(source, default_dialect=dialect), dialect=dialect
).columns_to_types
once = format_model_expressions(parse(source, default_dialect=dialect), dialect=dialect)
twice = format_model_expressions(parse(once, default_dialect=dialect), dialect=dialect)
assert once == twice
model = load_sql_based_model(parse(twice, default_dialect=dialect), dialect=dialect)
assert model.columns_to_types == declaredThere was a problem hiding this comment.
Added in 68da7f4. test_format_model_expressions_preserves_column_types formats a BigQuery DATETIME and a T-SQL DATETIME2(6) model twice and reloads the model after each pass, asserting columns_to_types doesn't change. On main it fails the way you described (DATETIME → TIMESTAMP, DATETIME2 → ROWVERSION → VARBINARY), and it passes here. It replaces the text-only DATETIME2 idempotency case, since this one checks the loaded types.
| policy: t.Dict[str, bool] = {} | ||
| for source in sources: | ||
| for name, field in source.model_fields.items(): | ||
| policy.setdefault((field.alias or name).lower(), _holds_expression(field.annotation)) |
There was a problem hiding this comment.
Something to note here: this affects audits, so this will happen in T-SQL:
- User writes:
audits (my_audit(flag := true)) - After format:
audits (my_audit(flag := (1 = 1)))
Not sure it can't be helped, might just be a casualty of getting this to work for everything
There was a problem hiding this comment.
This one wasn't unavoidable: this PR caused it, since main writes flag := TRUE there. It also wasn't idempotent: the first format wrote (1 = 1) and the second split it across lines, so format --check failed once. Fixed in 68da7f4: boolean literals in dialect-rendered header values stay TRUE/FALSE. They still get transpiled when the value is used, so the audit query still renders WHERE (1 = 1) on T-SQL. I added audits (my_audit(flag := true)) to the idempotency test.
|
@mday-io The fix works for us! The JSON path problem is actually not even a problem. BigQuery now accepts the field as it is without having to use quotes. We'll just remove them then. So feel free to merge this. Thanks a lot! |
…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>
|
Thanks for the review @cmgoffena13. Both inline comments are addressed in 68da7f4, and I've added release notes to the description: the bug came in with v0.236.0 (#5864), the list of affected types, and recovery steps. Following up on your earlier question about #6035: this PR now covers both parts of it, not just Thanks also @albertosuman-1k5 for testing it against a real BigQuery project. @cmgoffena13, could you re-review when you get a chance? |
Description
#5864 stopped formatting from breaking SQLMesh's own MODEL/AUDIT/METRIC header properties. On tsql,
allow_partials TRUEwas becoming(1 = 1), which doesn't parse. The fix was to stop transpiling headers entirely.That was too broad. It also stopped transpiling header properties that really are warehouse SQL, which caused two problems:
columns (ts DATETIME2(6))was quietly rewritten to the genericTIMESTAMP(6). On tsql this got worse with each run:DATETIME2→TIMESTAMP→ reparsed asROWVERSION→VARBINARY. Twosqlmesh formatruns could turn a datetime column into a binary one without any error.tags ['C1', 'c2'],ignored_rules [...]andgrain [id]were rewritten asARRAY(...). BigQuery readsARRAY(as the start of a subquery, so a list with more than one value failed to load after formatting (Required keyword: 'value' missing for Property).This PR formats each header property on its own instead of applying one rule to the whole header:
columns,audits,physical_properties,partitioned_by,grain/grains, macro properties, and expression fields insidekindsuch astime_data_type,unique_keyandtime_column.allow_partials,description, and scalarkindproperties such asforward_only.grainandtable_propertiesare renamed tograinsandphysical_propertiesbefore validation, so the automatic split can't see them. They are mapped to the same group as the field they're renamed to.tagsandignored_rulesare written as[...]for dialects that write arrays that way (BigQuery, DuckDB, Snowflake, ClickHouse, StarRocks). Every other dialect keepsARRAY(...). This matters for tsql, SQLite and others that use[...]to quote identifiers: there,['a', 'b']would reload as a single identifier. The list items themselves are never converted to the model's dialect.Applies to MODEL, AUDIT, and METRIC headers.
Related: #6035. The
partitioned_by DATE_TRUNC(...)andtags/ignored_rulesbreakage reported there is fixed here.Not fixed here: BigQuery JSON paths in the query body, such as
JSON_VALUE(x, '$."28d_click"'), are rewritten to'$.28d_click'. That comes from sqlglot's BigQuery output (still present in sqlglot 30.19), not from header formatting, so it will be handled separately.Test Plan
test_format_model_expressions_meta_render_policy(tsql and fabric) andtest_format_audit_expressions_meta_render_policy:columns,audits,physical_properties, and expression properties nested inkindkeep their dialect spelling, while SQLMesh's scalar properties stay dialect-agnostic.test_format_model_expressions_is_idempotent: a second format run changes nothing. Coverscolumns,audits,kind,physical_properties, SQLMesh scalars, and macro properties.test_format_model_expressions_time_column_dialect:time_columnkeeps its dialect's identifier quoting (e.g. tsql[end]).test_format_model_expressions_macro_property_comments_preserved_with_dialect: comments inside a macro header property's arguments survive formatting, and a second run changes nothing.test_format_model_expressions_kind_scalar_sibling_dialect: on tsql, akindblock with bothtime_columnandforward_onlysurvives a round trip. After reloading throughload_sql_based_model,model.kind.forward_only is True.test_format_model_expressions_list_property_array_literal(BigQuery, DuckDB, Snowflake ×tags/ignored_rules): lists render as[...], reload with their values intact, and a second run changes nothing.test_format_model_expressions_list_property_bracket_identifier_dialects(tsql, SQLite ×tags/ignored_rules): lists keepARRAY(...)and reload with their values intact.test_format_model_expressions_array_property_no_dialect_unchanged: output is unchanged when the model has no dialect.test_format_model_expressions_grain_alias_render_policyandtest_format_model_expressions_table_properties_alias_render_policy:grainandtable_propertiesrender in the model's dialect, and a second run changes nothing.test_format_bigquery_header_list_properties: runssqlmesh formaton a BigQuery project withtags,ignored_rules,grainandpartitioned_by DATE_TRUNC(...), then reloads it and checks the values survive.pytest tests/core/test_dialect.py tests/core/test_format.py: 193 passed.Release Note
Fix:
sqlmesh formatcorrupting MODEL/AUDIT/METRIC headersOn models with an explicit
dialect,sqlmesh formatcould:columns,audits,physical_properties, andkindproperties such astime_data_typeandtime_column. On tsql and fabric it got worse with each run:DATETIME2→TIMESTAMP→VARBINARYacross two runs. For an SCD Type 2 model'stime_data_type, that turned thevalid_from/valid_tocolumns from a datetime type into a binary one.tags ['a', 'b'],ignored_rulesandgraintoARRAY(...). On BigQuery, a model with more than one value in such a list then failed to load.SQLMesh's own header settings (
allow_partials,description, thekindname, booleankindproperties likeforward_only, etc.) are unaffected and still render dialect-agnostically.Action for affected users: after upgrading, re-run
sqlmesh formatand review the diff. Types that were already rewritten will be restored to their dialect spelling. BigQuery models that failed to load because ofARRAY(...)in the header need that line changed back to[...]by hand before they load.Checklist
make styleand fixed any issuesmake fast-test)git commit -s) per the DCO