Skip to content

Commit 9fa4e98

Browse files
author
dirk.nilius
committed
perf: speed up SQL auto-categorization
Signed-off-by: dirk.nilius <dirk.nilius@nc-group.net>
1 parent e075200 commit 9fa4e98

2 files changed

Lines changed: 199 additions & 111 deletions

File tree

sqlmesh/core/model/definition.py

Lines changed: 125 additions & 106 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,11 +2881,6 @@ 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-
29152884
def _has_ordinal_references(query: exp.Select) -> bool:
29162885
order = query.args.get("order")
29172886
if order and any(
@@ -2924,84 +2893,134 @@ 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 _is_valid_added_projection(projection: exp.Expr) -> bool:
2897+
"""Return whether an added projection preserves the query's row cardinality.
2898+
2899+
A directly projected UDTF can emit multiple rows. SQLMesh treats it as safe when its nearest
2900+
subquery ancestor is contained by the added projection because engines require that projection
2901+
subquery to return at most one row.
29482902
"""
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
2903+
udtfs = list(projection.find_all(exp.UDTF))
2904+
if not udtfs:
2905+
return True
29522906

2907+
projection_node_ids = {id(node) for node in projection.walk()}
2908+
return all(
2909+
(subquery := udtf.find_ancestor(exp.Subquery)) is not None
2910+
and id(subquery) in projection_node_ids
2911+
for udtf in udtfs
2912+
)
2913+
2914+
2915+
def _projection_lists_match(previous_query: exp.Select, this_query: exp.Select) -> bool:
2916+
"""Return whether a SELECT's projections differ only through safe additions.
2917+
2918+
Every previous projection must occur unchanged and in the same order in the current list.
2919+
Unmatched current projections are additions, subject to the UDTF cardinality check. Additions
2920+
before an existing projection are unsafe when the query uses ordinal GROUP BY or ORDER BY
2921+
references because they can change which output those ordinals address.
2922+
"""
29532923
previous_projections = previous_query.expressions
29542924
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
2925+
this_index = 0
2926+
added_at: list[int] = []
2927+
matched_at: list[int] = []
29592928

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
2929+
# Match each previous projection to the earliest identical current projection. Any current
2930+
# projections skipped along the way are additions.
2931+
for previous_projection in previous_projections:
2932+
while (
2933+
this_index < len(this_projections)
2934+
and previous_projection != this_projections[this_index]
2935+
):
2936+
if not _is_valid_added_projection(this_projections[this_index]):
2937+
return False
29662938

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
2939+
added_at.append(this_index)
2940+
this_index += 1
29762941

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
2942+
if this_index == len(this_projections):
2943+
return False
2944+
2945+
matched_at.append(this_index)
2946+
this_index += 1
2947+
2948+
# Once all previous projections are matched, every remaining projection was appended.
2949+
for index in range(this_index, len(this_projections)):
2950+
if not _is_valid_added_projection(this_projections[index]):
2951+
return False
2952+
added_at.append(index)
2953+
2954+
# Be conservative about every mid-list addition when ordinals are present. Determining whether
2955+
# a particular ordinal was shifted would couple this comparison to dialect-specific semantics.
2956+
if (
2957+
added_at
2958+
and matched_at
2959+
and _has_ordinal_references(this_query)
2960+
and any(index < matched_at[-1] for index in added_at)
2961+
):
2962+
return False
2963+
2964+
return True
29942965

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
30012966

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
2967+
def _is_only_projection_additions(
2968+
previous_query: exp.Query,
2969+
this_query: exp.Query,
2970+
) -> bool:
2971+
"""Return whether a query changed exclusively through safe projection additions.
2972+
2973+
The two ASTs are walked in lockstep. Node types, scalar arguments, and non-projection child
2974+
lists must match exactly. SELECT projection lists may contain additional expressions as long
2975+
as all previous projections remain unchanged and ordered and the additions pass the
2976+
cardinality and ordinal-reference safeguards.
2977+
2978+
This specialized comparison avoids the candidate matching performed by SQLGlot's general tree
2979+
diff while remaining conservative for every change other than an added projection.
2980+
"""
2981+
expression_pairs: t.List[t.Tuple[exp.Expr, exp.Expr]] = [(previous_query, this_query)]
2982+
2983+
while expression_pairs:
2984+
previous_expression, this_expression = expression_pairs.pop()
2985+
2986+
if type(previous_expression) is not type(this_expression):
2987+
return False
2988+
2989+
for arg_key in previous_expression.args.keys() | this_expression.args.keys():
2990+
previous_value = previous_expression.args.get(arg_key)
2991+
this_value = this_expression.args.get(arg_key)
2992+
2993+
if isinstance(previous_value, exp.Expr):
2994+
if not isinstance(this_value, exp.Expr):
2995+
return False
2996+
2997+
expression_pairs.append((previous_value, this_value))
2998+
elif isinstance(previous_value, list):
2999+
if not isinstance(this_value, list):
3000+
return False
3001+
3002+
if (
3003+
isinstance(previous_expression, exp.Select)
3004+
and isinstance(this_expression, exp.Select)
3005+
and arg_key == "expressions"
3006+
):
3007+
if not _projection_lists_match(previous_expression, this_expression):
3008+
return False
3009+
elif len(previous_value) != len(this_value):
3010+
return False
3011+
else:
3012+
for previous_item, this_item in zip(previous_value, this_value):
3013+
if isinstance(previous_item, exp.Expr):
3014+
if not isinstance(this_item, exp.Expr):
3015+
return False
3016+
3017+
expression_pairs.append((previous_item, this_item))
3018+
elif previous_item != this_item:
3019+
return False
3020+
elif previous_value != this_value:
3021+
return False
3022+
3023+
return True
30053024

30063025

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

tests/core/test_snapshot.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,10 +1633,7 @@ def test_categorize_change_sql(make_snapshot):
16331633
)
16341634

16351635

1636-
def test_categorize_change_sql_redundant_cast(make_snapshot):
1637-
# Adding a column with a redundant CAST above an existing same-type cast makes SQLGlot's tree
1638-
# diff emit spurious Move/Update edits (interchangeable DataType leaves get cross-matched),
1639-
# even though the change is purely an added projection. These must remain NON_BREAKING.
1636+
def test_categorize_change_sql_additive_projection_edge_cases(make_snapshot):
16401637
config = CategorizerConfig(sql=AutoCategorizationMode.SEMI)
16411638

16421639
old_snapshot = make_snapshot(
@@ -1655,6 +1652,18 @@ def test_categorize_change_sql_redundant_cast(make_snapshot):
16551652
== SnapshotChangeCategory.NON_BREAKING
16561653
)
16571654

1655+
# An added projection identical to an existing projection remains non-breaking.
1656+
assert (
1657+
categorize_change(
1658+
new=make_snapshot(
1659+
SqlModel(name="a", query=parse_one("SELECT s::TEXT, a::DATE, s::TEXT FROM t"))
1660+
),
1661+
old=old_snapshot,
1662+
config=config,
1663+
)
1664+
== SnapshotChangeCategory.NON_BREAKING
1665+
)
1666+
16581667
# Multiple same-type cast columns have been inserted mid-list.
16591668
assert (
16601669
categorize_change(
@@ -1771,7 +1780,7 @@ def test_categorize_change_sql_redundant_cast(make_snapshot):
17711780
is None
17721781
)
17731782

1774-
# Aliased UDTF projection via fallback path: undetermined.
1783+
# Aliased UDTF projection: undetermined.
17751784
assert (
17761785
categorize_change(
17771786
new=make_snapshot(
@@ -1810,6 +1819,66 @@ def test_categorize_change_sql_redundant_cast(make_snapshot):
18101819
)
18111820

18121821

1822+
@pytest.mark.parametrize(
1823+
("previous_query", "this_query", "expected"),
1824+
[
1825+
pytest.param(
1826+
"WITH x AS (SELECT a FROM t) SELECT a FROM x",
1827+
"WITH x AS (SELECT a, b FROM t) SELECT a FROM x",
1828+
SnapshotChangeCategory.NON_BREAKING,
1829+
id="cte",
1830+
),
1831+
pytest.param(
1832+
"SELECT a FROM t WHERE EXISTS (SELECT x FROM u)",
1833+
"SELECT a FROM t WHERE EXISTS (SELECT x, y FROM u)",
1834+
SnapshotChangeCategory.NON_BREAKING,
1835+
id="exists",
1836+
),
1837+
pytest.param(
1838+
"SELECT (SELECT a FROM t) AS x",
1839+
"SELECT (SELECT a, b FROM t) AS x",
1840+
None,
1841+
id="scalar-subquery-projection",
1842+
),
1843+
pytest.param(
1844+
"SELECT a FROM x UNION ALL SELECT a FROM y",
1845+
"SELECT a, b FROM x UNION ALL SELECT a, b FROM y",
1846+
SnapshotChangeCategory.NON_BREAKING,
1847+
id="union",
1848+
),
1849+
pytest.param(
1850+
"WITH x AS (SELECT a::DATE, s::TEXT FROM t ORDER BY 2) SELECT * FROM x",
1851+
"WITH x AS (SELECT a::DATE, x::TEXT, s::TEXT FROM t ORDER BY 2) SELECT * FROM x",
1852+
None,
1853+
id="nested-order-by-ordinal",
1854+
),
1855+
pytest.param(
1856+
"SELECT a FROM (SELECT x FROM t) AS nested",
1857+
"SELECT a FROM (SELECT x, EXPLODE(y) FROM t) AS nested",
1858+
None,
1859+
id="derived-table-udtf",
1860+
),
1861+
],
1862+
)
1863+
def test_categorize_change_sql_nested_projection_additions(
1864+
make_snapshot,
1865+
previous_query,
1866+
this_query,
1867+
expected,
1868+
):
1869+
config = CategorizerConfig(sql=AutoCategorizationMode.SEMI)
1870+
old_snapshot = make_snapshot(SqlModel(name="a", query=parse_one(previous_query)))
1871+
1872+
assert (
1873+
categorize_change(
1874+
new=make_snapshot(SqlModel(name="a", query=parse_one(this_query))),
1875+
old=old_snapshot,
1876+
config=config,
1877+
)
1878+
is expected
1879+
)
1880+
1881+
18131882
def test_categorize_change_seed(make_snapshot, tmp_path):
18141883
config = CategorizerConfig(seed=AutoCategorizationMode.SEMI)
18151884
model_name = "test_db.test_seed_model"

0 commit comments

Comments
 (0)