HyukjinKwon commented on code in PR #59299:
URL: https://github.com/apache/spark/pull/59299#discussion_r4285347256


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveSchemaEvolution.scala:
##########
@@ -95,7 +96,12 @@ object ResolveSchemaEvolution extends Rule[LogicalPlan] {
       plan: T,
       oldOutput: Seq[Attribute],
       newOutput: Seq[Attribute]): T = {
-    val attrMap = AttributeMap(oldOutput.zip(newOutput))
+    // Match attributes by name rather than position: schema changes may add 
fields at
+    // any position, shifting the ordinal of following columns.
+    val resolver = conf.resolver
+    val attrMap = AttributeMap(oldOutput.flatMap { oldAttr =>

Review Comment:
   **Blocking (P1):** With `spark.sql.schemaEvolution.preserveColumnOrder` on, 
`assignAddPositions` can insert a nested field ahead of existing siblings, but 
this rebinding only swaps top-level attributes. `QueryPlan.rewriteAttrs` 
replaces `AttributeReference`s only, so a `GetStructField` over a target column 
that was resolved before evolution keeps its old ordinal, and `GetStructField` 
reads `childSchema(ordinal)`.
   
   Two kinds of expression are affected. One is MERGE `ON`, `WHEN MATCHED AND` 
and `WHEN NOT MATCHED BY SOURCE AND` conditions, which are resolved in the same 
`ResolveReferences` pass that expands the stars `ResolveSchemaEvolution` waits 
for. The other is the `deleteExpr` of `INSERT WITH SCHEMA EVOLUTION ... REPLACE 
WHERE`.
   
   For example, take a target `info struct<salary int, status string>` and a 
source `info struct<salary int, bonus int, status string>`:
   
   ```sql
   MERGE WITH SCHEMA EVOLUTION INTO t USING s ON t.pk = s.pk
   WHEN MATCHED AND t.info.status = 'active' THEN UPDATE SET *
   WHEN NOT MATCHED THEN INSERT *
   ```
   
   The condition is bound as `GetStructField(info, 1, "status")`. After `ADD 
info.bonus AFTER salary`, ordinal 1 is `bonus`, so the condition compares 
`bonus` with `'active'` and fails with an unrelated cast error. If the inserted 
field has the same type as the shifted one (say a new `region string` ahead of 
`status string`), the condition silently reads the new, all-NULL field, and the 
MERGE updates or deletes the wrong rows. With the flag off, adds are appended 
and no ordinal shifts, so the default path is unaffected. None of the new tests 
uses a clause condition or a nested predicate, so nothing catches this.
   
   **Recommended change:** After the attribute rebinding in replaceWriteTarget, 
run a bottom-up expression transform over the rewritten write node. It 
re-derives each GetStructField ordinal from its recorded field name against the 
child's current struct type, and each GetArrayStructFields 
field/ordinal/numFields from the element struct, using the session resolver. 
Then add flag-on MERGE and INSERT REPLACE WHERE schema-evolution tests in which 
a condition or predicate reads an existing nested target field placed after a 
positioned nested add, with both a different-typed and a same-typed inserted 
field.
   
   **Why this works:** Running child-first means each extraction sees its 
child's already-rebound type, so chained paths such as info.addr.zip resolve 
level by level. Looking the field up by its recorded name restores the binding 
the analyzer chose originally. When the ordinal already matches (flag off, or 
appended adds), the transform is a no-op, so the top-level by-name map plus 
this nested pass together preserve every pre-evolution binding.
   
   **Scope:** Make post-evolution rebinding preserve nested field bindings, not 
just top-level attribute identity, and add regression tests for nested target 
references after positioned nested adds.
   
   **Compatibility:** The flag-off path and the top-level by-name attribute 
rebinding stay unchanged. Positions continue to be assigned after deduplication 
and connector filtering.
   
   **Risks:** A transform that rebinds extractions on the source side, or on 
non-target children, must leave them unchanged. Rebinding by recorded name is a 
no-op when the ordinal already matches, but it must not raise for extractions 
whose child type did not change. Ambiguous case-insensitive names in an evolved 
struct must surface the existing ambiguity error rather than picking an 
arbitrary field.
   
   **Constraints:** Use the session resolver, consistent with the by-name 
top-level rebinding and findNestedField. Leave GetStructField with name = None 
unchanged. With spark.sql.schemaEvolution.preserveColumnOrder off, the analyzed 
plans and results of existing schema-evolution statements must not change.
   
   **Success:** After a positioned nested add, every target-side field 
extraction resolved before evolution reads the same named field it read before 
evolution. A flag-on MERGE whose WHEN MATCHED AND condition reads info.status, 
with a field inserted ahead of status, updates exactly the rows whose status 
matches. A flag-on by-name INSERT ... REPLACE WHERE whose predicate reads an 
existing nested field after a positioned nested add deletes and replaces 
exactly the matching rows. With the flag off, schema-evolution plans and 
results are unchanged.
   
   **Verification:**
   - **Regression:** A flag-on MERGE with a WHEN MATCHED AND condition on an 
existing nested target field placed after a positioned nested add of a 
different type evaluates the intended field and produces the expected rows 
instead of a cast or type error.
   - **Regression:** The same MERGE where the inserted nested field has the 
same type as the referenced field updates exactly the rows whose referenced 
field matches, proving the condition does not silently read the inserted field.
   - **Regression:** A flag-on by-name INSERT WITH SCHEMA EVOLUTION ... REPLACE 
WHERE with a predicate on an existing nested field after a positioned nested 
add replaces exactly the rows matching that field.
   - **Behavior:** A condition that reads a field of an array-of-struct column 
after a positioned add inside the element struct still reads the intended 
element field.
   - **Compatibility:** Existing flag-off and preserve-order MERGE and INSERT 
schema-evolution cases keep their expected schemas and rows.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to