Skip to content

Commit e057958

Browse files
authored
Merge branch 'main' into lint-scope-schema-resolution
2 parents bcbed2f + 40a24dd commit e057958

17 files changed

Lines changed: 719 additions & 129 deletions

File tree

docs/concepts/audits.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,38 @@ AUDIT (name price_is_not_null);
158158
SELECT * FROM @this_model
159159
WHERE price IS NULL;
160160
```
161+
### Standalone audits
161162

163+
Standalone audits are defined independently rather than being attached to a specific model. They specify the models they depend on using the `depends_on` property.
164+
165+
Unlike model-level audits, standalone audits can be used to validate data across one or more models without being associated with a single model.
166+
167+
Standalone audits run as scheduled nodes during both `sqlmesh plan` and `sqlmesh run`.
168+
169+
```sql linenums="1"
170+
AUDIT (
171+
name assert_item_price_is_not_null,
172+
dialect spark,
173+
standalone TRUE,
174+
depends_on (
175+
sushi.items
176+
)
177+
);
178+
179+
SELECT *
180+
FROM sushi.items
181+
WHERE
182+
ds BETWEEN @start_ds AND @end_ds
183+
AND price IS NULL;
184+
```
185+
186+
In this example, the audit checks that the `price` column in `sushi.items` does not contain `NULL` values for the selected date range.
187+
188+
Standalone audits can declare dependencies using the `depends_on` property. SQLMesh can often infer dependencies directly from the audit query, but using `depends_on` is recommended when inference isn't sufficient.
189+
190+
!!! note
191+
192+
Standalone audits are non-blocking only. Because they are not associated with a single model, SQLMesh cannot determine which model should be blocked if the audit fails.
162193
## Built-in audits
163194
SQLMesh comes with a suite of built-in generic audits that cover a broad set of common use cases. Built-in audits are blocking by default, but they all have non-blocking counterparts which you can use by appending `_non_blocking` - see [Non-blocking audits](#non-blocking-audits).
164195

docs/guides/linter.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Here are all of SQLMesh's built-in linting rules:
7474
| `invalidselectstarexpansion` | Correctness | The query's top-level selection may be `SELECT *`, but only if SQLMesh can expand the `SELECT *` into individual columns |
7575
| `noselectstar` | Stylistic | The query's top-level selection may not be `SELECT *`, even if SQLMesh can expand the `SELECT *` into individual columns |
7676
| `nomissingaudits` | Governance | SQLMesh did not find any `audits` in the model's configuration to test data quality. |
77+
| `nomissingunittest` | Governance | SQLMesh did not find any `unit tests` associated with the model to test |
7778

7879
### User-defined rules
7980

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ dev = [
6565
# it depends on the 'InvalidCatalogIntegrationConfigError' class that only exists as of dbt-adapters==1.16.6
6666
# so we exclude it to prevent failures and hope that upstream releases a new version with the correct constraint
6767
"dbt-snowflake!=1.10.1",
68+
# fastjsonschema 2.22+ uses PEP 604 type hints and now requires Python >=3.10
69+
# (upstream #211 / #213). Keep 3.9 on the 2.21 line; pulled in via
70+
# dbt-bigquery → nbformat.
71+
"fastjsonschema<2.22; python_version<'3.10'",
6872
"dbt-athena-community",
6973
"dbt-clickhouse",
7074
"dbt-databricks",

sqlmesh/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None:
557557
)
558558
@click.option(
559559
"--min-intervals",
560+
type=int,
560561
default=None,
561562
help="For every model, ensure at least this many intervals are covered by a missing intervals check regardless of the plan start date",
562563
)

sqlmesh/core/console.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2920,7 +2920,10 @@ def __init__(
29202920

29212921
super().__init__(console, **kwargs)
29222922

2923-
self.display = display or get_ipython().user_ns.get("display", ipython_display)
2923+
ipython = get_ipython()
2924+
self.display = display or (
2925+
ipython.user_ns.get("display", ipython_display) if ipython else ipython_display
2926+
)
29242927
self.missing_dates_output = widgets.Output()
29252928
self.dynamic_options_after_categorization_output = widgets.VBox()
29262929

sqlmesh/core/context.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
filter_tests_by_patterns,
119119
)
120120
from sqlmesh.core.user import User
121-
from sqlmesh.utils import UniqueKeyDict, Verbosity
121+
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity
122122
from sqlmesh.utils.concurrency import concurrent_apply_to_values
123123
from sqlmesh.utils.dag import DAG
124124
from sqlmesh.utils.date import (
@@ -870,6 +870,9 @@ def run(
870870
engine_type=self.snapshot_evaluator.adapter.dialect,
871871
state_sync_type=self.state_sync.state_type(),
872872
)
873+
snapshot_evaluator = self.snapshot_evaluator.set_correlation_id(
874+
CorrelationId.from_run_id(analytics_run_id)
875+
)
873876
self._load_materializations()
874877

875878
env_check_attempts_num = max(
@@ -922,6 +925,7 @@ def _has_environment_changed() -> bool:
922925
select_models=select_models,
923926
circuit_breaker=_has_environment_changed,
924927
no_auto_upstream=no_auto_upstream,
928+
snapshot_evaluator=snapshot_evaluator,
925929
)
926930
done = True
927931
except CircuitBreakerError:
@@ -2664,8 +2668,9 @@ def _run(
26642668
select_models: t.Optional[t.Collection[str]],
26652669
circuit_breaker: t.Optional[t.Callable[[], bool]],
26662670
no_auto_upstream: bool,
2671+
snapshot_evaluator: t.Optional[SnapshotEvaluator] = None,
26672672
) -> CompletionStatus:
2668-
scheduler = self.scheduler(environment=environment)
2673+
scheduler = self.scheduler(environment=environment, snapshot_evaluator=snapshot_evaluator)
26692674
snapshots = scheduler.snapshots
26702675

26712676
if select_models is not None:

sqlmesh/core/macros.py

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -965,17 +965,84 @@ def generate_surrogate_key(
965965
)
966966
)
967967

968+
concat = exp.func("CONCAT", *string_fields)
969+
# The argument is always a string; annotating it here lets generators that
970+
# split string/binary hash semantics (Presto, Trino) wrap the encode.
971+
concat.type = exp.DataType.build("text")
972+
968973
func = exp.func(
969974
hash_function.name,
970-
exp.func("CONCAT", *string_fields),
975+
concat,
971976
dialect=evaluator.dialect,
972977
)
973978
if isinstance(func, exp.MD5Digest):
974979
func = exp.MD5(this=func.this)
980+
elif isinstance(func, exp.SHA2Digest):
981+
# Same split as MD5/MD5Digest: the surrogate key must be a hex string,
982+
# not a binary digest, on every dialect.
983+
func = exp.SHA2(this=func.this, length=func.args.get("length"))
984+
elif isinstance(func, exp.Anonymous) and _is_presto_family(evaluator.dialect):
985+
# Athena runs the Trino engine, so sha256() takes varbinary there too,
986+
# but its parser has no SHA256/SHA512 entry: exp.func returns an
987+
# Anonymous node, so neither branch above fires and the surrogate key
988+
# keeps the bare SHA256(varchar) form reported in #5871. Unlike the
989+
# probe below, this is not a pin-era workaround — Athena still parses
990+
# to Anonymous on sqlglot versions that carry tobymao/sqlglot#7824.
991+
#
992+
# Anonymous is the catch-all for every unrecognised function name, and
993+
# hash_function is caller-supplied, so the name is checked rather than
994+
# assumed: an unknown hash must pass through untouched.
995+
length = _SHA2_DIGEST_LENGTHS.get(func.name.upper())
996+
if length is not None:
997+
func = exp.SHA2(this=concat, length=exp.Literal.number(length))
998+
999+
if isinstance(func, exp.SHA2) and _sha2_renders_binary(evaluator.dialect):
1000+
# Presto/Trino render a bare SHA256(varchar) for exp.SHA2 on sqlglot
1001+
# versions without tobymao/sqlglot#7824: a type error on Trino, and
1002+
# binary rather than string semantics where it runs. Build the
1003+
# hex-string form explicitly, mirroring what those generators do for
1004+
# MD5: LOWER(TO_HEX(SHA256(TO_UTF8(...)))). The probe keeps this
1005+
# branch inert once sqlglot renders the hex form natively, so the
1006+
# expression is never wrapped twice.
1007+
return exp.Lower(
1008+
this=exp.Hex(
1009+
this=exp.SHA2(
1010+
this=exp.Encode(this=func.this, charset=exp.Literal.string("utf-8")),
1011+
length=func.args.get("length"),
1012+
)
1013+
)
1014+
)
9751015

9761016
return func
9771017

9781018

1019+
# Dialects that model string and binary hashes separately, so a bare
1020+
# SHA256(varchar) is a type error rather than a hex-string surrogate key.
1021+
# Athena is on the list because it runs the Trino engine.
1022+
_PRESTO_FAMILY = frozenset({"presto", "trino", "athena"})
1023+
1024+
# The SHA-2 digest widths a surrogate key may ask for, by function name.
1025+
_SHA2_DIGEST_LENGTHS = {"SHA256": 256, "SHA512": 512}
1026+
1027+
1028+
def _is_presto_family(dialect: DialectType) -> bool:
1029+
"""Whether this dialect is Presto, Trino or Athena."""
1030+
return (str(dialect) if dialect else "").split(",")[0].strip().lower() in _PRESTO_FAMILY
1031+
1032+
1033+
@lru_cache(maxsize=None)
1034+
def _sha2_renders_binary(dialect: DialectType) -> bool:
1035+
"""Whether this dialect renders exp.SHA2 as a bare binary-semantics call.
1036+
1037+
Only the Presto family models string and binary hashes separately; other
1038+
dialects' SHA256(varchar) already returns a hex string.
1039+
"""
1040+
if not _is_presto_family(dialect):
1041+
return False
1042+
probe = exp.SHA2(this=exp.column("_sqlmesh_probe"), length=exp.Literal.number(256))
1043+
return "TO_HEX" not in probe.sql(dialect=dialect)
1044+
1045+
9791046
@macro()
9801047
def safe_add(_: MacroEvaluator, *fields: exp.Expr) -> exp.Case:
9811048
"""Adds numbers together, substitutes nulls for 0s and only returns null if all fields are null.
@@ -1379,15 +1446,17 @@ def resolve_template(
13791446
"""
13801447
Generates either a String literal or an exp.Table representing a physical table location, based on rendering the provided template String literal.
13811448
1382-
Note: It relies on the @this_model variable being available in the evaluation context (@this_model resolves to an exp.Table object
1383-
representing the current physical table).
1449+
Note: It relies on the @this_model variable being available in the evaluation context. @this_model usually resolves to an
1450+
exp.Table object representing the current physical table, but in an audit on a model with a time column it resolves to a
1451+
subquery that selects from that table and filters it down to the audited time range. In that case the placeholders below
1452+
are resolved against the physical table the subquery selects from.
13841453
Therefore, the @resolve_template macro must be used at creation or evaluation time and not at load time.
13851454
13861455
Args:
13871456
template: Template string literal. Can contain the following placeholders:
1388-
@{catalog_name} -> replaced with the catalog of the exp.Table returned from @this_model
1389-
@{schema_name} -> replaced with the schema of the exp.Table returned from @this_model
1390-
@{table_name} -> replaced with the name of the exp.Table returned from @this_model
1457+
@{catalog_name} -> replaced with the catalog of the physical table @this_model refers to
1458+
@{schema_name} -> replaced with the schema of the physical table @this_model refers to
1459+
@{table_name} -> replaced with the name of the physical table @this_model refers to
13911460
mode: What to return.
13921461
'literal' -> return an exp.Literal string
13931462
'table' -> return an exp.Table
@@ -1400,9 +1469,26 @@ def resolve_template(
14001469
>>> evaluator.locals.update({"this_model": exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")})
14011470
>>> evaluator.transform(parse_one(sql)).sql()
14021471
"'s3://data-bucket/prod/test_catalog/sqlmesh__test/test__test_model__2517971505'"
1472+
1473+
The same template resolves to the same location when @this_model is the time-filtered
1474+
subquery that audits on models with a time column receive:
1475+
1476+
>>> table = exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")
1477+
>>> subquery = exp.select("*").from_(table).where(exp.column("ds").eq("2020-01-01")).subquery()
1478+
>>> evaluator.locals.update({"this_model": subquery})
1479+
>>> evaluator.transform(parse_one(sql)).sql()
1480+
"'s3://data-bucket/prod/test_catalog/sqlmesh__test/test__test_model__2517971505'"
14031481
"""
14041482
if "this_model" in evaluator.locals:
1405-
this_model = exp.to_table(evaluator.locals["this_model"], dialect=evaluator.dialect)
1483+
this_model_expr = evaluator.locals["this_model"]
1484+
if isinstance(this_model_expr, exp.Subquery):
1485+
# Audits on models with a time column render @this_model as a subquery that filters the
1486+
# physical table on the audited time range, so resolve against the table it selects from
1487+
from_ = this_model_expr.unnest().args.get("from_")
1488+
if from_ is not None and isinstance(from_.this, exp.Table):
1489+
this_model_expr = from_.this
1490+
1491+
this_model = exp.to_table(this_model_expr, dialect=evaluator.dialect)
14061492
template_str: str = template.this
14071493
result = (
14081494
template_str.replace("@{catalog_name}", this_model.catalog)

0 commit comments

Comments
 (0)