@@ -1588,7 +1588,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
15881588
15891589 for edit in edits :
15901590 if not isinstance (edit , Insert ):
1591- return None
1591+ return _additive_projection_change ( previous_query , this_query , self . dialect )
15921592
15931593 expr = edit .expression
15941594 if isinstance (expr , exp .UDTF ):
@@ -1602,7 +1602,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
16021602 expr = parent
16031603
16041604 if not _is_projection (expr ) and expr .parent not in inserted_expressions :
1605- return None
1605+ return _additive_projection_change ( previous_query , this_query , self . dialect )
16061606
16071607 return False
16081608
@@ -2907,6 +2907,75 @@ def _is_projection(expr: exp.Expr) -> bool:
29072907 return isinstance (parent , exp .Select ) and expr .arg_key == "expressions"
29082908
29092909
2910+ def _additive_projection_change (
2911+ previous_query : exp .Query ,
2912+ this_query : exp .Query ,
2913+ dialect : DialectType ,
2914+ ) -> t .Optional [bool ]:
2915+ """Fallback for when SQLGlot's tree diff can't express an additive projection change.
2916+
2917+ SQLGlot's diff matches nodes by structural similarity, so interchangeable leaves (e.g. two
2918+ identical ``CAST(... AS T)`` target types) can be cross-matched. Inserting a same-type cast
2919+ above an existing one therefore yields spurious ``Move`` / ``Update`` edits even though a
2920+ column was simply added to the SELECT list. In that case the edit-based check above is
2921+ inconclusive, so we verify additivity directly against the output projections.
2922+
2923+ Returns ``False`` (non-breaking) only when the change is provably additive:
2924+ * both queries are simple ``SELECT`` statements,
2925+ * everything other than the projection list is structurally identical,
2926+ * no added projection is a (potentially cardinality-changing) ``UDTF``, and
2927+ * every previous projection is preserved, in order, within the new projection list.
2928+
2929+ Otherwise returns ``None`` (undetermined), preserving the conservative default.
2930+ """
2931+ # UNIONs or other query expressions, are left to the caller's conservative diff result.
2932+ if not isinstance (previous_query , exp .Select ) or not isinstance (this_query , exp .Select ):
2933+ return None
2934+
2935+ previous_projections = previous_query .expressions
2936+ this_projections = this_query .expressions
2937+ # If the new query has not gained any projections, this cannot be an additive projection-only
2938+ # change, so there is nothing for this fallback to prove.
2939+ if len (this_projections ) <= len (previous_projections ):
2940+ return None
2941+
2942+ # Adding a UDTF projection (e.g. EXPLODE / UNNEST) can change row cardinality, so such a
2943+ # change is not safely non-breaking even when it appears as an extra SELECT item.
2944+ for projection in this_projections :
2945+ if isinstance (projection , exp .UDTF ) and not projection .find_ancestor (exp .Subquery ):
2946+ return None
2947+
2948+ # Everything other than the projection list must be structurally identical. Replacing each
2949+ # SELECT list with the same dummy literal lets the expression equality check focus on the
2950+ # FROM / WHERE / GROUP BY / ORDER BY / etc. parts of the query.
2951+ previous_skeleton = previous_query .copy ()
2952+ this_skeleton = this_query .copy ()
2953+ previous_skeleton .set ("expressions" , [exp .Literal .number (1 )])
2954+ this_skeleton .set ("expressions" , [exp .Literal .number (1 )])
2955+ if previous_skeleton != this_skeleton :
2956+ return None
2957+
2958+ # Every previous projection must appear, in order, within the new projection list. Comparing
2959+ # dialect-normalized SQL makes semantically equivalent projection nodes match even when the
2960+ # parser built distinct object identities.
2961+ this_projection_sql = [p .sql (dialect = dialect , comments = False ) for p in this_projections ]
2962+ search_start = 0
2963+ for projection in previous_projections :
2964+ target_sql = projection .sql (dialect = dialect , comments = False )
2965+ # Continue after the previous match so added columns can appear before, between, or after
2966+ # the original projections, but existing projections cannot be reordered or rewritten.
2967+ for index in range (search_start , len (this_projection_sql )):
2968+ if this_projection_sql [index ] == target_sql :
2969+ search_start = index + 1
2970+ break
2971+ else :
2972+ return None
2973+
2974+ # At this point the query shape is unchanged and all prior outputs are preserved, so the only
2975+ # remaining difference is one or more additional, non-UDTF projections.
2976+ return False
2977+
2978+
29102979def _single_expr_or_tuple (values : t .Sequence [exp .Expr ]) -> exp .Expr | exp .Tuple :
29112980 return values [0 ] if len (values ) == 1 else exp .Tuple (expressions = values )
29122981
0 commit comments