szehon-ho commented on code in PR #57722:
URL: https://github.com/apache/spark/pull/57722#discussion_r3715502969


##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -626,21 +628,28 @@ object DatasetManager extends Logging {
    * @param mergeWithExistingSchema whether the effective schema is the merge 
of the existing and
    *                                desired schemas (additive evolution) 
rather than the desired
    *                                schema as-is.
+   * @param caseSensitive           whether schema evolution treats field 
names differing only in
+   *                                case as distinct columns. Threaded from 
the session's
+   *                                `spark.sql.caseSensitive` so evolution 
matches how the rest of
+   *                                the engine resolves the same names; when 
`false`, an incoming
+   *                                column differing from an existing one only 
in case is folded
+   *                                onto it rather than added as a duplicate.
    */
   private def evolveTable(
       catalog: TableCatalog,
       tableIdentifier: Identifier,
       existingTable: V2Table,
       desiredSchema: StructType,
       properties: Map[String, String],
-      mergeWithExistingSchema: Boolean): Unit = {
+      mergeWithExistingSchema: Boolean,
+      caseSensitive: Boolean): Unit = {
     val currentSchema = v2ColumnsToStructType(existingTable.columns())
     val targetSchema = if (mergeWithExistingSchema) {
-      SchemaMergingUtils.mergeSchemas(currentSchema, desiredSchema)
+      SchemaMergingUtils.mergeSchemas(currentSchema, desiredSchema, 
caseSensitive)
     } else {
       desiredSchema
     }
-    val columnChanges = diffSchemas(currentSchema, targetSchema)
+    val columnChanges = diffSchemas(currentSchema, targetSchema, caseSensitive)

Review Comment:
   I think passing `caseSensitive` here changes behavior for materialized views 
in a way the PR doesn't mention. Concretely:
   
   ```sql
   -- pipeline source, v1
   CREATE MATERIALIZED VIEW daily_totals AS
     SELECT id, total FROM events;
   ```
   
   Materializes `daily_totals(id INT, total BIGINT)`. The author then renames 
the column's casing:
   
   ```sql
   -- pipeline source, v2
   CREATE MATERIALIZED VIEW daily_totals AS
     SELECT id, total AS Total FROM events;
   ```
   
   In 4.1.0 the next update dropped `total` and added `Total`, so the table 
matched the definition. With this change, no `ALTER` is emitted at all and the 
table keeps `total` -- permanently, since nothing on this path ever rebuilds 
it. The definition says `Total`, `DESCRIBE TABLE` says `total`, and there's no 
error pointing at the discrepancy. A colleague who runs the same definition 
against a fresh table gets `Total`, because `createTable` persists the declared 
casing verbatim -- so the same source yields different column names depending 
on whether the table already existed.
   
   The detail: for an MV (and for any full refresh) `targetSchema` is the run's 
schema as-is, with no merge, so case-insensitive matching makes a case-only 
rename invisible. That's also why it can't help the case this PR fixes -- on 
the incremental path `StructType.merge` has already replaced the incoming name 
with the persisted one, so a case-differing pair never reaches this call. 
Dropping the third argument leaves your `mergeSchemas` fix fully intact.
   
   Is the frozen casing intended? If so, could we make the create path agree, 
say so in the `evolveTable` doc, and add a test? If not, dropping the argument 
here restores the previous behavior.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DataflowGraph.scala:
##########
@@ -177,7 +177,7 @@ case class DataflowGraph(
         .map { flow =>
           resolvedFlow(flow.identifier).schema
         }
-        .reduce(SchemaMergingUtils.mergeSchemas)
+        .reduce(SchemaMergingUtils.mergeSchemas(_, _))

Review Comment:
   You already flagged that multi-flow inference still merges case-sensitively. 
Here's the end-to-end it produces, which also turns up a second problem.
   
   Two teams append to a shared events table. An earlier version of the source 
selected only `id`, so the table materialized as `events(id INT)`. Then each 
team adds a field, and they spell it differently:
   
   ```sql
   CREATE STREAMING TABLE events AS
     SELECT id, value FROM STREAM src_a;            -- value is STRING
   
   CREATE FLOW events_extra AS INSERT INTO events BY NAME
     SELECT id, Value FROM STREAM src_b;            -- Value is INT
   ```
   
   The next update leaves the table as `events(id INT, Value INT)`: one INT 
column, and the STRING column silently never created. `events_extra` writes 
fine, while the other flow's `value` STRING resolves case-insensitively onto 
the INT column and fails on the type. Which of the two survives depends on 
field order, so swapping which query defines the table flips which flow breaks. 
And once `Value INT` is persisted, correcting both flows to `value STRING` 
makes the merge reject INT against STRING on every later update, so recovery 
needs a full refresh.
   
   Worth noting that if both teams had written `value`, inference would have 
caught this immediately with a clear `unableToInferSchema` naming the 
STRING/INT conflict. Only the case difference gets through.
   
   The detail: inference merges case-sensitively, so both spellings reach the 
output schema; `mergeSchemas` appends both, since neither case-matches the 
existing `id`; then `diffSchemas` collapses them, because `getFieldMap` keys on 
the lower-cased name and `.toMap` lets the last field win. Threading the 
session conf into `inferredSchema` / `inferSchemaFromFlows` fixes the root 
cause -- the two fold, the type conflict surfaces at validation before the 
catalog is touched, and the same-type variant simply works.
   
   Separately, should `diffSchemas` ever silently pick one of two colliding 
columns? That stays reachable even with inference fixed: 
`CreateStreamingTableHandler` takes `cst.columns` verbatim and nothing checks 
for duplicate column names, so a declared `(id INT, value STRING, Value 
STRING)` reaches it too. Either `getFieldMap` should fail on a duplicate 
normalized name, or the `caseSensitive` parameter should come off `diffSchemas` 
entirely -- which would also address my other comment.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -397,7 +397,8 @@ object DatasetManager extends Logging {
           existingTable = existingTable,
           desiredSchema = outputSchema,
           properties = mergedProperties,
-          mergeWithExistingSchema = isTableIncrementallyUpdated
+          mergeWithExistingSchema = isTableIncrementallyUpdated,
+          caseSensitive = context.spark.sessionState.conf.caseSensitiveAnalysis

Review Comment:
   Suggestion: take this from the pipeline's conf rather than the session's.
   
   A pipeline can set `spark.sql.caseSensitive` for itself, and `SET` in 
pipeline source doesn't touch the session -- `SqlGraphRegistrationContext` 
records it, `GraphRegistrationContext.registerFlow` folds it into each flow's 
`sqlConf` (`defaultSqlConf ++ flowDef.sqlConf`), and it's applied when the flow 
is analyzed (`FlowAnalysis.scala:260`) and executed 
(`FlowExecution.scala:214`). So the two can disagree:
   
   ```sql
   SET spark.sql.caseSensitive = true;
   
   CREATE STREAMING TABLE events AS
     SELECT id, Value FROM STREAM src;
   ```
   
   With `events(id INT, value STRING)` already materialized, the pipeline has 
explicitly asked for `Value` to be a distinct column, and that's what happened 
before this PR. Now evolution reads the session's default `false`, folds 
`Value` onto the existing `value`, and emits no change -- after which the flow, 
which does resolve case-sensitively, can't find `Value` and the write fails.
   
   The flows writing to this table already carry the effective value in 
`flow.sqlConf`, so reading it from there (or from the pipeline's default conf) 
would keep evolution consistent with the schemas it is evolving.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtils.scala:
##########
@@ -112,25 +114,46 @@ object SchemaInferenceUtils {
    *
    * @param currentSchema The current schema of the table
    * @param targetSchema The target schema that we want the table to have
+   * @param caseSensitive Whether two field names that differ only in case 
identify distinct
+   *                      columns. When `false` (mirroring a case-insensitive 
session), a target
+   *                      field is matched to the current field it differs 
from only in case -- so
+   *                      it is treated as the same column (an in-place update 
against the current
+   *                      column's name) rather than a spurious drop-then-add. 
Callers on a
+   *                      schema-evolution path should pass the session's 
`spark.sql.caseSensitive`;
+   *                      the default `true` preserves the historical 
case-sensitive behavior.
    * @return A sequence of TableChange objects representing the necessary 
changes
    */
-  def diffSchemas(currentSchema: StructType, targetSchema: StructType): 
Seq[TableChange] = {
+  def diffSchemas(
+      currentSchema: StructType,
+      targetSchema: StructType,
+      caseSensitive: Boolean = true): Seq[TableChange] = {
     val changes = scala.collection.mutable.ArrayBuffer.empty[TableChange]
 
-    // Helper function to get a map of field name to field
+    // Normalize a field name to its lookup key: identity when case-sensitive, 
lower-cased when not,
+    // so that a target field is matched to the current field it differs from 
only in case. Lower-
+    // case with Locale.ROOT to match StructType.merge and Spark's analyzer 
resolver; a locale-

Review Comment:
   Nit: suggest dropping "and Spark's analyzer resolver" here -- matching 
`StructType.merge` / `fieldsMap` is the accurate claim, and the right one.
   
   The resolver is `equalsIgnoreCase` (`analysis/package.scala:40`), which 
isn't the same predicate as folding both sides with `toLowerCase(Locale.ROOT)`; 
they disagree where case mapping isn't 1:1, e.g. `equalsIgnoreCase("İ", "i")` 
is true but the folded forms aren't equal. No code change needed -- just so a 
future reader doesn't rely on the parity.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaMergingUtils.scala:
##########
@@ -20,7 +20,24 @@ package org.apache.spark.sql.pipelines.util
 import org.apache.spark.sql.types.StructType
 
 object SchemaMergingUtils {
-  def mergeSchemas(tableSchema: StructType, dataSchema: StructType): 
StructType = {
-    StructType.merge(tableSchema, dataSchema).asInstanceOf[StructType]
+
+  /**
+   * Additively merges `dataSchema` into `tableSchema`, returning a schema 
that is the union of the
+   * two (recursing into nested structs/arrays). On a field present in both, 
`tableSchema`'s name
+   * and position win; `dataSchema` only contributes fields absent from 
`tableSchema`.
+   *
+   * @param caseSensitive whether two field names that differ only in case are 
considered distinct.
+   *                      When `false` (mirroring a case-insensitive session), 
`dataSchema`'s field
+   *                      is folded onto the matching `tableSchema` field 
rather than added as a
+   *                      separate, case-differing column. Callers on a 
schema-evolution path should
+   *                      pass the session's `spark.sql.caseSensitive`; the 
default `true` preserves
+   *                      the historical behavior for callers that 
intentionally merge case
+   *                      sensitively.
+   */
+  def mergeSchemas(
+      tableSchema: StructType,
+      dataSchema: StructType,
+      caseSensitive: Boolean = true): StructType = {

Review Comment:
   Would it be worth dropping the `= true` default here and on `diffSchemas`, 
and passing the flag explicitly at every call site?
   
   The two places that still merge case-sensitively are exactly the ones that 
silently inherited this default rather than choosing it. Without a default 
they'd have been compile errors, and each site's choice would be visible in 
review. The `mergeSchemas(_, _)` eta-expansion this PR had to add at 
`DataflowGraph.scala:180` exists only to preserve the default, which points the 
same way.



-- 
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