This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 4d3af133b42e test(variant): pin ALTER COLUMN TYPE refusal (#20127)
4d3af133b42e is described below
commit 4d3af133b42e488abc37ebf9f81e4d75debbcd8b
Author: voonhous <[email protected]>
AuthorDate: Wed Sep 30 16:49:46 2026 +0800
test(variant): pin ALTER COLUMN TYPE refusal (#20127)
Without hoodie.schema.on.read.enable HoodieCatalog.loadTable hands the
table back as a V1Table and the Hudi ALTER rewrite rule is a no-op, so
Spark's ResolveSessionCatalog turns ALTER COLUMN ... TYPE (and the
Hive-style CHANGE COLUMN) into its v1 AlterTableChangeColumnCommand;
HoodiePostAnalysisRule then swaps in
AlterHoodieTableChangeColumnCommand, which refuses any data-type change
(columnEqual) before commitWithSchema. Spark's own up-cast check never
runs on this route. So the explicit DDL can never widen a sibling
column beside a variant: the DataFrame write pinned by the two
implicit-widening tests is the only widening path. A struct member
never reaches Hudi at all; Spark refuses a qualified column on a v1
table at analysis.
TestVariantShreddingMixedLayouts gets the explicit twin: a top-level
table (id, v variant, n int, ts) and a nested one (s struct<inner:
variant, n: int>), COW and MOR, each seeded with a shredded base file
and a second commit (a log block on MOR). ALTER COLUMN and CHANGE
COLUMN on n, on s.n, on the variant itself and from int into variant
are all refused; the pins are the message per statement, no new
instant, the catalog types unchanged, the variant projection still
reading back on both pushVariantIntoScan arms, and one more insert
landing.
Green on master as-is; no production change.
Part of #18285.
---
.../schema/TestVariantShreddingMixedLayouts.scala | 131 +++++++++++++++++++++
1 file changed, 131 insertions(+)
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
index 1ccaa716c343..671840ca5ce5 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
@@ -1111,6 +1111,137 @@ class TestVariantShreddingMixedLayouts extends
HoodieSparkSqlTestBase with Varia
}
}
+ test("Explicit ALTER COLUMN TYPE beside a variant is refused without
schema-on-read") {
+ assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+ // The explicit twin of the two "Implicit widening" tests. Without
hoodie.schema.on.read.enable
+ // HoodieCatalog.loadTable hands the table back as a V1Table and
BaseResolveHudiAlterTableCommand
+ // leaves ALTER plans alone, so Spark's ResolveSessionCatalog turns ALTER
COLUMN ... TYPE (and the
+ // Hive-style CHANGE COLUMN) into its v1 AlterTableChangeColumnCommand,
which
+ // HoodiePostAnalysisRule swaps for AlterHoodieTableChangeColumnCommand;
Spark's own up-cast check
+ // on AlterColumns never runs on this route. That command
+ // requires name AND type to match (columnEqual) before commitWithSchema,
so every type change
+ // is refused with no instant written and the catalog schema untouched:
re-typing the variant
+ // and re-typing the sibling into a variant hit the same check as the
widening. The explicit
+ // path therefore cannot widen a sibling of a variant; the DataFrame write
in the "Implicit
+ // widening" tests is the only way a table gets there. A struct member
never reaches Hudi at
+ // all: ResolveSessionCatalog refuses a qualified column on a v1 table
outright.
+ // A refused DDL does not depend on the record type, so the SPARK record
type alone is swept.
+ def hudiRefusal(column: String, from: String, to: String): String =
+ "ALTER TABLE CHANGE COLUMN is not supported for changing column " +
+ s"'$column' with type '$from' to '$column' with type '$to'"
+
+ // The refused DDLs must leave the table readable on both
PushVariantIntoScan arms: the value
+ // and filter reads, the full variant, and the plan shape of the
projection.
+ def assertReadsOnBothArms(tableName: String, leg: String, variantExpr:
String, nExpr: String): Unit = {
+ Seq("true", "false").foreach { pushIntoScan =>
+ withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) {
+ val projection = s"select id, variant_get($variantExpr, '$$.k',
'string'), $nExpr from $tableName"
+ checkAnswer(s"$projection order by id")(Seq(0, "x0", 0), Seq(1,
"y1", 1), Seq(2, "y2", 2))
+ checkAnswer(s"select id from $tableName where
variant_get($variantExpr, '$$.k', 'string') = 'y2'")(
+ Seq(2))
+ checkAnswer(s"select id from $tableName where $nExpr >= 1 order by
id")(Seq(1), Seq(2))
+ checkAnswer(s"select id, cast($variantExpr as string) from
$tableName order by id")(
+ Seq(0, """{"k":"x0"}"""), Seq(1, """{"k":"y1"}"""), Seq(2,
"""{"k":"y2"}"""))
+ val pushed = pushIntoScan.toBoolean
+ assert(variantProjectionPushedIntoScan(projection) == pushed,
+ s"[$leg] PushVariantIntoScan ${if (pushed) "should have" else
"must not have"} rewritten " +
+ s"$variantExpr into a projection struct")
+ }
+ }
+ }
+
+ Seq("cow", "mor").foreach { tableType =>
+ // On MOR the update of commit 2 lands in a log block; keeping inline
compaction off keeps it
+ // there, so the reads after the refused DDLs merge a log over the base.
+ val props = if (tableType == "mor") Seq("hoodie.compact.inline =
'false'") else Seq.empty
+
+ withVariantTable(s"alter column type beside a top-level variant
$tableType", tableType, props = props,
+ extraCols = "n int", recordTypes = Seq(HoodieRecordType.SPARK)) {
(tableName, tablePath, leg) =>
+ def rowsSql(lo: Int, hi: Int, nType: String, kPrefix: String, ts:
Long): String =
+ s"""select cast(id as int) as id,
parse_json(concat('{"k":"$kPrefix', id, '"}')) as v,
+ | cast(id as $nType) as n, ${ts}L as ts from range($lo, $hi, 1,
1)""".stripMargin
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(0, 3, "int", "x",
1000L)}")
+ }
+ assertVariantLayout(tablePath, shredded = true, leg)
+ // On MOR this is the log block the reads below merge; on COW it
rewrites the base file.
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"""update $tableName set v = parse_json(concat('{"k":"y',
id, '"}')),
+ | n = cast(id as int), ts = 1001 where id >=
1""".stripMargin)
+ }
+ val before = latestCompletedInstant(tablePath)
+
+ // Widening the sibling: columnEqual refuses it, and the Hive-style
CHANGE COLUMN syntax lands
+ // on the same command and the same refusal.
+ val widening = hudiRefusal("n", "IntegerType", "LongType")
+ checkExceptionContain(s"alter table $tableName alter column n type
bigint")(widening)
+ checkExceptionContain(s"alter table $tableName change column n n
bigint")(widening)
+ // Re-typing the variant, or the sibling into a variant, meets the
same check.
+ checkExceptionContain(s"alter table $tableName alter column v type
string")(
+ hudiRefusal("v", "VariantType", "StringType"))
+ checkExceptionContain(s"alter table $tableName alter column n type
variant")(
+ hudiRefusal("n", "IntegerType", "VariantType"))
+
+ val schema = spark.table(tableName).schema
+ assert(schema("n").dataType == IntegerType, s"[$leg] n must stay int,
got ${schema("n").dataType}")
+ assert(schema("v").dataType.simpleString == "variant",
+ s"[$leg] v must stay a variant, got ${schema("v").dataType}")
+ assert(latestCompletedInstant(tablePath) == before, s"[$leg] a refused
DDL must not commit an instant")
+
+ assertReadsOnBothArms(tableName, leg, "v", "n")
+
+ // The table still takes writes after the refusals.
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(3, 5, "int", "z",
1002L)}")
+ }
+ assertVariantLayout(tablePath, shredded = true, leg)
+ checkAnswer(s"select id, variant_get(v, '$$.k', 'string'), n from
$tableName order by id")(
+ Seq(0, "x0", 0), Seq(1, "y1", 1), Seq(2, "y2", 2), Seq(3, "z3", 3),
Seq(4, "z4", 4))
+ }
+
+ withNestedOnlyVariantTable(s"alter column type beside a nested variant
$tableType", tableType,
+ props = props, recordTypes = Seq(HoodieRecordType.SPARK),
structMembers = "inner: variant, n: int") {
+ (tableName, tablePath, leg) =>
+ def rowsSql(lo: Int, hi: Int, nType: String, kPrefix: String, ts:
Long): String =
+ s"""select cast(id as int) as id,
+ | named_struct('inner', parse_json(concat('{"k":"$kPrefix', id,
'"}')), 'n', cast(id as $nType)) as s,
+ | ${ts}L as ts from range($lo, $hi, 1, 1)""".stripMargin
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(0, 3, "int", "x",
1000L)}")
+ }
+ assertVariantLayout(tablePath, shredded = true, leg, column =
"s.inner")
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"""update $tableName
+ | set s = named_struct('inner',
parse_json(concat('{"k":"y', id, '"}')), 'n', cast(id as int)),
+ | ts = 1001 where id >= 1""".stripMargin)
+ }
+ val before = latestCompletedInstant(tablePath)
+
+ // Spark refuses a qualified column on a v1 table at analysis, so
neither the sibling nor the
+ // variant inside `s` can be re-typed, and Hudi's command is never
reached.
+ val qualifiedRefusal = "does not support ALTER COLUMN with qualified
column"
+ checkExceptionContain(s"alter table $tableName alter column s.n type
bigint")(qualifiedRefusal)
+ checkExceptionContain(s"alter table $tableName alter column s.inner
type string")(qualifiedRefusal)
+
+ val structType =
spark.table(tableName).schema("s").dataType.asInstanceOf[StructType]
+ assert(structType("n").dataType == IntegerType, s"[$leg] s.n must stay
int, got ${structType("n").dataType}")
+ assert(structType("inner").dataType.simpleString == "variant",
+ s"[$leg] s.inner must stay a variant, got
${structType("inner").dataType}")
+ assert(latestCompletedInstant(tablePath) == before, s"[$leg] a refused
DDL must not commit an instant")
+
+ assertReadsOnBothArms(tableName, leg, "s.inner", "s.n")
+
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(3, 5, "int", "z",
1002L)}")
+ }
+ assertVariantLayout(tablePath, shredded = true, leg, column =
"s.inner")
+ checkAnswer(s"select id, variant_get(s.inner, '$$.k', 'string'), s.n
from $tableName order by id")(
+ Seq(0, "x0", 0), Seq(1, "y1", 1), Seq(2, "y2", 2), Seq(3, "z3", 3),
Seq(4, "z4", 4))
+ }
+ }
+ }
+
//
-----------------------------------------------------------------------------------------------
// F. Nested variant
//
-----------------------------------------------------------------------------------------------