From a19f7cb3c33c1b2a55f653da04d4bfd2d475e23b Mon Sep 17 00:00:00 2001 From: mday-io Date: Sun, 1 Nov 2026 21:57:48 +0000 Subject: [PATCH 1/6] fix!: include referenced Jinja macros in standalone audit metadata hash StandaloneAudit.metadata_hash ignored the audit's trimmed Jinja macro registry, so editing only the body of a Jinja macro used by a standalone audit changed the rendered audit query but not the audit's fingerprint. `sqlmesh plan` did not pick up the change. The registry's hash values are now included when it holds macros (root macros or package macros). Audits without Jinja macros keep their exact hash, including registries that only carry refs/sources/vars globals. Standalone audits that use Jinja macros, including dbt data tests, get a new metadata hash once on upgrade. This is a metadata-only change: no backfill. Signed-off-by: mday-io --- sqlmesh/core/audit/definition.py | 8 +++ tests/core/test_audit.py | 115 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/sqlmesh/core/audit/definition.py b/sqlmesh/core/audit/definition.py index 4c90151ee4..3b5e063a35 100644 --- a/sqlmesh/core/audit/definition.py +++ b/sqlmesh/core/audit/definition.py @@ -291,6 +291,14 @@ def metadata_hash(self) -> str: data.append(self.query_.sql) data.extend([e.sql for e in self.expressions_ or []]) + + # Only include the registry when it holds macros, so that audits without Jinja + # macros keep their existing hash. The registry is trimmed to the referenced macros. + # The refs / sources / vars from `global_objs` are therefore hashed only when macros + # are present, which matches how models hash their registry. + if self.jinja_macros.root_macros or self.jinja_macros.packages: + data.extend(self.jinja_macros.data_hash_values) + self._metadata_hash = hash_data(data) return self._metadata_hash diff --git a/tests/core/test_audit.py b/tests/core/test_audit.py index ac2e8abe16..85492176ea 100644 --- a/tests/core/test_audit.py +++ b/tests/core/test_audit.py @@ -1,4 +1,5 @@ import json +import typing as t import pytest from sqlglot import exp, parse_one @@ -1233,3 +1234,117 @@ def test_audit_formatting_flag_serde(): deserialized_audit = ModelAudit.parse_raw(audit_json) assert deserialized_audit.dict() == audit.dict() + + +def _load_standalone_audit_with_macros( + macros: str, + query_body: str, + package_macros: t.Optional[t.Dict[str, str]] = None, + global_objs: t.Optional[t.Dict[str, t.Any]] = None, +) -> StandaloneAudit: + expressions = parse( + f""" + AUDIT ( + name my_audit, + standalone true, + ); + + JINJA_QUERY_BEGIN; + {query_body} + JINJA_END; + """ + ) + jinja_macros = JinjaMacroRegistry(global_objs=global_objs or {}) + jinja_macros.add_macros(MacroExtractor().extract(macros)) + for package, package_source in (package_macros or {}).items(): + jinja_macros.add_macros(MacroExtractor().extract(package_source), package=package) + audit = load_audit(expressions, jinja_macros=jinja_macros) + assert isinstance(audit, StandaloneAudit) + return audit + + +def test_standalone_audit_metadata_hash_includes_referenced_jinja_macros(): + query = "SELECT * FROM db.tbl WHERE id <> {{ jone() }}" + unused = "{% macro unused() %}x{% endmacro %}" + + audit_1 = _load_standalone_audit_with_macros( + "{% macro jone() %}1{% endmacro %}" + unused, query + ) + audit_1_again = _load_standalone_audit_with_macros( + "{% macro jone() %}1{% endmacro %}" + unused, query + ) + audit_2 = _load_standalone_audit_with_macros( + "{% macro jone() %}2{% endmacro %}" + unused, query + ) + + assert audit_1.metadata_hash == audit_1_again.metadata_hash + assert audit_1.metadata_hash != audit_2.metadata_hash + + +def test_standalone_audit_metadata_hash_ignores_unreferenced_jinja_macros(): + query = "SELECT * FROM db.tbl WHERE id <> {{ jone() }}" + jone = "{% macro jone() %}1{% endmacro %}" + + audit_1 = _load_standalone_audit_with_macros( + jone + "{% macro unused() %}x{% endmacro %}", query + ) + audit_2 = _load_standalone_audit_with_macros( + jone + "{% macro unused() %}y{% endmacro %}", query + ) + + assert "unused" not in audit_1.jinja_macros.root_macros + assert audit_1.metadata_hash == audit_2.metadata_hash + + +def test_standalone_audit_metadata_hash_unchanged_without_jinja_macros(): + audit = load_audit( + parse( + """ + AUDIT ( + name my_audit, + owner owner_name, + standalone true, + ); + + SELECT * FROM db.tbl WHERE id IS NULL + """ + ) + ) + assert isinstance(audit, StandaloneAudit) + assert not audit.jinja_macros.root_macros and not audit.jinja_macros.packages + + # This literal must match the value computed on main before Jinja macros were hashed, so that + # audits without Jinja macros are not rehashed (and thus not re-versioned) on upgrade. + assert audit.metadata_hash == "3234547564" + + +def test_standalone_audit_metadata_hash_ignores_global_objs_without_macros(): + query = "SELECT * FROM db.tbl WHERE id IS NULL" + no_macros = "" + + empty = _load_standalone_audit_with_macros(no_macros, query) + with_globals = _load_standalone_audit_with_macros( + no_macros, query, global_objs={"refs": {"a": {"name": "a"}}, "vars": {"v": 1}} + ) + + assert with_globals.jinja_macros.global_objs + assert not with_globals.jinja_macros.root_macros and not with_globals.jinja_macros.packages + assert with_globals.metadata_hash == empty.metadata_hash + # Must match the value computed on main, see the test above. + assert empty.metadata_hash == "435209792" + + +def test_standalone_audit_metadata_hash_includes_referenced_package_macros(): + query = "SELECT * FROM db.tbl WHERE id <> {{ pkg.jone() }}" + + def load(body: str) -> StandaloneAudit: + return _load_standalone_audit_with_macros( + "", query, package_macros={"pkg": "{% macro jone() %}" + body + "{% endmacro %}"} + ) + + audit_1, audit_1_again, audit_2 = load("1"), load("1"), load("2") + + assert "pkg" in audit_1.jinja_macros.packages + assert not audit_1.jinja_macros.root_macros + assert audit_1.metadata_hash == audit_1_again.metadata_hash + assert audit_1.metadata_hash != audit_2.metadata_hash From 7de4991a5ebe92d1c6ae66853bbbf4f27863a2a9 Mon Sep 17 00:00:00 2001 From: mday-io Date: Sun, 1 Nov 2026 22:51:07 +0000 Subject: [PATCH 2/6] fix: ignore empty Jinja packages in standalone audit metadata hash Trimming the registry for an audit that only reads an attribute, such as {{ target.name }}, leaves an empty package entry. The previous check treated that as macros being present and rehashed the audit. Only count packages that hold macros, and pin the hash of such an audit to its value on main. Signed-off-by: mday-io --- sqlmesh/core/audit/definition.py | 6 ++++-- tests/core/test_audit.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/audit/definition.py b/sqlmesh/core/audit/definition.py index 3b5e063a35..81e3830420 100644 --- a/sqlmesh/core/audit/definition.py +++ b/sqlmesh/core/audit/definition.py @@ -295,8 +295,10 @@ def metadata_hash(self) -> str: # Only include the registry when it holds macros, so that audits without Jinja # macros keep their existing hash. The registry is trimmed to the referenced macros. # The refs / sources / vars from `global_objs` are therefore hashed only when macros - # are present, which matches how models hash their registry. - if self.jinja_macros.root_macros or self.jinja_macros.packages: + # are present, which matches how models hash their registry. Trimming can leave empty + # packages behind (for example `target` from `{{ target.name }}`), so only packages that + # hold macros count. + if self.jinja_macros.root_macros or any(self.jinja_macros.packages.values()): data.extend(self.jinja_macros.data_hash_values) self._metadata_hash = hash_data(data) diff --git a/tests/core/test_audit.py b/tests/core/test_audit.py index 85492176ea..0a6d7a44c5 100644 --- a/tests/core/test_audit.py +++ b/tests/core/test_audit.py @@ -1348,3 +1348,16 @@ def load(body: str) -> StandaloneAudit: assert not audit_1.jinja_macros.root_macros assert audit_1.metadata_hash == audit_1_again.metadata_hash assert audit_1.metadata_hash != audit_2.metadata_hash + + +def test_standalone_audit_metadata_hash_ignores_attribute_only_jinja(): + # `{{ target.name }}` reads an attribute and calls no macro, but trimming still leaves an empty + # `target` package in the registry. The hash must match the value computed on main before + # Jinja macros were hashed, so that such audits are not rehashed on upgrade. + audit = _load_standalone_audit_with_macros( + "", "SELECT * FROM db.tbl WHERE name = '{{ target.name }}'" + ) + assert audit.jinja_macros.packages == {"target": {}} + assert not audit.jinja_macros.root_macros + + assert audit.metadata_hash == "3822507146" From 55e306deb5acf336d2035723abffa6393e5f6436 Mon Sep 17 00:00:00 2001 From: mday-io Date: Mon, 2 Nov 2026 02:17:29 +0000 Subject: [PATCH 3/6] fix: add a migration for the standalone audit Jinja macro hash change Changing the standalone audit metadata hash inputs needs a schema version bump, so that `sqlmesh migrate` recomputes stored fingerprints. Without it, standalone audits that use Jinja macros, including dbt data tests, show as modified after upgrading. Signed-off-by: mday-io --- ...clude_jinja_macros_in_standalone_audit_fingerprint.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 sqlmesh/migrations/v0103_include_jinja_macros_in_standalone_audit_fingerprint.py diff --git a/sqlmesh/migrations/v0103_include_jinja_macros_in_standalone_audit_fingerprint.py b/sqlmesh/migrations/v0103_include_jinja_macros_in_standalone_audit_fingerprint.py new file mode 100644 index 0000000000..034f1db110 --- /dev/null +++ b/sqlmesh/migrations/v0103_include_jinja_macros_in_standalone_audit_fingerprint.py @@ -0,0 +1,9 @@ +"""Include referenced Jinja macros in the standalone audit fingerprint.""" + + +def migrate_schemas(engine_adapter, schema, **kwargs): # type: ignore + pass + + +def migrate_rows(engine_adapter, schema, **kwargs): # type: ignore + pass From d80555905ca30433ef35d1b961e93edf38cfafd5 Mon Sep 17 00:00:00 2001 From: mday-io Date: Mon, 2 Nov 2026 12:46:30 +0000 Subject: [PATCH 4/6] fix: hash non-JSON dbt vars in the Jinja macro registry dbt loads an unquoted YAML date, such as `cutoff: 2024-01-02`, as a `date` object. JinjaMacroRegistry.data_hash_values passed it to json.dumps, which raised TypeError. Models that used such a var already failed to load, and hashing standalone audit registries would have extended the failure to dbt data tests. Pass `default=str` so these values hash as their string form. It only applies to values JSON can't represent, so existing hashes don't change. Signed-off-by: mday-io --- sqlmesh/utils/jinja.py | 5 ++++- tests/core/test_audit.py | 13 +++++++++++++ tests/utils/test_jinja.py | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py index 829981db1c..a18c307ba8 100644 --- a/sqlmesh/utils/jinja.py +++ b/sqlmesh/utils/jinja.py @@ -529,7 +529,10 @@ def data_hash_values(self) -> t.List[str]: trimmed_global_objs = { k: self.global_objs[k] for k in ("refs", "sources", "vars") if k in self.global_objs } - data.append(json.dumps(trimmed_global_objs, sort_keys=True)) + # `default=str` covers values that JSON can't represent, such as dbt vars loaded from an + # unquoted YAML date. It doesn't apply to JSON-serializable values, so their hashes are + # unchanged. + data.append(json.dumps(trimmed_global_objs, sort_keys=True, default=str)) return data diff --git a/tests/core/test_audit.py b/tests/core/test_audit.py index 0a6d7a44c5..baa7200fc7 100644 --- a/tests/core/test_audit.py +++ b/tests/core/test_audit.py @@ -1334,6 +1334,19 @@ def test_standalone_audit_metadata_hash_ignores_global_objs_without_macros(): assert empty.metadata_hash == "435209792" +def test_standalone_audit_metadata_hash_with_date_var(): + from datetime import date + + # dbt loads an unquoted YAML date as a `date` object, which `json.dumps` can't serialize. + audit = _load_standalone_audit_with_macros( + "{% macro jone() %}1{% endmacro %}", + "SELECT * FROM db.tbl WHERE id <> {{ jone() }}", + global_objs={"vars": {"cutoff": date(2024, 1, 2)}}, + ) + + assert audit.metadata_hash + + def test_standalone_audit_metadata_hash_includes_referenced_package_macros(): query = "SELECT * FROM db.tbl WHERE id <> {{ pkg.jone() }}" diff --git a/tests/utils/test_jinja.py b/tests/utils/test_jinja.py index 01eeb47412..4c196e99cd 100644 --- a/tests/utils/test_jinja.py +++ b/tests/utils/test_jinja.py @@ -353,3 +353,23 @@ def test_builtin_b64decode_with_env_var(monkeypatch): config = yaml.load("password: \"{{ env_var('SNOWFLAKE_PW_B64') | b64decode }}\"") assert config == {"password": "super-secret-pw"} + + +def test_macro_registry_data_hash_values_with_non_json_vars(): + from datetime import date + + macros = MacroExtractor().extract("{% macro m() %}1{% endmacro %}") + + # dbt loads an unquoted YAML date such as `cutoff: 2024-01-02` as a `date` object. + registry = JinjaMacroRegistry(global_objs={"vars": {"cutoff": date(2024, 1, 2)}}) + registry.add_macros(macros) + assert registry.data_hash_values[-1] == '{"vars": {"cutoff": "2024-01-02"}}' + + # JSON-serializable globals produce the same values as before. + plain = JinjaMacroRegistry(global_objs={"vars": {"cutoff": "2024-01-02", "limit": 10}}) + plain.add_macros(macros) + assert plain.data_hash_values == [ + "m", + "{% macro m() %}1{% endmacro %}", + '{"vars": {"cutoff": "2024-01-02", "limit": 10}}', + ] From a448364bc851d3c42fc2d4d454c251e18387e58a Mon Sep 17 00:00:00 2001 From: mday-io Date: Tue, 3 Nov 2026 16:57:20 +0000 Subject: [PATCH 5/6] fix: keep the type of non-JSON vars in the Jinja registry hash With default=str, a date var and its ISO string hashed the same, so changing one into the other didn't change the fingerprint even though a macro could tell them apart. Hash non-JSON values with their type name. JSON-serializable values are unchanged. Signed-off-by: mday-io --- sqlmesh/utils/jinja.py | 14 ++++++++++---- tests/utils/test_jinja.py | 10 +++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py index a18c307ba8..1e2292e369 100644 --- a/sqlmesh/utils/jinja.py +++ b/sqlmesh/utils/jinja.py @@ -267,6 +267,12 @@ def sort_dict_recursive( JinjaGlobalAttribute = t.Union[str, int, float, bool, AttributeDict] +def _hashable_json_value(value: t.Any) -> t.Dict[str, str]: + """Represents a value that JSON can't serialize, keeping its type so that, for example, a date + and its ISO string hash differently.""" + return {"__type__": f"{type(value).__module__}.{type(value).__qualname__}", "value": str(value)} + + class JinjaMacroRegistry(PydanticModel): """Registry for Jinja macros. @@ -529,10 +535,10 @@ def data_hash_values(self) -> t.List[str]: trimmed_global_objs = { k: self.global_objs[k] for k in ("refs", "sources", "vars") if k in self.global_objs } - # `default=str` covers values that JSON can't represent, such as dbt vars loaded from an - # unquoted YAML date. It doesn't apply to JSON-serializable values, so their hashes are - # unchanged. - data.append(json.dumps(trimmed_global_objs, sort_keys=True, default=str)) + # `_hashable_json_value` covers values that JSON can't represent, such as dbt vars loaded + # from an unquoted YAML date. It doesn't apply to JSON-serializable values, so their hashes + # are unchanged. + data.append(json.dumps(trimmed_global_objs, sort_keys=True, default=_hashable_json_value)) return data diff --git a/tests/utils/test_jinja.py b/tests/utils/test_jinja.py index 4c196e99cd..2c4bf6c6c1 100644 --- a/tests/utils/test_jinja.py +++ b/tests/utils/test_jinja.py @@ -363,7 +363,15 @@ def test_macro_registry_data_hash_values_with_non_json_vars(): # dbt loads an unquoted YAML date such as `cutoff: 2024-01-02` as a `date` object. registry = JinjaMacroRegistry(global_objs={"vars": {"cutoff": date(2024, 1, 2)}}) registry.add_macros(macros) - assert registry.data_hash_values[-1] == '{"vars": {"cutoff": "2024-01-02"}}' + assert ( + registry.data_hash_values[-1] + == '{"vars": {"cutoff": {"__type__": "datetime.date", "value": "2024-01-02"}}}' + ) + + # The type is part of the hash, so a date and its ISO string don't collide. + as_string = JinjaMacroRegistry(global_objs={"vars": {"cutoff": "2024-01-02"}}) + as_string.add_macros(macros) + assert as_string.data_hash_values != registry.data_hash_values # JSON-serializable globals produce the same values as before. plain = JinjaMacroRegistry(global_objs={"vars": {"cutoff": "2024-01-02", "limit": 10}}) From bc8ae23530db03d6e81913c0bea5c9e58c23a694 Mon Sep 17 00:00:00 2001 From: mday-io Date: Wed, 4 Nov 2026 18:24:08 +0000 Subject: [PATCH 6/6] fix: hash non-JSON dbt vars as strings so stored snapshots match Snapshots stored in state keep a date var as its ISO string. Hashing the type made a reloaded registry hash differently from the project, so fingerprints recomputed from state (for example by migrations) would not match and models could be re-versioned. Go back to default=str, which hashes a date and its stored string the same, and test the round trip. Signed-off-by: mday-io --- sqlmesh/utils/jinja.py | 15 +++++---------- tests/utils/test_jinja.py | 14 ++++++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py index 1e2292e369..b50b7cd3da 100644 --- a/sqlmesh/utils/jinja.py +++ b/sqlmesh/utils/jinja.py @@ -267,12 +267,6 @@ def sort_dict_recursive( JinjaGlobalAttribute = t.Union[str, int, float, bool, AttributeDict] -def _hashable_json_value(value: t.Any) -> t.Dict[str, str]: - """Represents a value that JSON can't serialize, keeping its type so that, for example, a date - and its ISO string hash differently.""" - return {"__type__": f"{type(value).__module__}.{type(value).__qualname__}", "value": str(value)} - - class JinjaMacroRegistry(PydanticModel): """Registry for Jinja macros. @@ -535,10 +529,11 @@ def data_hash_values(self) -> t.List[str]: trimmed_global_objs = { k: self.global_objs[k] for k in ("refs", "sources", "vars") if k in self.global_objs } - # `_hashable_json_value` covers values that JSON can't represent, such as dbt vars loaded - # from an unquoted YAML date. It doesn't apply to JSON-serializable values, so their hashes - # are unchanged. - data.append(json.dumps(trimmed_global_objs, sort_keys=True, default=_hashable_json_value)) + # `default=str` covers values that JSON can't represent, such as dbt vars loaded from an + # unquoted YAML date. It doesn't apply to JSON-serializable values, so their hashes are + # unchanged. A snapshot stored in state keeps such a value as its string, so hashing the + # string form keeps the hash the same after the snapshot is loaded back. + data.append(json.dumps(trimmed_global_objs, sort_keys=True, default=str)) return data diff --git a/tests/utils/test_jinja.py b/tests/utils/test_jinja.py index 2c4bf6c6c1..6106296f10 100644 --- a/tests/utils/test_jinja.py +++ b/tests/utils/test_jinja.py @@ -363,15 +363,13 @@ def test_macro_registry_data_hash_values_with_non_json_vars(): # dbt loads an unquoted YAML date such as `cutoff: 2024-01-02` as a `date` object. registry = JinjaMacroRegistry(global_objs={"vars": {"cutoff": date(2024, 1, 2)}}) registry.add_macros(macros) - assert ( - registry.data_hash_values[-1] - == '{"vars": {"cutoff": {"__type__": "datetime.date", "value": "2024-01-02"}}}' - ) + assert registry.data_hash_values[-1] == '{"vars": {"cutoff": "2024-01-02"}}' - # The type is part of the hash, so a date and its ISO string don't collide. - as_string = JinjaMacroRegistry(global_objs={"vars": {"cutoff": "2024-01-02"}}) - as_string.add_macros(macros) - assert as_string.data_hash_values != registry.data_hash_values + # Saving a snapshot to state stores the date as a string. The hash must not change when the + # registry is loaded back, because migrations recompute fingerprints from stored snapshots. + reloaded = JinjaMacroRegistry.parse_raw(registry.json()) + assert reloaded.global_objs["vars"]["cutoff"] == "2024-01-02" + assert reloaded.data_hash_values == registry.data_hash_values # JSON-serializable globals produce the same values as before. plain = JinjaMacroRegistry(global_objs={"vars": {"cutoff": "2024-01-02", "limit": 10}})