99from pathlib import Path
1010
1111from pydantic import Field
12- from sqlglot import diff , exp
13- from sqlglot .diff import Insert
12+ from sqlglot import exp
1413from sqlglot .helper import seq_get
1514from sqlglot .optimizer .qualify_columns import quote_identifiers
1615from 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-
29152884def _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
30073026def _single_expr_or_tuple (values : t .Sequence [exp .Expr ]) -> exp .Expr | exp .Tuple :
0 commit comments