This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 0e65589efb [format] Support case-insensitive column matching for
Parquet and ORC readers (#8337)
0e65589efb is described below
commit 0e65589efbd69ee51d4e73b2f86d38a0482d047c
Author: Zouxxyy <[email protected]>
AuthorDate: Wed Jun 24 09:12:04 2026 +0800
[format] Support case-insensitive column matching for Parquet and ORC
readers (#8337)
Support case-insensitive column matching when reading Parquet and ORC
files.
When `case-sensitive` is set to `false` at the catalog level, the format
readers now resolve columns by case-insensitive name matching. This is
useful for reading external data files whose column names differ only in
case from the table schema (e.g. `Event_Name` in the file vs
`event_name` in the table).
---
.../apache/paimon/format/FileFormatFactory.java | 5 +
.../paimon/table/format/FormatReadBuilder.java | 22 +-
.../apache/paimon/format/orc/OrcFileFormat.java | 2 +
.../apache/paimon/format/orc/OrcReaderFactory.java | 5 +-
.../paimon/format/parquet/ParquetFileFormat.java | 5 +
.../format/parquet/ParquetReaderFactory.java | 41 +++-
.../format/parquet/reader/ParquetReaderUtil.java | 20 +-
.../format/orc/OrcCaseInsensitiveReadTest.java | 124 ++++++++++
.../parquet/ParquetCaseInsensitiveReadTest.java | 267 +++++++++++++++++++++
.../table/FormatTableCaseInsensitiveTest.scala | 125 ++++++++++
10 files changed, 606 insertions(+), 10 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/format/FileFormatFactory.java
b/paimon-common/src/main/java/org/apache/paimon/format/FileFormatFactory.java
index 79354b9c26..b5cba150fe 100644
---
a/paimon-common/src/main/java/org/apache/paimon/format/FileFormatFactory.java
+++
b/paimon-common/src/main/java/org/apache/paimon/format/FileFormatFactory.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format;
import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
@@ -94,5 +95,9 @@ public interface FileFormatFactory {
public MemorySize blockSize() {
return blockSize;
}
+
+ public boolean caseSensitive() {
+ return
options.getOptional(CatalogOptions.CASE_SENSITIVE).orElse(true);
+ }
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
index 05ad36da72..30a567b436 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
@@ -19,6 +19,7 @@
package org.apache.paimon.table.format;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormatDiscover;
@@ -26,6 +27,8 @@ import org.apache.paimon.format.FormatReaderContext;
import org.apache.paimon.format.FormatReaderFactory;
import org.apache.paimon.io.DataFileRecordReader;
import org.apache.paimon.mergetree.compact.ConcatRecordReader;
+import org.apache.paimon.options.CatalogOptions;
+import org.apache.paimon.options.Options;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.partition.PartitionUtils;
import org.apache.paimon.predicate.Predicate;
@@ -78,7 +81,24 @@ public class FormatReadBuilder implements ReadBuilder {
public FormatReadBuilder(FormatTable table) {
this.table = table;
this.readType = this.table.rowType();
- this.options = new CoreOptions(table.options());
+ this.options = mergeCaseSensitive(table);
+ }
+
+ /**
+ * Case-sensitivity is a catalog-level property carried by {@link
CatalogContext}, not part of
+ * the table options. Merge it onto the format options bus once so it
flows naturally to the
+ * format readers via {@link
org.apache.paimon.format.FileFormatFactory.FormatContext}.
+ */
+ private static CoreOptions mergeCaseSensitive(FormatTable table) {
+ CatalogContext ctx = table.catalogContext();
+ if (ctx == null) {
+ return new CoreOptions(table.options());
+ }
+ Options merged = new Options(table.options());
+ ctx.options()
+ .getOptional(CatalogOptions.CASE_SENSITIVE)
+ .ifPresent(v -> merged.set(CatalogOptions.CASE_SENSITIVE, v));
+ return new CoreOptions(merged);
}
@Override
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcFileFormat.java
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcFileFormat.java
index 1072f036d0..01c2fa9517 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcFileFormat.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcFileFormat.java
@@ -78,6 +78,8 @@ public class OrcFileFormat extends FileFormat {
this.orcProperties = getOrcProperties(formatContext.options(),
formatContext);
this.readerConf = new org.apache.hadoop.conf.Configuration(false);
this.orcProperties.forEach((k, v) -> readerConf.set(k.toString(),
v.toString()));
+ OrcConf.IS_SCHEMA_EVOLUTION_CASE_SENSITIVE.setBoolean(
+ readerConf, formatContext.caseSensitive());
this.writerConf = new org.apache.hadoop.conf.Configuration(false);
this.orcProperties.forEach((k, v) -> writerConf.set(k.toString(),
v.toString()));
this.readBatchSize = formatContext.readBatchSize();
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
index 81bfdd3185..b1de74242a 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
@@ -288,8 +288,9 @@ public class OrcReaderFactory implements
FormatReaderFactory {
.range(offsetAndLength.getLeft(),
offsetAndLength.getRight())
.useZeroCopy(OrcConf.USE_ZEROCOPY.getBoolean(conf))
.skipCorruptRecords(OrcConf.SKIP_CORRUPT_DATA.getBoolean(conf))
- .tolerateMissingSchema(
-
OrcConf.TOLERATE_MISSING_SCHEMA.getBoolean(conf));
+
.tolerateMissingSchema(OrcConf.TOLERATE_MISSING_SCHEMA.getBoolean(conf))
+ .isSchemaEvolutionCaseAware(
+
OrcConf.IS_SCHEMA_EVOLUTION_CASE_SENSITIVE.getBoolean(conf));
if (!conjunctPredicates.isEmpty() && !deletionVectorsEnabled &&
selection == null) {
// row group filter push down will make row number change
incorrect
// so deletion vectors mode and bitmap index cannot work with
row group push down
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetFileFormat.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetFileFormat.java
index 9ccf88bd17..66ab9565e7 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetFileFormat.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetFileFormat.java
@@ -27,6 +27,7 @@ import org.apache.paimon.format.SimpleStatsExtractor;
import org.apache.paimon.format.parquet.writer.RowDataParquetBuilder;
import org.apache.paimon.format.variant.VariantInferenceConfig;
import org.apache.paimon.format.variant.VariantInferenceWriterFactory;
+import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
import org.apache.paimon.predicate.Predicate;
@@ -106,6 +107,10 @@ public class ParquetFileFormat extends FileFormat {
ParquetOutputFormat.BLOCK_SIZE,
String.valueOf(blockSize.getBytes()));
}
+ // case-sensitive is not a parquet.* key, so it is dropped by
getIdentifierPrefixOptions;
+ // carry the resolved value onto the reader options bus for
ParquetReaderFactory to read.
+ parquetOptions.set(CatalogOptions.CASE_SENSITIVE,
context.caseSensitive());
+
return parquetOptions;
}
}
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetReaderFactory.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetReaderFactory.java
index fad603e74c..43c516a48c 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetReaderFactory.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetReaderFactory.java
@@ -28,6 +28,7 @@ import org.apache.paimon.data.variant.VariantPathSegment;
import org.apache.paimon.format.FormatReaderFactory;
import org.apache.paimon.format.parquet.reader.VectorizedParquetRecordReader;
import org.apache.paimon.format.parquet.type.ParquetField;
+import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.Options;
import org.apache.paimon.reader.FileRecordReader;
import org.apache.paimon.types.ArrayType;
@@ -82,6 +83,7 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
private final Options conf;
private final DataField[] readFields;
private final int batchSize;
+ private final boolean caseSensitive;
@Nullable private final FilterCompat.Filter filter;
/**
@@ -102,6 +104,7 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
this.conf = conf;
this.readFields = readType.getFields().toArray(new DataField[0]);
this.batchSize = batchSize;
+ this.caseSensitive =
conf.getOptional(CatalogOptions.CASE_SENSITIVE).orElse(true);
this.filter = filter;
}
@@ -175,21 +178,47 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
Type[] types = new Type[readFields.length];
for (int i = 0; i < readFields.length; ++i) {
String fieldName = readFields[i].name();
- if (!parquetSchema.containsField(fieldName)) {
+ Type matched = matchParquetField(parquetSchema, fieldName);
+ if (matched != null) {
+ types[i] = clipParquetType(readFields[i].type(), matched);
+ } else {
LOG.warn(
"{} does not exist in {}, will fill the field with
null.",
fieldName,
parquetSchema);
types[i] =
ParquetSchemaConverter.convertToParquetType(readFields[i]);
- } else {
- Type parquetType = parquetSchema.getType(fieldName);
- types[i] = clipParquetType(readFields[i].type(), parquetType);
}
}
return Types.buildMessage().addFields(types).named(PAIMON_SCHEMA);
}
+ /**
+ * Resolves a field of {@code group} by {@code fieldName}, returning
{@code null} when no field
+ * matches. In case-sensitive mode only an exact-name match is accepted.
In case-insensitive
+ * mode a name that matches more than one parquet field (differing only by
case) is ambiguous
+ * and fails, mirroring Spark's case-insensitive Parquet resolution.
+ */
+ @Nullable
+ private Type matchParquetField(GroupType group, String fieldName) {
+ if (caseSensitive) {
+ return group.containsField(fieldName) ? group.getType(fieldName) :
null;
+ }
+ Type matched = null;
+ for (Type field : group.getFields()) {
+ if (field.getName().equalsIgnoreCase(fieldName)) {
+ if (matched != null) {
+ throw new RuntimeException(
+ String.format(
+ "Found duplicate field(s) \"%s\": [%s, %s]
in case-insensitive mode",
+ fieldName, matched.getName(),
field.getName()));
+ }
+ matched = field;
+ }
+ }
+ return matched;
+ }
+
/** Clips `parquetType` by `readType`. */
private Type clipParquetType(DataType readType, Type parquetType) {
switch (readType.getTypeRoot()) {
@@ -202,8 +231,8 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
List<Type> rowGroupFields = new ArrayList<>();
for (DataField field : rowType.getFields()) {
String fieldName = field.name();
- if (rowGroup.containsField(fieldName)) {
- Type type = rowGroup.getType(fieldName);
+ Type type = matchParquetField(rowGroup, fieldName);
+ if (type != null) {
rowGroupFields.add(clipParquetType(field.type(),
type));
} else {
// todo: support nested field missing
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetReaderUtil.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetReaderUtil.java
index 316cc2e4fe..02ffe69931 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetReaderUtil.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetReaderUtil.java
@@ -62,6 +62,7 @@ import org.apache.parquet.io.ColumnIO;
import org.apache.parquet.io.GroupColumnIO;
import org.apache.parquet.io.MessageColumnIO;
import org.apache.parquet.io.PrimitiveColumnIO;
+import org.apache.parquet.schema.GroupType;
import org.apache.parquet.schema.MessageType;
import org.apache.parquet.schema.Type;
@@ -275,7 +276,7 @@ public class ParquetReaderUtil {
constructField(
children.get(i),
lookupColumnByName(groupColumnIO, childName),
- parquetType.asGroupType().getType(childName)));
+ getTypeIgnoreCase(parquetType.asGroupType(),
childName)));
}
return new ParquetGroupField(
@@ -414,6 +415,23 @@ public class ParquetReaderUtil {
columnName, String.join(".",
groupColumnIO.getFieldPath())));
}
+ /**
+ * Resolves a child {@link Type} by name, first by exact match then
case-insensitively,
+ * mirroring {@link #lookupColumnByName}. Falls back to {@link
GroupType#getType(String)} (which
+ * throws) so a genuinely missing field keeps the original failure
behavior.
+ */
+ private static Type getTypeIgnoreCase(GroupType groupType, String
fieldName) {
+ if (groupType.containsField(fieldName)) {
+ return groupType.getType(fieldName);
+ }
+ for (Type field : groupType.getFields()) {
+ if (field.getName().equalsIgnoreCase(fieldName)) {
+ return field;
+ }
+ }
+ return groupType.getType(fieldName);
+ }
+
public static GroupColumnIO getMapKeyValueColumn(GroupColumnIO
groupColumnIO) {
while (groupColumnIO.getChildrenCount() == 1) {
groupColumnIO = (GroupColumnIO) groupColumnIO.getChild(0);
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcCaseInsensitiveReadTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcCaseInsensitiveReadTest.java
new file mode 100644
index 0000000000..1af3e0b8d1
--- /dev/null
+++
b/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcCaseInsensitiveReadTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.format.orc;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FormatReaderContext;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.ql.exec.vector.BytesColumnVector;
+import org.apache.hadoop.hive.ql.exec.vector.LongColumnVector;
+import org.apache.hadoop.hive.ql.exec.vector.VectorizedRowBatch;
+import org.apache.orc.OrcConf;
+import org.apache.orc.OrcFile;
+import org.apache.orc.TypeDescription;
+import org.apache.orc.Writer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test case-insensitive column matching in {@link OrcReaderFactory}. */
+class OrcCaseInsensitiveReadTest {
+
+ @TempDir File folder;
+
+ @Test
+ void testCaseInsensitiveColumnMatching() throws Exception {
+ Path path = writeMixedCaseOrc();
+
+ // Read with lowercase column names + caseSensitive=false.
+ RowType readType =
+ RowType.builder()
+ .field("event_name", DataTypes.STRING())
+ .field("campaign_id", DataTypes.STRING())
+ .field("amount", DataTypes.BIGINT())
+ .build();
+
+ Configuration conf = new Configuration(false);
+ OrcConf.IS_SCHEMA_EVOLUTION_CASE_SENSITIVE.setBoolean(conf, false);
+
+ OrcReaderFactory factory =
+ new OrcReaderFactory(conf, readType, Collections.emptyList(),
1024, false, false);
+
+ LocalFileIO fileIO = new LocalFileIO();
+ List<InternalRow> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+ factory.createReader(
+ new FormatReaderContext(fileIO, path,
fileIO.getFileSize(path)))) {
+ reader.forEachRemaining(
+ row ->
+ rows.add(
+ GenericRow.of(
+ row.isNullAt(0) ? null :
row.getString(0).copy(),
+ row.isNullAt(1) ? null :
row.getString(1).copy(),
+ row.isNullAt(2) ? null : (Object)
row.getLong(2))));
+ }
+
+ assertThat(rows).hasSize(3);
+ assertThat(rows.get(0).getString(0).toString()).isEqualTo("install");
+ assertThat(rows.get(0).getString(1).toString()).isEqualTo("c001");
+ assertThat(rows.get(0).getLong(2)).isEqualTo(100L);
+ }
+
+ private Path writeMixedCaseOrc() throws Exception {
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ TypeDescription schema =
+ TypeDescription.fromString(
+
"struct<Event_Name:string,Campaign_ID:string,Amount:bigint>");
+ Configuration conf = new Configuration(false);
+
+ try (Writer writer =
+ OrcFile.createWriter(
+ new org.apache.hadoop.fs.Path(path.toString()),
+ OrcFile.writerOptions(conf).setSchema(schema))) {
+ VectorizedRowBatch batch = schema.createRowBatch();
+ BytesColumnVector eventName = (BytesColumnVector) batch.cols[0];
+ BytesColumnVector campaignId = (BytesColumnVector) batch.cols[1];
+ LongColumnVector amount = (LongColumnVector) batch.cols[2];
+
+ String[][] data = {{"install", "c001"}, {"purchase", "c002"},
{"login", "c001"}};
+ long[] amounts = {100L, 200L, 300L};
+
+ for (int i = 0; i < 3; i++) {
+ int row = batch.size++;
+ byte[] eventBytes =
data[i][0].getBytes(StandardCharsets.UTF_8);
+ byte[] cidBytes = data[i][1].getBytes(StandardCharsets.UTF_8);
+ eventName.setVal(row, eventBytes);
+ campaignId.setVal(row, cidBytes);
+ amount.vector[row] = amounts[i];
+ }
+ writer.addRowBatch(batch);
+ }
+ return path;
+ }
+}
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetCaseInsensitiveReadTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetCaseInsensitiveReadTest.java
new file mode 100644
index 0000000000..f8c22de58b
--- /dev/null
+++
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetCaseInsensitiveReadTest.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.format.parquet;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FormatReaderContext;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.CatalogOptions;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.column.ParquetProperties;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.filter2.compat.FilterCompat;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.hadoop.util.HadoopOutputFile;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test case-insensitive column matching in {@link ParquetReaderFactory}. */
+class ParquetCaseInsensitiveReadTest {
+
+ @TempDir File folder;
+
+ @Test
+ void testCaseInsensitiveColumnMatching() throws Exception {
+ Path path = writeMixedCaseParquet();
+
+ // Read with lowercase column names + caseSensitive=false.
+ RowType readType =
+ RowType.builder()
+ .field("event_name", DataTypes.STRING())
+ .field("campaign_id", DataTypes.STRING())
+ .field("amount", DataTypes.BIGINT())
+ .build();
+
+ List<InternalRow> rows = read(path, readType, false);
+
+ assertThat(rows).hasSize(3);
+ assertThat(rows.get(0).getString(0).toString()).isEqualTo("install");
+ assertThat(rows.get(0).getString(1).toString()).isEqualTo("c001");
+ assertThat(rows.get(0).getLong(2)).isEqualTo(100L);
+ }
+
+ @Test
+ void testCaseSensitiveReturnsNull() throws Exception {
+ Path path = writeMixedCaseParquet();
+
+ RowType readType =
+ RowType.builder()
+ .field("event_name", DataTypes.STRING())
+ .field("amount", DataTypes.BIGINT())
+ .build();
+
+ // caseSensitive=true: lowercase names won't match mixed-case parquet
columns.
+ List<InternalRow> rows = read(path, readType, true);
+
+ assertThat(rows).hasSize(3);
+ assertThat(rows.get(0).isNullAt(0)).isTrue();
+ assertThat(rows.get(0).isNullAt(1)).isTrue();
+ }
+
+ @Test
+ void testNestedCaseInsensitiveColumnMatching() throws Exception {
+ Path path = writeNestedMixedCaseParquet();
+
+ // Lowercase top-level and nested field names + caseSensitive=false.
+ RowType readType =
+ RowType.builder()
+ .field("event_name", DataTypes.STRING())
+ .field(
+ "user_info",
+ RowType.builder()
+ .field("user_name", DataTypes.STRING())
+ .field("user_age", DataTypes.BIGINT())
+ .build())
+ .build();
+
+ List<InternalRow> rows = read(path, readType, false);
+
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).getString(0).toString()).isEqualTo("install");
+ InternalRow nested = rows.get(0).getRow(1, 2);
+ assertThat(nested.getString(0).toString()).isEqualTo("alice");
+ assertThat(nested.getLong(1)).isEqualTo(20L);
+ }
+
+ @Test
+ void testAmbiguousCaseInsensitiveMatchFails() throws Exception {
+ Path path = writeDuplicateCaseParquet();
+
+ RowType readType = RowType.builder().field("col",
DataTypes.STRING()).build();
+
+ assertThatThrownBy(() -> read(path, readType, false))
+ .hasMessageContaining("Found duplicate field(s)")
+ .hasMessageContaining("col");
+ }
+
+ private List<InternalRow> read(Path path, RowType readType, boolean
caseSensitive)
+ throws Exception {
+ Options options = new Options();
+ options.set(CatalogOptions.CASE_SENSITIVE, caseSensitive);
+ ParquetReaderFactory factory =
+ new ParquetReaderFactory(options, readType, 1024,
FilterCompat.NOOP);
+ LocalFileIO fileIO = new LocalFileIO();
+ List<InternalRow> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+ factory.createReader(
+ new FormatReaderContext(fileIO, path,
fileIO.getFileSize(path)))) {
+ // Materialize a copy because InternalRow instances are reused
across iterations.
+ reader.forEachRemaining(row -> rows.add(copy(row, readType)));
+ }
+ return rows;
+ }
+
+ private static InternalRow copy(InternalRow row, RowType rowType) {
+ Object[] values = new Object[rowType.getFieldCount()];
+ for (int i = 0; i < values.length; i++) {
+ if (row.isNullAt(i)) {
+ continue;
+ }
+ switch (rowType.getTypeAt(i).getTypeRoot()) {
+ case VARCHAR:
+ case CHAR:
+ values[i] = row.getString(i).copy();
+ break;
+ case BIGINT:
+ values[i] = row.getLong(i);
+ break;
+ case ROW:
+ RowType child = (RowType) rowType.getTypeAt(i);
+ values[i] = copy(row.getRow(i, child.getFieldCount()),
child);
+ break;
+ default:
+ throw new UnsupportedOperationException(
+ "Unhandled type in test: " + rowType.getTypeAt(i));
+ }
+ }
+ return GenericRow.of(values);
+ }
+
+ private Path writeMixedCaseParquet() throws Exception {
+ MessageType schema =
+ MessageTypeParser.parseMessageType(
+ "message root {\n"
+ + " optional binary Event_Name (UTF8);\n"
+ + " optional binary Campaign_ID (UTF8);\n"
+ + " optional int64 Amount;\n"
+ + "}");
+
+ String[][] data = {{"install", "c001"}, {"purchase", "c002"},
{"login", "c001"}};
+ long[] amounts = {100L, 200L, 300L};
+ return write(
+ schema,
+ factory -> {
+ List<Group> groups = new ArrayList<>();
+ for (int i = 0; i < data.length; i++) {
+ groups.add(
+ factory.newGroup()
+ .append("Event_Name", data[i][0])
+ .append("Campaign_ID", data[i][1])
+ .append("Amount", amounts[i]));
+ }
+ return groups;
+ });
+ }
+
+ private Path writeNestedMixedCaseParquet() throws Exception {
+ MessageType schema =
+ MessageTypeParser.parseMessageType(
+ "message root {\n"
+ + " optional binary Event_Name (UTF8);\n"
+ + " optional group User_Info {\n"
+ + " optional binary User_Name (UTF8);\n"
+ + " optional int64 User_Age;\n"
+ + " }\n"
+ + "}");
+
+ String[][] data = {{"install", "alice", "20"}, {"login", "bob", "30"}};
+ return write(
+ schema,
+ factory -> {
+ List<Group> groups = new ArrayList<>();
+ for (String[] row : data) {
+ Group g = factory.newGroup().append("Event_Name",
row[0]);
+ g.addGroup("User_Info")
+ .append("User_Name", row[1])
+ .append("User_Age", Long.parseLong(row[2]));
+ groups.add(g);
+ }
+ return groups;
+ });
+ }
+
+ private Path writeDuplicateCaseParquet() throws Exception {
+ // An exact match ("col") coexists with a case-variant ("COL");
reading "col" must still be
+ // ambiguous in case-insensitive mode, mirroring Spark.
+ MessageType schema =
+ MessageTypeParser.parseMessageType(
+ "message root {\n"
+ + " optional binary col (UTF8);\n"
+ + " optional binary COL (UTF8);\n"
+ + "}");
+
+ return write(
+ schema,
+ factory ->
+ Collections.singletonList(
+ factory.newGroup().append("col",
"a").append("COL", "b")));
+ }
+
+ private interface GroupSupplier {
+ List<Group> get(SimpleGroupFactory factory);
+ }
+
+ private Path write(MessageType schema, GroupSupplier supplier) throws
Exception {
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ Configuration conf = new Configuration();
+ try (ParquetWriter<Group> writer =
+ ExampleParquetWriter.builder(
+ HadoopOutputFile.fromPath(
+ new
org.apache.hadoop.fs.Path(path.toString()), conf))
+ .withType(schema)
+ .withConf(conf)
+
.withWriterVersion(ParquetProperties.WriterVersion.PARQUET_1_0)
+ .build()) {
+ for (Group group : supplier.get(new SimpleGroupFactory(schema))) {
+ writer.write(group);
+ }
+ }
+ return path;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/FormatTableCaseInsensitiveTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/FormatTableCaseInsensitiveTest.scala
new file mode 100644
index 0000000000..d857819710
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/FormatTableCaseInsensitiveTest.scala
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.table
+
+import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
+import org.apache.paimon.spark.SparkCatalog
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+
+class FormatTableCaseInsensitiveTest extends
PaimonSparkTestWithRestCatalogBase {
+
+ override protected def sparkConf: SparkConf = {
+ val conf = super.sparkConf
+ .set("spark.sql.catalog.paimon.case-sensitive", "false")
+ Seq("metastore", "uri", "token", "warehouse", "token.provider").foreach {
+ key =>
+ conf.getOption(s"spark.sql.catalog.paimon.$key").foreach {
+ value => conf.set(s"spark.sql.catalog.paimon_cs.$key", value)
+ }
+ }
+ conf
+ .set("spark.sql.catalog.paimon_cs", classOf[SparkCatalog].getName)
+ .set("spark.sql.catalog.paimon_cs.case-sensitive", "true")
+ }
+
+ override protected def beforeEach(): Unit = {
+ super.beforeEach()
+ sql("USE paimon")
+ sql("CREATE DATABASE IF NOT EXISTS test_db")
+ sql("USE test_db")
+ }
+
+ test("format table: case-insensitive read all and partial columns") {
+ Seq("parquet", "orc").foreach {
+ format =>
+ val dataPath = s"${tempDBDir.getCanonicalPath}/${format}_ci"
+ withTable("writer_tbl", "reader_tbl") {
+ sql(s"""CREATE TABLE writer_tbl (Event_Name STRING, Campaign_ID
STRING, Amount BIGINT)
+ |USING $format LOCATION '$dataPath'
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |""".stripMargin)
+ sql("INSERT INTO writer_tbl VALUES ('install', 'c001', 100),
('purchase', 'c002', 200)")
+
+ sql(s"""CREATE TABLE reader_tbl (event_name STRING, campaign_id
STRING, amount BIGINT)
+ |USING $format LOCATION '$dataPath'
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |""".stripMargin)
+
+ // case-sensitive=false: all columns
+ checkAnswer(
+ sql("SELECT * FROM paimon.test_db.reader_tbl ORDER BY amount"),
+ Seq(Row("install", "c001", 100L), Row("purchase", "c002", 200L))
+ )
+ // case-sensitive=true: all columns return nulls
+ checkAnswer(
+ sql("SELECT * FROM paimon_cs.test_db.reader_tbl ORDER BY amount"),
+ Seq(Row(null, null, null), Row(null, null, null))
+ )
+
+ // case-sensitive=false: partial columns
+ checkAnswer(
+ sql("SELECT event_name, amount FROM paimon.test_db.reader_tbl
ORDER BY amount"),
+ Seq(Row("install", 100L), Row("purchase", 200L))
+ )
+ // case-sensitive=true: partial columns return nulls
+ checkAnswer(
+ sql("SELECT event_name, amount FROM paimon_cs.test_db.reader_tbl
ORDER BY amount"),
+ Seq(Row(null, null), Row(null, null))
+ )
+ }
+ }
+ }
+
+ test("parquet format table: case-insensitive read nested struct columns") {
+ val dataPath = s"${tempDBDir.getCanonicalPath}/parquet_ci_nested"
+ withTable("nested_writer", "nested_reader") {
+ sql(s"""CREATE TABLE nested_writer (
+ | Event_Name STRING,
+ | User_Info STRUCT<User_Name: STRING, User_Age: BIGINT>
+ |) USING parquet LOCATION '$dataPath'
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |""".stripMargin)
+ sql(
+ "INSERT INTO nested_writer VALUES ('install',
named_struct('User_Name', 'alice', 'User_Age', 20L)),"
+ + " ('login', named_struct('User_Name', 'bob', 'User_Age', 30L))")
+
+ sql(s"""CREATE TABLE nested_reader (
+ | event_name STRING,
+ | user_info STRUCT<user_name: STRING, user_age: BIGINT>
+ |) USING parquet LOCATION '$dataPath'
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |""".stripMargin)
+
+ // case-sensitive=false: nested struct fields match
+ checkAnswer(
+ sql(
+ "SELECT event_name, user_info.user_name, user_info.user_age FROM
paimon.test_db.nested_reader ORDER BY event_name"),
+ Seq(Row("install", "alice", 20L), Row("login", "bob", 30L))
+ )
+ // case-sensitive=true: returns nulls
+ checkAnswer(
+ sql(
+ "SELECT event_name, user_info.user_name, user_info.user_age FROM
paimon_cs.test_db.nested_reader ORDER BY event_name"),
+ Seq(Row(null, null, null), Row(null, null, null))
+ )
+ }
+ }
+}