Skip to content

Commit bdcf9d4

Browse files
authored
Merge branch 'main' into docs/duckdb-s3-compatible-endpoint
2 parents 3f66d28 + bb9f590 commit bdcf9d4

11 files changed

Lines changed: 442 additions & 122 deletions

File tree

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

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 (
@@ -811,6 +811,9 @@ def run(
811811
engine_type=self.snapshot_evaluator.adapter.dialect,
812812
state_sync_type=self.state_sync.state_type(),
813813
)
814+
snapshot_evaluator = self.snapshot_evaluator.set_correlation_id(
815+
CorrelationId.from_run_id(analytics_run_id)
816+
)
814817
self._load_materializations()
815818

816819
env_check_attempts_num = max(
@@ -863,6 +866,7 @@ def _has_environment_changed() -> bool:
863866
select_models=select_models,
864867
circuit_breaker=_has_environment_changed,
865868
no_auto_upstream=no_auto_upstream,
869+
snapshot_evaluator=snapshot_evaluator,
866870
)
867871
done = True
868872
except CircuitBreakerError:
@@ -2605,8 +2609,9 @@ def _run(
26052609
select_models: t.Optional[t.Collection[str]],
26062610
circuit_breaker: t.Optional[t.Callable[[], bool]],
26072611
no_auto_upstream: bool,
2612+
snapshot_evaluator: t.Optional[SnapshotEvaluator] = None,
26082613
) -> CompletionStatus:
2609-
scheduler = self.scheduler(environment=environment)
2614+
scheduler = self.scheduler(environment=environment, snapshot_evaluator=snapshot_evaluator)
26102615
snapshots = scheduler.snapshots
26112616

26122617
if select_models is not None:

sqlmesh/core/macros.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,15 +1379,17 @@ def resolve_template(
13791379
"""
13801380
Generates either a String literal or an exp.Table representing a physical table location, based on rendering the provided template String literal.
13811381
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).
1382+
Note: It relies on the @this_model variable being available in the evaluation context. @this_model usually resolves to an
1383+
exp.Table object representing the current physical table, but in an audit on a model with a time column it resolves to a
1384+
subquery that selects from that table and filters it down to the audited time range. In that case the placeholders below
1385+
are resolved against the physical table the subquery selects from.
13841386
Therefore, the @resolve_template macro must be used at creation or evaluation time and not at load time.
13851387
13861388
Args:
13871389
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
1390+
@{catalog_name} -> replaced with the catalog of the physical table @this_model refers to
1391+
@{schema_name} -> replaced with the schema of the physical table @this_model refers to
1392+
@{table_name} -> replaced with the name of the physical table @this_model refers to
13911393
mode: What to return.
13921394
'literal' -> return an exp.Literal string
13931395
'table' -> return an exp.Table
@@ -1400,9 +1402,26 @@ def resolve_template(
14001402
>>> evaluator.locals.update({"this_model": exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")})
14011403
>>> evaluator.transform(parse_one(sql)).sql()
14021404
"'s3://data-bucket/prod/test_catalog/sqlmesh__test/test__test_model__2517971505'"
1405+
1406+
The same template resolves to the same location when @this_model is the time-filtered
1407+
subquery that audits on models with a time column receive:
1408+
1409+
>>> table = exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")
1410+
>>> subquery = exp.select("*").from_(table).where(exp.column("ds").eq("2020-01-01")).subquery()
1411+
>>> evaluator.locals.update({"this_model": subquery})
1412+
>>> evaluator.transform(parse_one(sql)).sql()
1413+
"'s3://data-bucket/prod/test_catalog/sqlmesh__test/test__test_model__2517971505'"
14031414
"""
14041415
if "this_model" in evaluator.locals:
1405-
this_model = exp.to_table(evaluator.locals["this_model"], dialect=evaluator.dialect)
1416+
this_model_expr = evaluator.locals["this_model"]
1417+
if isinstance(this_model_expr, exp.Subquery):
1418+
# Audits on models with a time column render @this_model as a subquery that filters the
1419+
# physical table on the audited time range, so resolve against the table it selects from
1420+
from_ = this_model_expr.unnest().args.get("from_")
1421+
if from_ is not None and isinstance(from_.this, exp.Table):
1422+
this_model_expr = from_.this
1423+
1424+
this_model = exp.to_table(this_model_expr, dialect=evaluator.dialect)
14061425
template_str: str = template.this
14071426
result = (
14081427
template_str.replace("@{catalog_name}", this_model.catalog)

sqlmesh/core/model/definition.py

Lines changed: 138 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
from pathlib import Path
1010

1111
from pydantic import Field
12-
from sqlglot import diff, exp
13-
from sqlglot.diff import Insert
12+
from sqlglot import exp
1413
from sqlglot.helper import seq_get
1514
from sqlglot.optimizer.qualify_columns import quote_identifiers
1615
from sqlglot.optimizer.simplify import gen
@@ -1580,37 +1579,12 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
15801579
# Can't determine if there's a breaking change if we can't render the query.
15811580
return None
15821581

1583-
if previous_query is this_query:
1584-
edits = []
1585-
else:
1586-
edits = diff(
1587-
previous_query,
1588-
this_query,
1589-
matchings=[(previous_query, this_query)],
1590-
delta_only=True,
1591-
dialect=self.dialect if self.dialect == previous.dialect else None,
1592-
)
1593-
inserted_expressions = {e.expression for e in edits if isinstance(e, Insert)}
1594-
1595-
for edit in edits:
1596-
if not isinstance(edit, Insert):
1597-
return _additive_projection_change(previous_query, this_query, self.dialect)
1598-
1599-
expr = edit.expression
1600-
if isinstance(expr, exp.UDTF):
1601-
# projection subqueries do not change cardinality, engines don't allow these to return
1602-
# more than one row of data
1603-
parent = expr.find_ancestor(exp.Subquery)
1604-
1605-
if not parent:
1606-
return None
1607-
1608-
expr = parent
1609-
1610-
if not _is_projection(expr) and expr.parent not in inserted_expressions:
1611-
return _additive_projection_change(previous_query, this_query, self.dialect)
1582+
if previous_query is this_query or _is_only_projection_additions(
1583+
previous_query, this_query
1584+
):
1585+
return False
16121586

1613-
return False
1587+
return None
16141588

16151589
def is_metadata_only_change(self, previous: _Node) -> bool:
16161590
if self._is_metadata_only_change_cache.get(id(previous), None) is not None:
@@ -2907,12 +2881,7 @@ def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> ex
29072881
)
29082882

29092883

2910-
def _is_projection(expr: exp.Expr) -> bool:
2911-
parent = expr.parent
2912-
return isinstance(parent, exp.Select) and expr.arg_key == "expressions"
2913-
2914-
2915-
def _has_ordinal_references(query: exp.Select) -> bool:
2884+
def _has_ordinal_references(query: exp.Query) -> bool:
29162885
order = query.args.get("order")
29172886
if order and any(
29182887
isinstance(ob.this, exp.Literal) and ob.this.is_number for ob in order.expressions
@@ -2924,84 +2893,146 @@ def _has_ordinal_references(query: exp.Select) -> bool:
29242893
)
29252894

29262895

2927-
def _additive_projection_change(
2928-
previous_query: exp.Query,
2929-
this_query: exp.Query,
2930-
dialect: DialectType,
2931-
) -> t.Optional[bool]:
2932-
"""Fallback for when SQLGlot's tree diff can't express an additive projection change.
2933-
2934-
SQLGlot's diff matches nodes by structural similarity, so interchangeable leaves (e.g. two
2935-
identical ``CAST(... AS T)`` target types) can be cross-matched. Inserting a same-type cast
2936-
above an existing one therefore yields spurious ``Move`` / ``Update`` edits even though a
2937-
column was simply added to the SELECT list. In that case the edit-based check above is
2938-
inconclusive, so we verify additivity directly against the output projections.
2939-
2940-
Returns ``False`` (non-breaking) only when the change is provably additive:
2941-
* both queries are simple ``SELECT`` statements,
2942-
* everything other than the projection list is structurally identical,
2943-
* no added projection is a (potentially cardinality-changing) ``UDTF``,
2944-
* every previous projection is preserved, in order, within the new projection list, and
2945-
* no mid-list insert shifts ordinal ``ORDER BY`` / ``GROUP BY`` references.
2946-
2947-
Otherwise returns ``None`` (undetermined), preserving the conservative default.
2896+
def _has_ordinal_references_in_scope(query: exp.Select) -> bool:
2897+
"""Return whether the SELECT or any set operation it is a branch of uses ordinal references.
2898+
2899+
An ORDER BY on a UNION is attached to the set operation rather than to its branches, but its
2900+
ordinals address the branch projections positionally, so a mid-list addition shifts them too.
2901+
Ascending only while the direct parent is a set operation keeps the walk within the projection
2902+
list's own output scope: it covers chained set operations but stops at a subquery or CTE
2903+
boundary, whose ordinals refer to that enclosing scope's projections instead.
29482904
"""
2949-
# UNIONs or other query expressions, are left to the caller's conservative diff result.
2950-
if not isinstance(previous_query, exp.Select) or not isinstance(this_query, exp.Select):
2951-
return None
2905+
if _has_ordinal_references(query):
2906+
return True
2907+
2908+
parent = query.parent
2909+
while isinstance(parent, exp.SetOperation):
2910+
if _has_ordinal_references(parent):
2911+
return True
2912+
parent = parent.parent
29522913

2914+
return False
2915+
2916+
2917+
def _added_projection_preserves_cardinality(projection: exp.Expr) -> bool:
2918+
"""Return whether an added projection preserves the query's row cardinality.
2919+
2920+
A directly projected UDTF can emit multiple rows. SQLMesh treats it as safe when its nearest
2921+
subquery ancestor is contained by the added projection because engines require that projection
2922+
subquery to return at most one row.
2923+
"""
2924+
udtfs = list(projection.find_all(exp.UDTF))
2925+
if not udtfs:
2926+
return True
2927+
2928+
projection_node_ids = {id(node) for node in projection.walk()}
2929+
return all(
2930+
(subquery := udtf.find_ancestor(exp.Subquery)) is not None
2931+
and id(subquery) in projection_node_ids
2932+
for udtf in udtfs
2933+
)
2934+
2935+
2936+
def _projections_only_safely_added(previous_query: exp.Select, this_query: exp.Select) -> bool:
2937+
"""Return whether a SELECT's projections differ only through safe additions.
2938+
2939+
Every previous projection must occur unchanged and in the same order in the current list.
2940+
Unmatched current projections are additions, subject to the UDTF cardinality check. Additions
2941+
before an existing projection are unsafe when the query uses ordinal GROUP BY or ORDER BY
2942+
references because they can change which output those ordinals address.
2943+
"""
29532944
previous_projections = previous_query.expressions
29542945
this_projections = this_query.expressions
2955-
# If the new query has not gained any projections, this cannot be an additive projection-only
2956-
# change, so there is nothing for this fallback to prove.
2957-
if len(this_projections) <= len(previous_projections):
2958-
return None
2946+
this_index = 0
2947+
added_before_existing = False
2948+
2949+
# Match each previous projection to the earliest identical current projection. Any current
2950+
# projections skipped along the way are additions placed before an existing projection.
2951+
for previous_projection in previous_projections:
2952+
while (
2953+
this_index < len(this_projections)
2954+
and previous_projection != this_projections[this_index]
2955+
):
2956+
if not _added_projection_preserves_cardinality(this_projections[this_index]):
2957+
return False
29592958

2960-
# Adding a UDTF projection (e.g. EXPLODE / UNNEST) can change row cardinality, so such a
2961-
# change is not safely non-breaking even when it appears as an extra SELECT item.
2962-
for projection in this_projections:
2963-
bare = projection.this if isinstance(projection, exp.Alias) else projection
2964-
if isinstance(bare, exp.UDTF):
2965-
return None
2959+
added_before_existing = True
2960+
this_index += 1
29662961

2967-
# Everything other than the projection list must be structurally identical. Replacing each
2968-
# SELECT list with the same dummy literal lets the expression equality check focus on the
2969-
# FROM / WHERE / GROUP BY / ORDER BY / etc. parts of the query.
2970-
previous_skeleton = previous_query.copy()
2971-
this_skeleton = this_query.copy()
2972-
previous_skeleton.set("expressions", [exp.Literal.number(1)])
2973-
this_skeleton.set("expressions", [exp.Literal.number(1)])
2974-
if previous_skeleton != this_skeleton:
2975-
return None
2962+
if this_index == len(this_projections):
2963+
return False
29762964

2977-
# Every previous projection must appear, in order, within the new projection list. Comparing
2978-
# dialect-normalized SQL makes semantically equivalent projection nodes match even when the
2979-
# parser built distinct object identities.
2980-
this_projection_sql = [p.sql(dialect=dialect, comments=False) for p in this_projections]
2981-
search_start = 0
2982-
matched_at: list[int] = []
2983-
for projection in previous_projections:
2984-
target_sql = projection.sql(dialect=dialect, comments=False)
2985-
# Continue after the previous match so added columns can appear before, between, or after
2986-
# the original projections, but existing projections cannot be reordered or rewritten.
2987-
for index in range(search_start, len(this_projection_sql)):
2988-
if this_projection_sql[index] == target_sql:
2989-
matched_at.append(index)
2990-
search_start = index + 1
2991-
break
2992-
else:
2993-
return None
2965+
this_index += 1
29942966

2995-
# Mid-list inserts shift ordinal references in ORDER BY / GROUP BY clauses.
2996-
if _has_ordinal_references(this_query):
2997-
matched_set = set(matched_at)
2998-
last_matched = matched_at[-1]
2999-
if any(i < last_matched for i in range(len(this_projections)) if i not in matched_set):
3000-
return None
2967+
# Once all previous projections are matched, every remaining projection was appended, which
2968+
# leaves the positions of the existing projections untouched.
2969+
for index in range(this_index, len(this_projections)):
2970+
if not _added_projection_preserves_cardinality(this_projections[index]):
2971+
return False
30012972

3002-
# At this point the query shape is unchanged and all prior outputs are preserved, so the only
3003-
# remaining difference is one or more additional, non-UDTF projections.
3004-
return False
2973+
# Be conservative about every addition placed before an existing projection when ordinals are
2974+
# present. Determining whether a particular ordinal was shifted would couple this comparison
2975+
# to dialect-specific semantics.
2976+
return not (added_before_existing and _has_ordinal_references_in_scope(this_query))
2977+
2978+
2979+
def _is_only_projection_additions(
2980+
previous_query: exp.Query,
2981+
this_query: exp.Query,
2982+
) -> bool:
2983+
"""Return whether a query changed exclusively through safe projection additions.
2984+
2985+
The two ASTs are walked in lockstep. Node types, scalar arguments, and non-projection child
2986+
lists must match exactly. SELECT projection lists may contain additional expressions as long
2987+
as all previous projections remain unchanged and ordered and the additions pass the
2988+
cardinality and ordinal-reference safeguards.
2989+
2990+
This specialized comparison avoids the candidate matching performed by SQLGlot's general tree
2991+
diff while remaining conservative for every change other than an added projection.
2992+
"""
2993+
expression_pairs: t.List[t.Tuple[exp.Expr, exp.Expr]] = [(previous_query, this_query)]
2994+
2995+
while expression_pairs:
2996+
previous_expression, this_expression = expression_pairs.pop()
2997+
2998+
if type(previous_expression) is not type(this_expression):
2999+
return False
3000+
3001+
for arg_key in previous_expression.args.keys() | this_expression.args.keys():
3002+
previous_value = previous_expression.args.get(arg_key)
3003+
this_value = this_expression.args.get(arg_key)
3004+
3005+
if isinstance(previous_value, exp.Expr):
3006+
if not isinstance(this_value, exp.Expr):
3007+
return False
3008+
3009+
expression_pairs.append((previous_value, this_value))
3010+
elif isinstance(previous_value, list):
3011+
if not isinstance(this_value, list):
3012+
return False
3013+
3014+
if (
3015+
isinstance(previous_expression, exp.Select)
3016+
and isinstance(this_expression, exp.Select)
3017+
and arg_key == "expressions"
3018+
):
3019+
if not _projections_only_safely_added(previous_expression, this_expression):
3020+
return False
3021+
elif len(previous_value) != len(this_value):
3022+
return False
3023+
else:
3024+
for previous_item, this_item in zip(previous_value, this_value):
3025+
if isinstance(previous_item, exp.Expr):
3026+
if not isinstance(this_item, exp.Expr):
3027+
return False
3028+
3029+
expression_pairs.append((previous_item, this_item))
3030+
elif previous_item != this_item:
3031+
return False
3032+
elif previous_value != this_value:
3033+
return False
3034+
3035+
return True
30053036

30063037

30073038
def _single_expr_or_tuple(values: t.Sequence[exp.Expr]) -> exp.Expr | exp.Tuple:

0 commit comments

Comments
 (0)