Skip to content

Commit 3b813af

Browse files
author
dirk.nilius
committed
fix(model): honor set-operation ordinals when categorizing projection additions
Signed-off-by: dirk.nilius <dirk.nilius@nc-group.net>
1 parent 31c86e6 commit 3b813af

2 files changed

Lines changed: 60 additions & 24 deletions

File tree

sqlmesh/core/model/definition.py

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2881,7 +2881,7 @@ def _list_of_calls_to_exp(value: t.List[t.Tuple[str, t.Dict[str, t.Any]]]) -> ex
28812881
)
28822882

28832883

2884-
def _has_ordinal_references(query: exp.Select) -> bool:
2884+
def _has_ordinal_references(query: exp.Query) -> bool:
28852885
order = query.args.get("order")
28862886
if order and any(
28872887
isinstance(ob.this, exp.Literal) and ob.this.is_number for ob in order.expressions
@@ -2893,7 +2893,28 @@ def _has_ordinal_references(query: exp.Select) -> bool:
28932893
)
28942894

28952895

2896-
def _is_valid_added_projection(projection: exp.Expr) -> bool:
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.
2904+
"""
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
2913+
2914+
return False
2915+
2916+
2917+
def _added_projection_preserves_cardinality(projection: exp.Expr) -> bool:
28972918
"""Return whether an added projection preserves the query's row cardinality.
28982919
28992920
A directly projected UDTF can emit multiple rows. SQLMesh treats it as safe when its nearest
@@ -2912,7 +2933,7 @@ def _is_valid_added_projection(projection: exp.Expr) -> bool:
29122933
)
29132934

29142935

2915-
def _projection_lists_match(previous_query: exp.Select, this_query: exp.Select) -> bool:
2936+
def _projections_only_safely_added(previous_query: exp.Select, this_query: exp.Select) -> bool:
29162937
"""Return whether a SELECT's projections differ only through safe additions.
29172938
29182939
Every previous projection must occur unchanged and in the same order in the current list.
@@ -2923,45 +2944,36 @@ def _projection_lists_match(previous_query: exp.Select, this_query: exp.Select)
29232944
previous_projections = previous_query.expressions
29242945
this_projections = this_query.expressions
29252946
this_index = 0
2926-
added_at: list[int] = []
2927-
matched_at: list[int] = []
2947+
added_before_existing = False
29282948

29292949
# Match each previous projection to the earliest identical current projection. Any current
2930-
# projections skipped along the way are additions.
2950+
# projections skipped along the way are additions placed before an existing projection.
29312951
for previous_projection in previous_projections:
29322952
while (
29332953
this_index < len(this_projections)
29342954
and previous_projection != this_projections[this_index]
29352955
):
2936-
if not _is_valid_added_projection(this_projections[this_index]):
2956+
if not _added_projection_preserves_cardinality(this_projections[this_index]):
29372957
return False
29382958

2939-
added_at.append(this_index)
2959+
added_before_existing = True
29402960
this_index += 1
29412961

29422962
if this_index == len(this_projections):
29432963
return False
29442964

2945-
matched_at.append(this_index)
29462965
this_index += 1
29472966

2948-
# Once all previous projections are matched, every remaining projection was appended.
2967+
# Once all previous projections are matched, every remaining projection was appended, which
2968+
# leaves the positions of the existing projections untouched.
29492969
for index in range(this_index, len(this_projections)):
2950-
if not _is_valid_added_projection(this_projections[index]):
2970+
if not _added_projection_preserves_cardinality(this_projections[index]):
29512971
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
29632972

2964-
return True
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))
29652977

29662978

29672979
def _is_only_projection_additions(
@@ -3004,7 +3016,7 @@ def _is_only_projection_additions(
30043016
and isinstance(this_expression, exp.Select)
30053017
and arg_key == "expressions"
30063018
):
3007-
if not _projection_lists_match(previous_expression, this_expression):
3019+
if not _projections_only_safely_added(previous_expression, this_expression):
30083020
return False
30093021
elif len(previous_value) != len(this_value):
30103022
return False

tests/core/test_snapshot.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,6 +1858,30 @@ def test_categorize_change_sql_additive_projection_edge_cases(make_snapshot):
18581858
None,
18591859
id="derived-table-udtf",
18601860
),
1861+
pytest.param(
1862+
"SELECT a, c FROM t ORDER BY 2",
1863+
"SELECT a, b, c FROM t ORDER BY 2",
1864+
None,
1865+
id="order-by-ordinal",
1866+
),
1867+
pytest.param(
1868+
"SELECT a, c FROM x UNION ALL SELECT a, c FROM y ORDER BY 2",
1869+
"SELECT a, b, c FROM x UNION ALL SELECT a, b, c FROM y ORDER BY 2",
1870+
None,
1871+
id="union-order-by-ordinal",
1872+
),
1873+
pytest.param(
1874+
"SELECT a, c FROM x UNION ALL SELECT a, c FROM y UNION ALL SELECT a, c FROM z ORDER BY 2",
1875+
"SELECT a, b, c FROM x UNION ALL SELECT a, b, c FROM y UNION ALL SELECT a, b, c FROM z ORDER BY 2",
1876+
None,
1877+
id="nested-union-order-by-ordinal",
1878+
),
1879+
pytest.param(
1880+
"SELECT a, c FROM x UNION ALL SELECT a, c FROM y ORDER BY 2",
1881+
"SELECT a, c, b FROM x UNION ALL SELECT a, c, b FROM y ORDER BY 2",
1882+
SnapshotChangeCategory.NON_BREAKING,
1883+
id="union-order-by-ordinal-append",
1884+
),
18611885
],
18621886
)
18631887
def test_categorize_change_sql_nested_projection_additions(

0 commit comments

Comments
 (0)