Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions sqlmesh/core/audit/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ 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. 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()):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
data.extend(self.jinja_macros.data_hash_values)

self._metadata_hash = hash_data(data)
return self._metadata_hash

Expand Down
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion sqlmesh/utils/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -529,7 +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
}
data.append(json.dumps(trimmed_global_objs, sort_keys=True))
# `_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

Expand Down
141 changes: 141 additions & 0 deletions tests/core/test_audit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import typing as t
import pytest
from sqlglot import exp, parse_one

Expand Down Expand Up @@ -1233,3 +1234,143 @@ 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_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() }}"

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


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"
28 changes: 28 additions & 0 deletions tests/utils/test_jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,31 @@ 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": {"__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}})
plain.add_macros(macros)
assert plain.data_hash_values == [
"m",
"{% macro m() %}1{% endmacro %}",
'{"vars": {"cutoff": "2024-01-02", "limit": 10}}',
]
Loading