@@ -1594,7 +1594,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
15941594
15951595 for edit in edits :
15961596 if not isinstance (edit , Insert ):
1597- return None
1597+ return _additive_projection_change ( previous_query , this_query , self . dialect )
15981598
15991599 expr = edit .expression
16001600 if isinstance (expr , exp .UDTF ):
@@ -1608,7 +1608,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
16081608 expr = parent
16091609
16101610 if not _is_projection (expr ) and expr .parent not in inserted_expressions :
1611- return None
1611+ return _additive_projection_change ( previous_query , this_query , self . dialect )
16121612
16131613 return False
16141614
@@ -2913,6 +2913,98 @@ def _is_projection(expr: exp.Expr) -> bool:
29132913 return isinstance (parent , exp .Select ) and expr .arg_key == "expressions"
29142914
29152915
2916+ def _has_ordinal_references (query : exp .Select ) -> bool :
2917+ order = query .args .get ("order" )
2918+ if order and any (
2919+ isinstance (ob .this , exp .Literal ) and ob .this .is_number for ob in order .expressions
2920+ ):
2921+ return True
2922+ group = query .args .get ("group" )
2923+ return bool (
2924+ group and any (isinstance (gb , exp .Literal ) and gb .is_number for gb in group .expressions )
2925+ )
2926+
2927+
2928+ def _additive_projection_change (
2929+ previous_query : exp .Query ,
2930+ this_query : exp .Query ,
2931+ dialect : DialectType ,
2932+ ) -> t .Optional [bool ]:
2933+ """Fallback for when SQLGlot's tree diff can't express an additive projection change.
2934+
2935+ SQLGlot's diff matches nodes by structural similarity, so interchangeable leaves (e.g. two
2936+ identical ``CAST(... AS T)`` target types) can be cross-matched. Inserting a same-type cast
2937+ above an existing one therefore yields spurious ``Move`` / ``Update`` edits even though a
2938+ column was simply added to the SELECT list. In that case the edit-based check above is
2939+ inconclusive, so we verify additivity directly against the output projections.
2940+
2941+ Returns ``False`` (non-breaking) only when the change is provably additive:
2942+ * both queries are simple ``SELECT`` statements,
2943+ * everything other than the projection list is structurally identical,
2944+ * no added projection is a (potentially cardinality-changing) ``UDTF``,
2945+ * every previous projection is preserved, in order, within the new projection list, and
2946+ * no mid-list insert shifts ordinal ``ORDER BY`` / ``GROUP BY`` references.
2947+
2948+ Otherwise returns ``None`` (undetermined), preserving the conservative default.
2949+ """
2950+ # UNIONs or other query expressions, are left to the caller's conservative diff result.
2951+ if not isinstance (previous_query , exp .Select ) or not isinstance (this_query , exp .Select ):
2952+ return None
2953+
2954+ previous_projections = previous_query .expressions
2955+ this_projections = this_query .expressions
2956+ # If the new query has not gained any projections, this cannot be an additive projection-only
2957+ # change, so there is nothing for this fallback to prove.
2958+ if len (this_projections ) <= len (previous_projections ):
2959+ return None
2960+
2961+ # Adding a UDTF projection (e.g. EXPLODE / UNNEST) can change row cardinality, so such a
2962+ # change is not safely non-breaking even when it appears as an extra SELECT item.
2963+ for projection in this_projections :
2964+ bare = projection .this if isinstance (projection , exp .Alias ) else projection
2965+ if isinstance (bare , exp .UDTF ):
2966+ return None
2967+
2968+ # Everything other than the projection list must be structurally identical. Replacing each
2969+ # SELECT list with the same dummy literal lets the expression equality check focus on the
2970+ # FROM / WHERE / GROUP BY / ORDER BY / etc. parts of the query.
2971+ previous_skeleton = previous_query .copy ()
2972+ this_skeleton = this_query .copy ()
2973+ previous_skeleton .set ("expressions" , [exp .Literal .number (1 )])
2974+ this_skeleton .set ("expressions" , [exp .Literal .number (1 )])
2975+ if previous_skeleton != this_skeleton :
2976+ return None
2977+
2978+ # Every previous projection must appear, in order, within the new projection list. Comparing
2979+ # dialect-normalized SQL makes semantically equivalent projection nodes match even when the
2980+ # parser built distinct object identities.
2981+ this_projection_sql = [p .sql (dialect = dialect , comments = False ) for p in this_projections ]
2982+ search_start = 0
2983+ matched_at : list [int ] = []
2984+ for projection in previous_projections :
2985+ target_sql = projection .sql (dialect = dialect , comments = False )
2986+ # Continue after the previous match so added columns can appear before, between, or after
2987+ # the original projections, but existing projections cannot be reordered or rewritten.
2988+ for index in range (search_start , len (this_projection_sql )):
2989+ if this_projection_sql [index ] == target_sql :
2990+ matched_at .append (index )
2991+ search_start = index + 1
2992+ break
2993+ else :
2994+ return None
2995+
2996+ # Mid-list inserts shift ordinal references in ORDER BY / GROUP BY clauses.
2997+ if _has_ordinal_references (this_query ):
2998+ matched_set = set (matched_at )
2999+ last_matched = matched_at [- 1 ]
3000+ if any (i < last_matched for i in range (len (this_projections )) if i not in matched_set ):
3001+ return None
3002+
3003+ # At this point the query shape is unchanged and all prior outputs are preserved, so the only
3004+ # remaining difference is one or more additional, non-UDTF projections.
3005+ return False
3006+
3007+
29163008def _single_expr_or_tuple (values : t .Sequence [exp .Expr ]) -> exp .Expr | exp .Tuple :
29173009 return values [0 ] if len (values ) == 1 else exp .Tuple (expressions = values )
29183010
0 commit comments