This is an automated email from the ASF dual-hosted git repository.
agrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new c97f0338d fix: native_datafusion: case-insensitive mode doesn't detect
duplicate/ambiguous Parquet fields (#3808)
c97f0338d is described below
commit c97f0338d2e35f17103557bb0906907b3de64622
Author: Vipul Vaibhaw <[email protected]>
AuthorDate: Tue Mar 31 21:05:56 2026 +0530
fix: native_datafusion: case-insensitive mode doesn't detect
duplicate/ambiguous Parquet fields (#3808)
---
dev/diffs/3.4.3.diff | 100 +++++++++++++-----
dev/diffs/3.5.8.diff | 85 ++++++++++-----
dev/diffs/4.0.1.diff | 102 ++++++++++++------
docs/source/contributor-guide/parquet_scans.md | 6 +-
native/core/src/parquet/schema_adapter.rs | 114 ++++++++++++++++++++-
.../org/apache/comet/rules/CometScanRule.scala | 16 ---
.../apache/comet/exec/CometNativeReaderSuite.scala | 36 +++++++
7 files changed, 350 insertions(+), 109 deletions(-)
diff --git a/dev/diffs/3.4.3.diff b/dev/diffs/3.4.3.diff
index e40328536..44f7601e3 100644
--- a/dev/diffs/3.4.3.diff
+++ b/dev/diffs/3.4.3.diff
@@ -523,7 +523,7 @@ index a6b295578d6..91acca4306f 100644
test("SPARK-35884: Explain Formatted") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
-index 2796b1cf154..d628f44e4ee 100644
+index 2796b1cf154..53dcfde932e 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -33,6 +33,7 @@ import org.apache.spark.sql.TestingUDT.{IntervalUDT,
NullData, NullUDT}
@@ -534,17 +534,46 @@ index 2796b1cf154..d628f44e4ee 100644
import org.apache.spark.sql.execution.{FileSourceScanLike, SimpleMode}
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
import org.apache.spark.sql.execution.datasources.FilePartition
-@@ -499,7 +500,8 @@ class FileBasedDataSourceSuite extends QueryTest
- }
+@@ -516,21 +517,24 @@ class FileBasedDataSourceSuite extends QueryTest
+ checkAnswer(sql(s"select A from $tableName"), data.select("A"))
+
+ // RuntimeException is triggered at executor side, which is then
wrapped as
+- // SparkException at driver side
+- val e1 = intercept[SparkException] {
+- sql(s"select b from $tableName").collect()
++ // SparkException at driver side. Comet native readers throw
RuntimeException
++ // directly without the SparkException wrapper.
++ def getDuplicateFieldError(query: String): RuntimeException = {
++ try {
++ sql(query).collect()
++ fail("Expected an exception").asInstanceOf[RuntimeException]
++ } catch {
++ case e: SparkException =>
++ e.getCause.asInstanceOf[RuntimeException]
++ case e: RuntimeException => e
++ }
+ }
+- assert(
+- e1.getCause.isInstanceOf[RuntimeException] &&
+- e1.getCause.getMessage.contains(
+- """Found duplicate field(s) "b": [b, B] in case-insensitive
mode"""))
+- val e2 = intercept[SparkException] {
+- sql(s"select B from $tableName").collect()
+- }
+- assert(
+- e2.getCause.isInstanceOf[RuntimeException] &&
+- e2.getCause.getMessage.contains(
+- """Found duplicate field(s) "b": [b, B] in case-insensitive
mode"""))
++ val e1 = getDuplicateFieldError(s"select b from $tableName")
++ assert(e1.getMessage.contains(
++ """Found duplicate field(s) "b": [b, B] in case-insensitive
mode"""))
++ val e2 = getDuplicateFieldError(s"select B from $tableName")
++ assert(e2.getMessage.contains(
++ """Found duplicate field(s) "b": [b, B] in case-insensitive
mode"""))
+ }
- Seq("parquet", "orc").foreach { format =>
-- test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}") {
-+ test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}",
-+
IgnoreCometNativeDataFusion("https://github.com/apache/datafusion-comet/issues/3760"))
{
- withTempDir { dir =>
- val tableName = s"spark_25132_${format}_native"
- val tableDir = dir.getCanonicalPath + s"/$tableName"
-@@ -815,6 +817,7 @@ class FileBasedDataSourceSuite extends QueryTest
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+@@ -815,6 +819,7 @@ class FileBasedDataSourceSuite extends QueryTest
assert(bJoinExec.isEmpty)
val smJoinExec = collect(joinedDF.queryExecution.executedPlan) {
case smJoin: SortMergeJoinExec => smJoin
@@ -552,7 +581,7 @@ index 2796b1cf154..d628f44e4ee 100644
}
assert(smJoinExec.nonEmpty)
}
-@@ -875,6 +878,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -875,6 +880,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _, _, _, _) => f
@@ -560,7 +589,7 @@ index 2796b1cf154..d628f44e4ee 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.nonEmpty)
-@@ -916,6 +920,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -916,6 +922,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _, _, _, _) => f
@@ -568,7 +597,7 @@ index 2796b1cf154..d628f44e4ee 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.isEmpty)
-@@ -1100,6 +1105,9 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1100,6 +1107,9 @@ class FileBasedDataSourceSuite extends QueryTest
val filters = df.queryExecution.executedPlan.collect {
case f: FileSourceScanLike => f.dataFilters
case b: BatchScanExec => b.scan.asInstanceOf[FileScan].dataFilters
@@ -2003,7 +2032,7 @@ index 07e2849ce6f..3e73645b638 100644
ParquetOutputFormat.WRITER_VERSION ->
ParquetProperties.WriterVersion.PARQUET_2_0.toString
)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
-index 104b4e416cd..d865077684f 100644
+index 104b4e416cd..b8af360fa14 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
@@ -38,6 +38,7 @@ import org.apache.parquet.schema.MessageType
@@ -2083,17 +2112,32 @@ index 104b4e416cd..d865077684f 100644
val schema = StructType(Seq(
StructField("a", IntegerType, nullable = false)
))
-@@ -1934,7 +1950,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
- }
- }
+@@ -1950,11 +1966,21 @@ abstract class ParquetFilterSuite extends QueryTest
with ParquetTest with Shared
+ """.stripMargin)
+
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
+- val e = intercept[SparkException] {
++ // Spark native readers wrap the error in SparkException.
++ // Comet native readers throw RuntimeException directly.
++ val msg = try {
+ sql(s"select a from $tableName where b > 0").collect()
++ fail("Expected an exception")
++ } catch {
++ case e: SparkException =>
++ assert(e.getCause.isInstanceOf[RuntimeException])
++ e.getCause.getMessage
++ case e: RuntimeException =>
++ e.getMessage
+ }
+- assert(e.getCause.isInstanceOf[RuntimeException] &&
e.getCause.getMessage.contains(
+- """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""))
++ assert(msg.contains(
++ """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""),
++ s"Unexpected error message: $msg")
+ }
-- test("SPARK-25207: exception when duplicate fields in case-insensitive
mode") {
-+ test("SPARK-25207: exception when duplicate fields in case-insensitive
mode",
-+
IgnoreCometNativeDataFusion("https://github.com/apache/datafusion-comet/issues/3760"))
{
- withTempPath { dir =>
- val count = 10
- val tableName = "spark_25207"
-@@ -1985,7 +2002,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+@@ -1985,7 +2011,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2103,7 +2147,7 @@ index 104b4e416cd..d865077684f 100644
// block 1:
// null count min
max
// page-0 0 0
99
-@@ -2045,7 +2063,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+@@ -2045,7 +2072,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2113,7 +2157,7 @@ index 104b4e416cd..d865077684f 100644
withTempPath { dir =>
val path = dir.getCanonicalPath
spark.range(100).selectExpr("id * 2 AS id")
-@@ -2277,7 +2296,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
+@@ -2277,7 +2305,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
@@ -2126,7 +2170,7 @@ index 104b4e416cd..d865077684f 100644
} else {
assert(selectedFilters.isEmpty, "There is filter pushed down")
}
-@@ -2337,7 +2360,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
+@@ -2337,7 +2369,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
diff --git a/dev/diffs/3.5.8.diff b/dev/diffs/3.5.8.diff
index 227ac20f0..c879ac8f2 100644
--- a/dev/diffs/3.5.8.diff
+++ b/dev/diffs/3.5.8.diff
@@ -494,7 +494,7 @@ index a206e97c353..fea1149b67d 100644
test("SPARK-35884: Explain Formatted") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
-index 93275487f29..601cb6647fe 100644
+index 93275487f29..77a27d1c40a 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -23,6 +23,7 @@ import java.nio.file.{Files, StandardOpenOption}
@@ -522,17 +522,40 @@ index 93275487f29..601cb6647fe 100644
checkErrorMatchPVals(
exception = intercept[SparkException] {
testIgnoreMissingFiles(options)
-@@ -639,7 +643,8 @@ class FileBasedDataSourceSuite extends QueryTest
- }
-
- Seq("parquet", "orc").foreach { format =>
-- test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}") {
-+ test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}",
-+
IgnoreCometNativeDataFusion("https://github.com/apache/datafusion-comet/issues/3760"))
{
- withTempDir { dir =>
- val tableName = s"spark_25132_${format}_native"
- val tableDir = dir.getCanonicalPath + s"/$tableName"
-@@ -955,6 +960,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -656,18 +660,25 @@ class FileBasedDataSourceSuite extends QueryTest
+ checkAnswer(sql(s"select A from $tableName"), data.select("A"))
+
+ // RuntimeException is triggered at executor side, which is then
wrapped as
+- // SparkException at driver side
++ // SparkException at driver side. Comet native readers throw
++ // SparkRuntimeException directly without the SparkException
wrapper.
++ def getDuplicateFieldError(query: String): SparkRuntimeException
= {
++ try {
++ sql(query).collect()
++ fail("Expected an
exception").asInstanceOf[SparkRuntimeException]
++ } catch {
++ case e: SparkException =>
++ e.getCause.asInstanceOf[SparkRuntimeException]
++ case e: SparkRuntimeException => e
++ }
++ }
+ checkError(
+- exception = intercept[SparkException] {
+- sql(s"select b from $tableName").collect()
+- }.getCause.asInstanceOf[SparkRuntimeException],
++ exception = getDuplicateFieldError(s"select b from $tableName"),
+ errorClass = "_LEGACY_ERROR_TEMP_2093",
+ parameters = Map("requiredFieldName" -> "b", "matchedOrcFields"
-> "[b, B]")
+ )
+ checkError(
+- exception = intercept[SparkException] {
+- sql(s"select B from $tableName").collect()
+- }.getCause.asInstanceOf[SparkRuntimeException],
++ exception = getDuplicateFieldError(s"select B from $tableName"),
+ errorClass = "_LEGACY_ERROR_TEMP_2093",
+ parameters = Map("requiredFieldName" -> "b", "matchedOrcFields"
-> "[b, B]")
+ )
+@@ -955,6 +966,7 @@ class FileBasedDataSourceSuite extends QueryTest
assert(bJoinExec.isEmpty)
val smJoinExec = collect(joinedDF.queryExecution.executedPlan) {
case smJoin: SortMergeJoinExec => smJoin
@@ -540,7 +563,7 @@ index 93275487f29..601cb6647fe 100644
}
assert(smJoinExec.nonEmpty)
}
-@@ -1015,6 +1021,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1015,6 +1027,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _) => f
@@ -548,7 +571,7 @@ index 93275487f29..601cb6647fe 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.nonEmpty)
-@@ -1056,6 +1063,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1056,6 +1069,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _) => f
@@ -556,7 +579,7 @@ index 93275487f29..601cb6647fe 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.isEmpty)
-@@ -1240,6 +1248,9 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1240,6 +1254,9 @@ class FileBasedDataSourceSuite extends QueryTest
val filters = df.queryExecution.executedPlan.collect {
case f: FileSourceScanLike => f.dataFilters
case b: BatchScanExec => b.scan.asInstanceOf[FileScan].dataFilters
@@ -1959,7 +1982,7 @@ index 07e2849ce6f..3e73645b638 100644
ParquetOutputFormat.WRITER_VERSION ->
ParquetProperties.WriterVersion.PARQUET_2_0.toString
)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
-index 8e88049f51e..f19c12c98e6 100644
+index 8e88049f51e..f9d515edee1 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
@@ -1095,7 +1095,11 @@ abstract class ParquetFilterSuite extends QueryTest
with ParquetTest with Shared
@@ -2030,17 +2053,25 @@ index 8e88049f51e..f19c12c98e6 100644
val schema = StructType(Seq(
StructField("a", IntegerType, nullable = false)
))
-@@ -1952,8 +1966,17 @@ abstract class ParquetFilterSuite extends QueryTest
with ParquetTest with Shared
- val e = intercept[SparkException] {
+@@ -1949,11 +1965,24 @@ abstract class ParquetFilterSuite extends QueryTest
with ParquetTest with Shared
+ """.stripMargin)
+
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
+- val e = intercept[SparkException] {
++ // Spark native readers wrap the error in
SparkException(FAILED_READ_FILE).
++ // Comet native readers throw SparkRuntimeException directly.
++ val msg = try {
sql(s"select a from $tableName where b > 0").collect()
++ fail("Expected an exception")
++ } catch {
++ case e: SparkException =>
++ assert(e.getCause.isInstanceOf[RuntimeException])
++ e.getCause.getMessage
++ case e: RuntimeException =>
++ e.getMessage
}
- assert(e.getCause.isInstanceOf[RuntimeException] &&
e.getCause.getMessage.contains(
- """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""))
-+ assert(e.getCause.isInstanceOf[RuntimeException])
-+ val msg = e.getCause.getMessage
-+ // native_datafusion converts DataFusion's "Unable to get field
named" error
-+ // to _LEGACY_ERROR_TEMP_2093 but with a lowercase field name ("b"
vs "B")
-+ // because DataFusion resolves field names case-insensitively
+ assert(
+ msg.contains(
+ """Found duplicate field(s) "B": [B, b] in case-insensitive
mode""") ||
@@ -2050,7 +2081,7 @@ index 8e88049f51e..f19c12c98e6 100644
}
withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
-@@ -1984,7 +2007,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+@@ -1984,7 +2013,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2060,7 +2091,7 @@ index 8e88049f51e..f19c12c98e6 100644
// block 1:
// null count min
max
// page-0 0 0
99
-@@ -2044,7 +2068,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+@@ -2044,7 +2074,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2070,7 +2101,7 @@ index 8e88049f51e..f19c12c98e6 100644
withTempPath { dir =>
val path = dir.getCanonicalPath
spark.range(100).selectExpr("id * 2 AS id")
-@@ -2276,7 +2301,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
+@@ -2276,7 +2307,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
@@ -2083,7 +2114,7 @@ index 8e88049f51e..f19c12c98e6 100644
} else {
assert(selectedFilters.isEmpty, "There is filter pushed down")
}
-@@ -2336,7 +2365,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
+@@ -2336,7 +2371,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
diff --git a/dev/diffs/4.0.1.diff b/dev/diffs/4.0.1.diff
index 364c62a99..4775c3a2f 100644
--- a/dev/diffs/4.0.1.diff
+++ b/dev/diffs/4.0.1.diff
@@ -723,7 +723,7 @@ index 9c90e0105a4..fadf2f0f698 100644
test("SPARK-35884: Explain Formatted") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
-index 9c529d14221..5c4e370dfff 100644
+index 9c529d14221..2c3ee31723f 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -33,6 +33,8 @@ import
org.apache.spark.sql.catalyst.expressions.{AttributeReference, GreaterTha
@@ -757,17 +757,40 @@ index 9c529d14221..5c4e370dfff 100644
}
Seq("json", "orc").foreach { format =>
-@@ -651,7 +657,8 @@ class FileBasedDataSourceSuite extends QueryTest
- }
-
- Seq("parquet", "orc").foreach { format =>
-- test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}") {
-+ test(s"Spark native readers should respect spark.sql.caseSensitive -
${format}",
-+
IgnoreCometNativeDataFusion("https://github.com/apache/datafusion-comet/issues/3760"))
{
- withTempDir { dir =>
- val tableName = s"spark_25132_${format}_native"
- val tableDir = dir.getCanonicalPath + s"/$tableName"
-@@ -967,6 +974,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -668,18 +674,25 @@ class FileBasedDataSourceSuite extends QueryTest
+ checkAnswer(sql(s"select A from $tableName"), data.select("A"))
+
+ // RuntimeException is triggered at executor side, which is then
wrapped as
+- // SparkException at driver side
++ // SparkException at driver side. Comet native readers throw
++ // SparkRuntimeException directly without the SparkException
wrapper.
++ def getDuplicateFieldError(query: String): SparkRuntimeException
= {
++ try {
++ sql(query).collect()
++ fail("Expected an
exception").asInstanceOf[SparkRuntimeException]
++ } catch {
++ case e: SparkException =>
++ e.getCause.asInstanceOf[SparkRuntimeException]
++ case e: SparkRuntimeException => e
++ }
++ }
+ checkError(
+- exception = intercept[SparkException] {
+- sql(s"select b from $tableName").collect()
+- }.getCause.asInstanceOf[SparkRuntimeException],
++ exception = getDuplicateFieldError(s"select b from $tableName"),
+ condition = "_LEGACY_ERROR_TEMP_2093",
+ parameters = Map("requiredFieldName" -> "b", "matchedOrcFields"
-> "[b, B]")
+ )
+ checkError(
+- exception = intercept[SparkException] {
+- sql(s"select B from $tableName").collect()
+- }.getCause.asInstanceOf[SparkRuntimeException],
++ exception = getDuplicateFieldError(s"select B from $tableName"),
+ condition = "_LEGACY_ERROR_TEMP_2093",
+ parameters = Map("requiredFieldName" -> "b", "matchedOrcFields"
-> "[b, B]")
+ )
+@@ -967,6 +980,7 @@ class FileBasedDataSourceSuite extends QueryTest
assert(bJoinExec.isEmpty)
val smJoinExec = collect(joinedDF.queryExecution.executedPlan) {
case smJoin: SortMergeJoinExec => smJoin
@@ -775,7 +798,7 @@ index 9c529d14221..5c4e370dfff 100644
}
assert(smJoinExec.nonEmpty)
}
-@@ -1027,6 +1035,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1027,6 +1041,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _) => f
@@ -783,7 +806,7 @@ index 9c529d14221..5c4e370dfff 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.nonEmpty)
-@@ -1068,6 +1077,7 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1068,6 +1083,7 @@ class FileBasedDataSourceSuite extends QueryTest
val fileScan = df.queryExecution.executedPlan collectFirst {
case BatchScanExec(_, f: FileScan, _, _, _, _) => f
@@ -791,7 +814,7 @@ index 9c529d14221..5c4e370dfff 100644
}
assert(fileScan.nonEmpty)
assert(fileScan.get.partitionFilters.isEmpty)
-@@ -1241,7 +1251,8 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1241,7 +1257,8 @@ class FileBasedDataSourceSuite extends QueryTest
}
}
@@ -801,7 +824,7 @@ index 9c529d14221..5c4e370dfff 100644
withTempPath { path =>
val pathStr = path.getCanonicalPath
spark.range(10).write.parquet(pathStr)
-@@ -1252,6 +1263,9 @@ class FileBasedDataSourceSuite extends QueryTest
+@@ -1252,6 +1269,9 @@ class FileBasedDataSourceSuite extends QueryTest
val filters = df.queryExecution.executedPlan.collect {
case f: FileSourceScanLike => f.dataFilters
case b: BatchScanExec => b.scan.asInstanceOf[FileScan].dataFilters
@@ -2742,14 +2765,14 @@ index cd6f41b4ef4..4b6a17344bc 100644
ParquetOutputFormat.WRITER_VERSION ->
ParquetProperties.WriterVersion.PARQUET_2_0.toString
)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
-index 6080a5e8e4b..cef477c8b4d 100644
+index 6080a5e8e4b..ea058d57b4b 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala
@@ -38,6 +38,7 @@ import org.apache.parquet.schema.MessageType
import org.apache.spark.{SparkConf, SparkException, SparkRuntimeException}
import org.apache.spark.sql._
-+import org.apache.spark.sql.{IgnoreCometNativeDataFusion,
IgnoreCometNativeScan}
++import org.apache.spark.sql.IgnoreCometNativeScan
import org.apache.spark.sql.catalyst.dsl.expressions._
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.optimizer.InferFiltersFromConstraints
@@ -2821,17 +2844,34 @@ index 6080a5e8e4b..cef477c8b4d 100644
val schema = StructType(Seq(
StructField("a", IntegerType, nullable = false)
))
-@@ -1940,7 +1955,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
- }
- }
+@@ -1956,13 +1971,21 @@ abstract class ParquetFilterSuite extends QueryTest
with ParquetTest with Shared
+ """.stripMargin)
+
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
+- val ex = intercept[SparkException] {
++ // Spark native readers wrap the error in
SparkException(FAILED_READ_FILE).
++ // Comet native readers throw SparkRuntimeException directly.
++ try {
+ sql(s"select a from $tableName where b > 0").collect()
++ fail("Expected an exception")
++ } catch {
++ case ex: SparkException =>
++ assert(ex.getCondition.startsWith("FAILED_READ_FILE"))
++ assert(ex.getCause.isInstanceOf[SparkRuntimeException])
++ assert(ex.getCause.getMessage.contains(
++ """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""))
++ case ex: SparkRuntimeException =>
++ assert(ex.getMessage.contains(
++ """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""))
+ }
+- assert(ex.getCondition.startsWith("FAILED_READ_FILE"))
+- assert(ex.getCause.isInstanceOf[SparkRuntimeException])
+- assert(ex.getCause.getMessage.contains(
+- """Found duplicate field(s) "B": [B, b] in case-insensitive
mode"""))
+ }
-- test("SPARK-25207: exception when duplicate fields in case-insensitive
mode") {
-+ test("SPARK-25207: exception when duplicate fields in case-insensitive
mode",
-+
IgnoreCometNativeDataFusion("https://github.com/apache/datafusion-comet/issues/3760"))
{
- withTempPath { dir =>
- val count = 10
- val tableName = "spark_25207"
-@@ -1993,7 +2009,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+@@ -1993,7 +2016,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2841,7 +2881,7 @@ index 6080a5e8e4b..cef477c8b4d 100644
// block 1:
// null count min
max
// page-0 0 0
99
-@@ -2053,7 +2070,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
+@@ -2053,7 +2077,8 @@ abstract class ParquetFilterSuite extends QueryTest with
ParquetTest with Shared
}
}
@@ -2851,7 +2891,7 @@ index 6080a5e8e4b..cef477c8b4d 100644
withTempPath { dir =>
val path = dir.getCanonicalPath
spark.range(100).selectExpr("id * 2 AS id")
-@@ -2305,7 +2323,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
+@@ -2305,7 +2330,11 @@ class ParquetV1FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
@@ -2864,7 +2904,7 @@ index 6080a5e8e4b..cef477c8b4d 100644
} else {
assert(selectedFilters.isEmpty, "There is filter pushed down")
}
-@@ -2368,7 +2390,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
+@@ -2368,7 +2397,11 @@ class ParquetV2FilterSuite extends ParquetFilterSuite {
assert(pushedParquetFilters.exists(_.getClass === filterClass),
s"${pushedParquetFilters.map(_.getClass).toList} did not contain
${filterClass}.")
diff --git a/docs/source/contributor-guide/parquet_scans.md
b/docs/source/contributor-guide/parquet_scans.md
index 833eda75e..81bb5537c 100644
--- a/docs/source/contributor-guide/parquet_scans.md
+++ b/docs/source/contributor-guide/parquet_scans.md
@@ -62,10 +62,8 @@ cause Comet to fall back to Spark.
- No support for `input_file_name()`, `input_file_block_start()`, or
`input_file_block_length()` SQL functions.
The `native_datafusion` scan does not use Spark's `FileScanRDD`, so these
functions cannot populate their values.
- No support for `ignoreMissingFiles` or `ignoreCorruptFiles` being set to
`true`
-- No support for duplicate field names in case-insensitive mode. When the
required or data schema contains
- field names that differ only by case (e.g., `B` and `b`), Comet falls back
to Spark. Duplicates
- in the physical Parquet file that are not reflected in the table schema
cannot be detected at plan time;
- in that case DataFusion will throw a `SparkRuntimeException` with error
class `_LEGACY_ERROR_TEMP_2093`,
+- Duplicate field names in case-insensitive mode (e.g., a Parquet file with
both `B` and `b` columns)
+ are detected at read time and raise a `SparkRuntimeException` with error
class `_LEGACY_ERROR_TEMP_2093`,
matching Spark's behavior.
The `native_iceberg_compat` scan has the following additional limitation that
may produce incorrect results
diff --git a/native/core/src/parquet/schema_adapter.rs
b/native/core/src/parquet/schema_adapter.rs
index 535ed7062..9f51fd42f 100644
--- a/native/core/src/parquet/schema_adapter.rs
+++ b/native/core/src/parquet/schema_adapter.rs
@@ -19,11 +19,12 @@ use crate::parquet::cast_column::CometCastColumnExpr;
use crate::parquet::parquet_support::{spark_parquet_convert,
SparkParquetOptions};
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use datafusion::common::tree_node::{Transformed, TransformedResult, TreeNode};
-use datafusion::common::Result as DataFusionResult;
+use datafusion::common::{DataFusionError, Result as DataFusionResult};
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_plan::ColumnarValue;
use datafusion::scalar::ScalarValue;
+use datafusion_comet_common::SparkError;
use datafusion_comet_spark_expr::{Cast, SparkCastOptions};
use datafusion_physical_expr_adapter::{
replace_columns_with_literals, DefaultPhysicalExprAdapterFactory,
PhysicalExprAdapter,
@@ -93,6 +94,23 @@ fn remap_physical_schema_names(
Arc::new(Schema::new(remapped_fields))
}
+/// Check if a specific column name has duplicate matches in the physical
schema
+/// (case-insensitive). Returns the error info if so.
+fn check_column_duplicate(col_name: &str, physical_schema: &SchemaRef) ->
Option<(String, String)> {
+ let matches: Vec<&str> = physical_schema
+ .fields()
+ .iter()
+ .filter(|pf| pf.name().eq_ignore_ascii_case(col_name))
+ .map(|pf| pf.name().as_str())
+ .collect();
+ if matches.len() > 1 {
+ // Include brackets to match the format expected by
ShimSparkErrorConverter
+ Some((col_name.to_string(), format!("[{}]", matches.join(", "))))
+ } else {
+ None
+ }
+}
+
impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory {
fn create(
&self,
@@ -109,7 +127,7 @@ impl PhysicalExprAdapterFactory for
SparkPhysicalExprAdapterFactory {
// to the original physical names. This is necessary because
downstream code
// (reassign_expr_columns) looks up columns by name in the actual
stream
// schema, which uses the original physical file column names.
- let (adapted_physical_schema, logical_to_physical_names) =
+ let (adapted_physical_schema, logical_to_physical_names,
original_physical_schema) =
if !self.parquet_options.case_sensitive {
let logical_to_physical: HashMap<String, String> =
logical_file_schema
.fields()
@@ -134,9 +152,11 @@ impl PhysicalExprAdapterFactory for
SparkPhysicalExprAdapterFactory {
} else {
Some(logical_to_physical)
},
+ // Keep original physical schema for per-column duplicate
detection
+ Some(Arc::clone(&physical_file_schema)),
)
} else {
- (Arc::clone(&physical_file_schema), None)
+ (Arc::clone(&physical_file_schema), None, None)
};
let default_factory = DefaultPhysicalExprAdapterFactory;
@@ -152,6 +172,7 @@ impl PhysicalExprAdapterFactory for
SparkPhysicalExprAdapterFactory {
default_values: self.default_values.clone(),
default_adapter,
logical_to_physical_names,
+ original_physical_schema,
})
}
}
@@ -181,10 +202,38 @@ struct SparkPhysicalExprAdapter {
/// physical names so that downstream reassign_expr_columns can find
/// columns in the actual stream schema.
logical_to_physical_names: Option<HashMap<String, String>>,
+ /// The original (un-remapped) physical schema, kept for per-column
duplicate
+ /// detection in case-insensitive mode. Only set when `!case_sensitive`.
+ original_physical_schema: Option<SchemaRef>,
}
impl PhysicalExprAdapter for SparkPhysicalExprAdapter {
fn rewrite(&self, expr: Arc<dyn PhysicalExpr>) -> DataFusionResult<Arc<dyn
PhysicalExpr>> {
+ // In case-insensitive mode, check if any Column in this expression
references
+ // a field with multiple case-insensitive matches in the physical
schema.
+ // Only the columns actually referenced trigger the error (not the
whole schema).
+ if let Some(orig_physical) = &self.original_physical_schema {
+ // Walk the expression tree to find Column references
+ let mut duplicate_err: Option<DataFusionError> = None;
+ let _ = Arc::<dyn PhysicalExpr>::clone(&expr).transform(|e| {
+ if let Some(col) = e.as_any().downcast_ref::<Column>() {
+ if let Some((req, matched)) =
check_column_duplicate(col.name(), orig_physical)
+ {
+ duplicate_err =
Some(DataFusionError::External(Box::new(
+ SparkError::DuplicateFieldCaseInsensitive {
+ required_field_name: req,
+ matched_fields: matched,
+ },
+ )));
+ }
+ }
+ Ok(Transformed::no(e))
+ });
+ if let Some(err) = duplicate_err {
+ return Err(err);
+ }
+ }
+
// First let the default adapter handle column remapping, missing
columns,
// and simple scalar type casts. Then replace DataFusion's
CastColumnExpr
// with Spark-compatible equivalents.
@@ -531,4 +580,63 @@ mod test {
let mut stream = parquet_exec.execute(0,
Arc::new(TaskContext::default()))?;
stream.next().await.unwrap()
}
+
+ #[tokio::test]
+ async fn parquet_duplicate_fields_case_insensitive() {
+ // Parquet file has columns "A", "B", "b" - reading "b" in
case-insensitive mode
+ // should fail with duplicate field error matching Spark's
_LEGACY_ERROR_TEMP_2093
+ let file_schema = Arc::new(Schema::new(vec![
+ Field::new("A", DataType::Int32, false),
+ Field::new("B", DataType::Int32, false),
+ Field::new("b", DataType::Int32, false),
+ ]));
+
+ let col_a = Arc::new(Int32Array::from(vec![1, 2, 3])) as Arc<dyn
arrow::array::Array>;
+ let col_b1 = Arc::new(Int32Array::from(vec![4, 5, 6])) as Arc<dyn
arrow::array::Array>;
+ let col_b2 = Arc::new(Int32Array::from(vec![7, 8, 9])) as Arc<dyn
arrow::array::Array>;
+ let batch =
+ RecordBatch::try_new(Arc::clone(&file_schema), vec![col_a, col_b1,
col_b2]).unwrap();
+
+ let filename = get_temp_filename();
+ let filename =
filename.as_path().as_os_str().to_str().unwrap().to_string();
+ let file = File::create(&filename).unwrap();
+ let mut writer = ArrowWriter::try_new(file,
Arc::clone(&batch.schema()), None).unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+
+ // Read with case-insensitive mode, requesting column "b" which
matches both "B" and "b"
+ let required_schema = Arc::new(Schema::new(vec![Field::new("b",
DataType::Int32, false)]));
+
+ let mut spark_parquet_options =
SparkParquetOptions::new(EvalMode::Legacy, "UTC", false);
+ spark_parquet_options.case_sensitive = false;
+
+ let expr_adapter_factory: Arc<dyn PhysicalExprAdapterFactory> =
Arc::new(
+ SparkPhysicalExprAdapterFactory::new(spark_parquet_options, None),
+ );
+
+ let object_store_url = ObjectStoreUrl::local_filesystem();
+ let parquet_source = ParquetSource::new(required_schema);
+ let files = FileGroup::new(vec![
+ PartitionedFile::from_path(filename.to_string()).unwrap()
+ ]);
+ let file_scan_config =
+ FileScanConfigBuilder::new(object_store_url,
Arc::new(parquet_source))
+ .with_file_groups(vec![files])
+ .with_expr_adapter(Some(expr_adapter_factory))
+ .build();
+
+ let parquet_exec = DataSourceExec::new(Arc::new(file_scan_config));
+ let mut stream = parquet_exec
+ .execute(0, Arc::new(TaskContext::default()))
+ .unwrap();
+ let result = stream.next().await.unwrap();
+
+ // Should fail with duplicate field error
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(
+ err_msg.contains("Found duplicate field"),
+ "Expected duplicate field error, got: {err_msg}"
+ );
+ }
}
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
index 863126397..7d23de167 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
@@ -222,22 +222,6 @@ case class CometScanRule(session: SparkSession)
withInfo(scanExec, "Native DataFusion scan does not support Parquet
field ID matching")
return None
}
- // Case-insensitive mode with duplicate field names produces different
errors
- // in DataFusion vs Spark, so fall back to avoid incompatible error
messages
- if (!session.sessionState.conf.caseSensitiveAnalysis) {
- val schemas = Seq(scanExec.requiredSchema, r.dataSchema)
- for (schema <- schemas) {
- val fieldNames =
- schema.fieldNames.map(_.toLowerCase(java.util.Locale.ROOT))
- if (fieldNames.length != fieldNames.distinct.length) {
- withInfo(
- scanExec,
- "Native DataFusion scan does not support " +
- "duplicate field names in case-insensitive mode")
- return None
- }
- }
- }
if (!isSchemaSupported(scanExec, SCAN_NATIVE_DATAFUSION, r)) {
return None
}
diff --git
a/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala
b/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala
index a369e183c..cf79f6af0 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala
@@ -61,6 +61,42 @@ class CometNativeReaderSuite extends CometTestBase with
AdaptiveSparkPlanHelper
}
}
+ test("native reader duplicate fields in case-insensitive mode") {
+ withTempPath { path =>
+ // Write parquet with columns A, B, b (B and b are duplicates
case-insensitively)
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+ spark
+ .range(5)
+ .selectExpr("id as A", "id as B", "id as b")
+ .write
+ .mode("overwrite")
+ .parquet(path.toString)
+ }
+ val tbl = s"dup_fields_${System.currentTimeMillis()}"
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+ sql(s"create table $tbl (A long, B long) using parquet options (path
'${path}')")
+ }
+ // In case-insensitive mode, selecting B should fail because both B and
b match
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
+ val e = intercept[Exception] {
+ sql(s"select B from $tbl").collect()
+ }
+ assert(
+ e.getMessage.contains("duplicate field") ||
+ e.getMessage.contains("Found duplicate field") ||
+ (e.getCause != null && e.getCause.getMessage.contains("duplicate
field")) ||
+ (e.getCause != null && e.getCause.getMessage.contains("Found
duplicate field")),
+ s"Expected duplicate field error, got: ${e.getMessage}")
+ }
+ // In case-sensitive mode, selecting B should work fine
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+ val df = sql(s"select A from $tbl")
+ assert(df.collect().length == 5)
+ }
+ sql(s"drop table if exists $tbl")
+ }
+ }
+
test("native reader - read simple STRUCT fields") {
testSingleLineQuery(
"""
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]