naveenp2708 commented on code in PR #57644:
URL: https://github.com/apache/spark/pull/57644#discussion_r4371787336


##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/AutoCdcReservedNames.scala:
##########
@@ -40,4 +45,280 @@ private[pipelines] object AutoCdcReservedNames {
    * Note that the schema of the CDC metadata column however can and does 
differ on the SCD-type.
    */
   val cdcMetadataColName: String = s"${prefix}metadata"
+
+  // A field is engine-owned when its name carries the reserved prefix. 
Matching goes through the
+  // caller's `resolver` so a user-declared column is still recognized as 
reserved under
+  // case-insensitive analysis, and the length guard keeps `substring` safe 
for shorter names.
+  private def isReservedFieldName(name: String, resolver: Resolver): Boolean =
+    name.length >= prefix.length && resolver(name.substring(0, prefix.length), 
prefix)
+
+  /** The engine-owned reserved AUTO CDC column(s) present in `schema`, if 
any. */
+  private[pipelines] def reservedFields(schema: StructType, resolver: 
Resolver): Seq[StructField] =
+    schema.fields.toSeq.filter(f => isReservedFieldName(f.name, resolver))
+
+  /** `schema` with the engine-owned reserved AUTO CDC column(s) removed. */
+  private[pipelines] def stripReservedFields(schema: StructType, resolver: 
Resolver): StructType =
+    StructType(schema.fields.filterNot(f => isReservedFieldName(f.name, 
resolver)))
+
+  /**
+   * The schema a materialized AUTO CDC target carries when the user declares 
a schema: the user's
+   * columns with the engine-owned type/nullability substituted into any 
declared reserved column,
+   * followed by the engine-owned reserved column(s) the declaration omitted. 
`flowInferredSchema`
+   * must be flow-derived (no user-declared columns merged in), so its 
reserved fields are exactly
+   * the engine-produced ones and never a user column that happens to carry 
the prefix.
+   *
+   * A declared reserved field keeps its own spelling, position, and metadata 
(e.g. a `COMMENT`);
+   * only its type and nullability are replaced with the engine's. That 
matters on the upgrade path:
+   * an already-materialized
+   * target may carry the reserved column under a different casing (e.g. an 
older definition
+   * declared it upper-cased under case-insensitive analysis), `evolveTable` 
merges that existing
+   * spelling in, and this schema -- which also feeds the analysis-time read 
path -- must agree with
+   * it so a downstream `SELECT *` plans against the column the target 
actually has.
+   *
+   * The engine-owned reserved fields are made nullable so a target created 
from a declared schema
+   * matches one created from an omitted schema (that path materializes the 
inferred schema
+   * `asNullable`), avoiding a spurious nullability diff when a declaration is 
added or removed.
+   */
+  private[pipelines] def appendEngineOwnedReservedFields(
+      declaredSchema: StructType,
+      flowInferredSchema: StructType,
+      resolver: Resolver): StructType = {
+    val engineReserved = StructType(reservedFields(flowInferredSchema, 
resolver)).asNullable.fields
+
+    // A declaration carries at most one field matching each engine-owned 
reserved column;
+    // `validateDeclaredReservedFields` rejects a case-insensitive duplicate 
upstream, so this guard
+    // is defense-in-depth, provably unreachable from a validated declaration.
+    engineReserved.foreach { ef =>
+      val matches = declaredSchema.fields.filter(df => resolver(df.name, 
ef.name))
+      if (matches.length > 1) {
+        throw SparkException.internalError(
+          s"Ambiguous reserved AUTO CDC field: 
${matches.map(_.name).mkString(", ")} all match " +
+            s"the engine-owned column ${ef.name}.")
+      }
+    }
+
+    // Rebuild the declared schema in place: a declared reserved field keeps 
its spelling and
+    // position but takes the engine-owned type/nullability. 
`validateDeclaredReservedFields`
+    // rejects a reserved-prefixed declared field with no engine counterpart 
upstream, so
+    // `case None => df` is unreachable from a validated declaration.
+    val usedEngineNames = scala.collection.mutable.Set.empty[String]
+    val rebuilt = declaredSchema.fields.map { df =>
+      if (isReservedFieldName(df.name, resolver)) {
+        engineReserved.find(ef => resolver(df.name, ef.name)) match {
+          case Some(ef) =>
+            usedEngineNames += ef.name
+            reconcileReservedField(df, ef, resolver)
+          case None => df
+        }
+      } else {
+        df
+      }
+    }
+
+    // Append engine-owned reserved fields the declaration omitted, in 
canonical form.
+    val appended = engineReserved.filterNot(ef => 
usedEngineNames.contains(ef.name))
+    StructType(rebuilt ++ appended)
+  }
+
+  /**
+   * Reject a declared AUTO CDC target schema whose reserved metadata 
column(s) are shaped in a way
+   * reconciliation cannot honor, so `appendEngineOwnedReservedFields`, the 
read path, and the
+   * casing-drift check can assume a well-formed declaration instead of a 
guard elsewhere.
+   * Run this from schema validation 
(`GraphValidations.validateUserSpecifiedSchemas`) before
+   * materialization. Recursively through structs and array-of-struct elements 
it throws a
+   * user-facing `AnalysisException` when a declared reserved-prefixed field 
has no engine-owned
+   * counterpart (a typo or an extra reserved-namespace column), when two 
declared fields resolve to
+   * one engine field, or when a declared nested field under a reserved column 
is not one the engine
+   * produces.
+   */
+  private[pipelines] def validateDeclaredReservedFields(
+      tableName: String,
+      declaredSchema: StructType,
+      flowInferredSchema: StructType,
+      resolver: Resolver): Unit =
+    validateReservedLevel(
+      tableName,
+      reservedColumn = None,
+      reservedFields(declaredSchema, resolver),
+      reservedFields(flowInferredSchema, resolver),
+      resolver)
+
+  // Validate one level of reserved fields against the engine's. 
`reservedColumn` is the enclosing
+  // reserved column's (possibly dotted) name for nested-field messages, None 
at the top level. At
+  // the top level `declared`/`engine` are the reserved-prefixed columns; 
within a reserved struct
+  // every field is engine-owned, so the recursion passes all of a matched 
struct's fields.
+  private def validateReservedLevel(
+      tableName: String,
+      reservedColumn: Option[String],
+      declared: Seq[StructField],
+      engine: Seq[StructField],
+      resolver: Resolver): Unit = {
+    declared.filterNot(df => engine.exists(ef => resolver(df.name, 
ef.name))).foreach { df =>
+      reservedColumn match {
+        case None =>
+          throw new AnalysisException(
+            "AUTOCDC_INVALID_RESERVED_SCHEMA.UNKNOWN_COLUMN",
+            Map("tableName" -> tableName, "columnName" -> df.name,
+              "reservedColumnNamePrefix" -> prefix))
+        case Some(col) =>
+          throw new AnalysisException(
+            "AUTOCDC_INVALID_RESERVED_SCHEMA.UNKNOWN_NESTED_FIELD",
+            Map("tableName" -> tableName, "reservedColumnName" -> col, 
"fieldName" -> df.name))
+      }
+    }
+    engine.foreach { ef =>
+      val matches = declared.filter(df => resolver(df.name, ef.name))
+      if (matches.length > 1) {
+        val names = matches.map(f => s"`${f.name}`").mkString(", ")
+        reservedColumn match {
+          case None =>
+            throw new AnalysisException(
+              "AUTOCDC_INVALID_RESERVED_SCHEMA.DUPLICATE_COLUMN",
+              Map("tableName" -> tableName, "columnNames" -> names, 
"engineColumnName" -> ef.name))
+          case Some(col) =>
+            throw new AnalysisException(
+              "AUTOCDC_INVALID_RESERVED_SCHEMA.DUPLICATE_NESTED_FIELD",
+              Map("tableName" -> tableName, "reservedColumnName" -> col, 
"fieldNames" -> names,
+                "engineFieldName" -> ef.name))
+        }
+      }
+    }
+    declared.foreach { df =>
+      engine.find(ef => resolver(df.name, ef.name)).foreach { ef =>
+        val col = Some(reservedColumn.map(c => 
s"$c.${df.name}").getOrElse(df.name))
+        (df.dataType, ef.dataType) match {
+          case (ds: StructType, es: StructType) =>
+            validateReservedLevel(tableName, col, ds.fields.toSeq, 
es.fields.toSeq, resolver)
+          case (da: ArrayType, ea: ArrayType) =>
+            (da.elementType, ea.elementType) match {
+              case (ds: StructType, es: StructType) =>
+                validateReservedLevel(tableName, col, ds.fields.toSeq, 
es.fields.toSeq, resolver)
+              case _ =>

Review Comment:
   Good catch @szehon-ho , thanks. Fixed in 58059a5 - validateReservedLevel now 
recurses through the array element types too, same as reconcileReservedType 
already does, so a bogus field inside ARRAY<ARRAY<STRUCT<...>>> gets rejected 
instead of slipping through and getting reconciled into the target. Added a 
regression test for that shape (the array(array(named_struct(‘ordinal’, 
version))) one you gave). Also backfilled a couple of tests the last commit was 
missing - a COMMENT on a declared reserved column surviving materialization, 
and the omitted nested fields getting appended.



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