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 aacb745227 [format] Follow Parquet list backward-compatibility rules
in schema clipping (#9550)
aacb745227 is described below
commit aacb74522769d5e4ae42af7d6ebda631a301f242
Author: Juntao Zhang <[email protected]>
AuthorDate: Sun Sep 20 11:43:34 2026 +0800
[format] Follow Parquet list backward-compatibility rules in schema
clipping (#9550)
---
.../format/parquet/ParquetListLayoutResolver.java | 226 ++++++++
.../format/parquet/ParquetReaderFactory.java | 64 ++-
.../format/parquet/ParquetSchemaConverter.java | 346 +++++++-----
.../parquet/VariantShreddingReadPlanFactory.java | 6 +-
.../format/parquet/VariantShreddingTypePruner.java | 51 +-
.../format/parquet/reader/ParquetReaderUtil.java | 112 ++--
.../format/parquet/ParquetLegacyListReadTest.java | 595 +++++++++++++++++++++
.../parquet/ParquetListLayoutResolverTest.java | 191 +++++++
.../format/parquet/ParquetSchemaConverterTest.java | 244 +++++++++
9 files changed, 1556 insertions(+), 279 deletions(-)
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetListLayoutResolver.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetListLayoutResolver.java
new file mode 100644
index 0000000000..2ae53e83aa
--- /dev/null
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetListLayoutResolver.java
@@ -0,0 +1,226 @@
+/*
+ * 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.parquet.schema.GroupType;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.Type;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.LIST_ELEMENT_NAME;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Resolves Parquet list layouts, following the backward-compatibility rules
in the Parquet spec: <a
+ *
href="https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#backward-compatibility-rules">LogicalTypes#Backward-compatibility-rules</a>.
+ *
+ * <p>All list layout decisions should be made through this class so that
schema inference,
+ * requested-schema clipping and reader construction share a single
interpretation.
+ */
+public final class ParquetListLayoutResolver {
+
+ private static final String LIST_WRAPPER_NAME = "list";
+ private static final String LEGACY_LIST_ARRAY_NAME = "array";
+
+ private ParquetListLayoutResolver() {}
+
+ /** Returns true if the given group is annotated as a Parquet LIST logical
type. */
+ public static boolean isList(GroupType listType) {
+ return listType.getLogicalTypeAnnotation()
+ instanceof LogicalTypeAnnotation.ListLogicalTypeAnnotation;
+ }
+
+ /**
+ * Whether the given group has the legacy nested-list shape: an
unannotated {@code REPEATED}
+ * group whose single child is also {@code REPEATED}.
+ *
+ * <p>This is the Parquet spec's backward-compatibility <b>Rule 3</b> and
appears as the element
+ * of an annotated list, e.g. {@code repeated group element { repeated
int32 array; }}. The
+ * repeated child is the element of the nested list.
+ */
+ public static boolean isLegacyNestedList(GroupType groupType) {
+ return groupType.isRepetition(Type.Repetition.REPEATED)
+ && groupType.getLogicalTypeAnnotation() == null
+ && groupType.getFieldCount() == 1
+ && groupType.getType(0).getRepetition() ==
Type.Repetition.REPEATED;
+ }
+
+ /**
+ * Returns true if the given group is a three-level Parquet list.
+ *
+ * <p>In a three-level list the immediate repeated child is a wrapper
group whose single
+ * non-repeated child is the actual element type. This covers the
canonical layout ({@code list
+ * -> element}) as well as legacy wrappers such as Hive's {@code bag}
layout.
+ *
+ * <p>This corresponds to the Parquet spec's backward-compatibility
<b>Rule 5</b>: a repeated
+ * group that contains exactly one non-repeated child is a wrapper, unless
it matches one of
+ * Rules 1-4.
+ *
+ * <p>The compatibility encodings that are <em>not</em> three-level are:
+ *
+ * <ul>
+ * <li><b>Rule 1</b>: the repeated field is a primitive and is itself
the element type.
+ * <li><b>Rule 2</b>: the repeated field is a group with multiple fields
and is itself the
+ * element type.
+ * <li><b>Rule 3</b>: the repeated field is a group whose single child
is also repeated; the
+ * group itself is the element type.
+ * <li><b>Rule 4</b>: the repeated field is a group named {@code
"array"} or {@code
+ * "<list>_tuple"} with a single child; the group itself is the
element type.
+ * </ul>
+ */
+ public static boolean isThreeLevelList(Type type) {
+ if (type.isPrimitive()) {
+ return false;
+ }
+
+ GroupType listType = type.asGroupType();
+ if (!isList(listType)) {
+ return false;
+ }
+
+ // A list must have exactly one repeated child (the middle level).
+ if (listType.getFieldCount() != 1) {
+ return false;
+ }
+ Type middle = listType.getType(0);
+ if (middle.isPrimitive() || middle.getRepetition() !=
Type.Repetition.REPEATED) {
+ return false;
+ }
+ GroupType repeatedGroup = middle.asGroupType();
+
+ // Rule 5: the repeated group is a wrapper containing exactly one
non-repeated child.
+ if (repeatedGroup.getFieldCount() != 1
+ || repeatedGroup.getType(0).getRepetition() ==
Type.Repetition.REPEATED) {
+ return false;
+ }
+
+ // Rule 4: legacy "array" and "<list>_tuple" encodings are not
wrappers; the repeated
+ // group itself is the element type.
+ return !LEGACY_LIST_ARRAY_NAME.equals(repeatedGroup.getName())
+ && !(listType.getName() +
"_tuple").equals(repeatedGroup.getName());
+ }
+
+ /**
+ * Returns true if the given group follows the canonical three-level
Parquet list layout ({@code
+ * list -> element}).
+ *
+ * <p>The canonical layout is described in the Parquet spec: <a
+ *
href="https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#lists">LogicalTypes#Lists</a>
+ */
+ public static boolean isCanonicalList(Type type) {
+ if (!isThreeLevelList(type)) {
+ return false;
+ }
+
+ Type middle = type.asGroupType().getType(0);
+ Type element = middle.asGroupType().getType(0);
+ return LIST_WRAPPER_NAME.equals(middle.getName())
+ && LIST_ELEMENT_NAME.equals(element.getName());
+ }
+
+ /**
+ * Resolves the element type of the given LIST-annotated group according
to the Parquet spec's
+ * backward-compatibility rules for lists.
+ *
+ * <p>For a three-level list (Rule 5) the returned type is the single
child of the repeated
+ * wrapper. For Rules 1-4 the repeated field itself is returned because it
is the element type.
+ */
+ public static Type resolveElementType(GroupType listType) {
+ checkArgument(
+ isList(listType) || isLegacyNestedList(listType),
+ "Expected LIST-annotated group but got: %s",
+ listType);
+
+ if (isThreeLevelList(listType)) {
+ return listType.getType(0).asGroupType().getType(0);
+ }
+
+ return listType.getType(0);
+ }
+
+ /**
+ * A schema-level manifest of list layouts, modeled after parquet-cpp's
{@code SchemaManifest}.
+ *
+ * <p>Built once from the file schema, it resolves the three-level verdict
of every
+ * LIST-annotated node in the file and records it by field path, so that
reader construction
+ * interprets list layouts against the file schema rather than the
(possibly reshaped) requested
+ * schema. Field paths are the identifier shared by the file schema, the
requested schema and
+ * the ColumnIO tree: clipping may rebuild nodes, but it preserves names,
so paths stay stable
+ * where node identity would not.
+ *
+ * <p>Paths absent from this context belong to nodes that are not
LIST-annotated in the file
+ * schema (or synthetic fill fields, whose requested shape is
Paimon-canonical); the verdict
+ * falls back to interpreting the requested node itself.
+ */
+ public static final class LayoutContext {
+ private static final LayoutContext EMPTY = new LayoutContext();
+
+ private final Map<List<String>, Boolean> threeLevelMapping = new
HashMap<>();
+
+ /**
+ * Returns a context carrying no file layout information. Lookups
always fall back to
+ * interpreting the requested node itself, which is the shape-based
behavior.
+ */
+ public static LayoutContext empty() {
+ return EMPTY;
+ }
+
+ /** Builds the manifest by resolving every LIST-annotated node of the
file schema. */
+ public static LayoutContext fromFileSchema(GroupType fileSchema) {
+ LayoutContext context = new LayoutContext();
+ for (Type field : fileSchema.getFields()) {
+ collect(field, Collections.singletonList(field.getName()),
context);
+ }
+ return context;
+ }
+
+ private static void collect(Type node, List<String> path,
LayoutContext context) {
+ if (node.isPrimitive()) {
+ return;
+ }
+ GroupType group = node.asGroupType();
+ if (isList(group)) {
+ context.threeLevelMapping.put(
+ path,
ParquetListLayoutResolver.isThreeLevelList(group));
+ }
+ for (Type child : group.getFields()) {
+ List<String> childPath = new ArrayList<>(path);
+ childPath.add(child.getName());
+ collect(child, childPath, context);
+ }
+ }
+
+ /**
+ * Returns whether the list at {@code path} is a three-level list, as
resolved against the
+ * file schema; falls back to interpreting {@code requestedGroup}
itself when the path is
+ * not an annotated list in the file schema.
+ */
+ public boolean isThreeLevelList(GroupType requestedGroup, List<String>
path) {
+ Boolean threeLevel = threeLevelMapping.get(path);
+ return threeLevel != null
+ ? threeLevel
+ :
ParquetListLayoutResolver.isThreeLevelList(requestedGroup);
+ }
+ }
+}
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 e66edb6f20..557002831d 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
@@ -26,6 +26,7 @@ import
org.apache.paimon.data.shredding.MapSharedShreddingReadPlanFactory;
import org.apache.paimon.data.shredding.ShreddingReadPlan;
import org.apache.paimon.format.FormatMetadataUtils;
import org.apache.paimon.format.FormatReaderFactory;
+import
org.apache.paimon.format.parquet.ParquetListLayoutResolver.LayoutContext;
import org.apache.paimon.format.parquet.reader.VectorizedParquetRecordReader;
import org.apache.paimon.format.parquet.type.ParquetField;
import org.apache.paimon.format.shredding.ShreddingFormatReader;
@@ -58,7 +59,6 @@ import org.apache.parquet.schema.ConversionPatterns;
import org.apache.parquet.schema.GroupType;
import org.apache.parquet.schema.LogicalTypeAnnotation;
import org.apache.parquet.schema.MessageType;
-import org.apache.parquet.schema.OriginalType;
import org.apache.parquet.schema.PrimitiveType;
import org.apache.parquet.schema.Type;
import org.apache.parquet.schema.Types;
@@ -81,8 +81,11 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.IntFunction;
import static
org.apache.paimon.data.columnar.ColumnVectorUtils.createParquetWritableColumnVector;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.isLegacyNestedList;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.isList;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.isThreeLevelList;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.resolveElementType;
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.PAIMON_SCHEMA;
-import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetListElementType;
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetMapKeyValueType;
import static
org.apache.paimon.format.parquet.reader.ParquetReaderUtil.buildFieldsList;
@@ -258,9 +261,10 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
}
private RequestedSchema createRequestedSchema(MessageType fileSchema,
DataField[] readFields) {
+ LayoutContext listLayout = LayoutContext.fromFileSchema(fileSchema);
MessageType rs = clipParquetSchema(fileSchema, readFields);
MessageColumnIO columnIO = new ColumnIOFactory().getColumnIO(rs);
- List<ParquetField> f = buildFieldsList(readFields, columnIO, rs);
+ List<ParquetField> f = buildFieldsList(readFields, columnIO, rs,
listLayout);
return new RequestedSchema(rs, f);
}
@@ -362,31 +366,37 @@ public class ParquetReaderFactory implements
FormatReaderFactory {
Preconditions.checkArgument(
listSubFields == 1,
"Parquet list group type should only have one middle
level REPEATED field.");
- // There are two representations for array type in parquet.
- // See link:
- //
https://impala.apache.org/docs/build/html/topics/impala_parquet_array_resolution.html.
- int level = arrayGroup.getType(0) instanceof GroupType ? 3 : 2;
- Type elementType =
- clipParquetType(elementReadType,
parquetListElementType(arrayGroup));
-
- if (level == 3) {
- // In case that the name in middle level is not "list".
- Type groupMiddle =
- new GroupType(
- Type.Repetition.REPEATED,
- arrayGroup.getType(0).getName(),
- elementType);
- return new GroupType(
- arrayGroup.getRepetition(),
- arrayGroup.getName(),
- OriginalType.LIST,
- groupMiddle);
+ if (isList(arrayGroup)) {
+ boolean threeLevel = isThreeLevelList(arrayGroup);
+ Type originalElement = resolveElementType(arrayGroup);
+ Type elementType = clipParquetType(elementReadType,
originalElement);
+ if (threeLevel) {
+ Type clippedMiddle =
+ arrayGroup
+ .getType(0)
+ .asGroupType()
+
.withNewFields(Collections.singletonList(elementType));
+ return
arrayGroup.withNewFields(Collections.singletonList(clippedMiddle));
+ } else {
+ // Rules 1-4: the repeated field itself is the
element. Keep it (clipped)
+ // in place so that reader construction unwraps by
path against the file
+ // schema instead of guessing from this reshaped
requested shape.
+ return
arrayGroup.withNewFields(Collections.singletonList(elementType));
+ }
+ } else if (isLegacyNestedList(arrayGroup)) {
+ // Rule 3: an unannotated repeated group is itself the
element of the annotated
+ // outer list, and its single repeated child is the
element of the nested inner
+ // list.
+ Type originalElement = arrayGroup.getType(0);
+ Type elementType = clipParquetType(elementReadType,
originalElement);
+ return
arrayGroup.withNewFields(Collections.singletonList(elementType));
} else {
- return new GroupType(
- arrayGroup.getRepetition(),
- arrayGroup.getName(),
- OriginalType.LIST,
- elementType);
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot read Parquet group '%s' as an
ARRAY: it is neither "
+ + "LIST-annotated nor a legacy
nested list. Parquet type: %s, "
+ + "read type: %s",
+ arrayGroup.getName(), arrayGroup,
readType));
}
default:
return parquetType;
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetSchemaConverter.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetSchemaConverter.java
index 912baec072..1b6a177e9d 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetSchemaConverter.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetSchemaConverter.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format.parquet;
import org.apache.paimon.data.variant.Variant;
+import
org.apache.paimon.format.parquet.ParquetListLayoutResolver.LayoutContext;
import org.apache.paimon.table.SpecialFields;
import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.DataField;
@@ -47,6 +48,7 @@ import org.apache.parquet.schema.PrimitiveType;
import org.apache.parquet.schema.Type;
import org.apache.parquet.schema.Types;
+import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
@@ -350,159 +352,233 @@ public class ParquetSchemaConverter {
/** Convert parquet {@link Type} to paimon {@link DataField} to. */
public static DataField convertToPaimonField(Type parquetType) {
- LogicalTypeAnnotation logicalType =
parquetType.getLogicalTypeAnnotation();
- DataType paimonDataType;
-
- if (parquetType.isPrimitive()) {
- switch (parquetType.asPrimitiveType().getPrimitiveTypeName()) {
- case BINARY:
- if (logicalType instanceof
LogicalTypeAnnotation.StringLogicalTypeAnnotation) {
- paimonDataType = DataTypes.STRING();
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.GeometryLogicalTypeAnnotation) {
- paimonDataType =
- DataTypes.GEOMETRY(
-
((LogicalTypeAnnotation.GeometryLogicalTypeAnnotation)
- logicalType)
- .getCrs());
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.GeographyLogicalTypeAnnotation) {
- LogicalTypeAnnotation.GeographyLogicalTypeAnnotation
geography =
-
(LogicalTypeAnnotation.GeographyLogicalTypeAnnotation) logicalType;
- EdgeAlgorithm algorithm =
- geography.getAlgorithm() == null
- ? null
- :
EdgeAlgorithm.valueOf(geography.getAlgorithm().name());
- paimonDataType =
DataTypes.GEOGRAPHY(geography.getCrs(), algorithm);
- } else {
- paimonDataType = DataTypes.BYTES();
- }
- break;
- case BOOLEAN:
- paimonDataType = DataTypes.BOOLEAN();
- break;
- case FLOAT:
- paimonDataType = DataTypes.FLOAT();
- break;
- case DOUBLE:
- paimonDataType = DataTypes.DOUBLE();
- break;
- case INT32:
- if (logicalType instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
- LogicalTypeAnnotation.DecimalLogicalTypeAnnotation
decimalType =
-
(LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) logicalType;
- paimonDataType =
- new DecimalType(decimalType.getPrecision(),
decimalType.getScale());
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.IntLogicalTypeAnnotation) {
- LogicalTypeAnnotation.IntLogicalTypeAnnotation intType
=
-
(LogicalTypeAnnotation.IntLogicalTypeAnnotation) logicalType;
- int bitWidth = intType.getBitWidth();
- if (bitWidth == 8) {
- paimonDataType = DataTypes.TINYINT();
- } else if (bitWidth == 16) {
- paimonDataType = DataTypes.SMALLINT();
+ return new FieldConverter(LayoutContext.empty()).convert(parquetType);
+ }
+
+ public static DataField convertToPaimonField(
+ Type parquetType, LayoutContext listLayout, String[] parentPath) {
+ return new FieldConverter(listLayout).convertAt(parquetType,
parentPath);
+ }
+
+ private static final class FieldConverter {
+
+ private final LayoutContext listLayout;
+ private final LinkedList<String> path = new LinkedList<>();
+
+ FieldConverter(LayoutContext listLayout) {
+ this.listLayout = listLayout == null ? LayoutContext.empty() :
listLayout;
+ }
+
+ /**
+ * Converts {@code parquetType} whose parent in the file schema sits
at {@code parentPath}.
+ * The converter instance is discarded after this call, so the seeded
path stack does not
+ * need to be unwound.
+ */
+ DataField convertAt(Type parquetType, String[] parentPath) {
+ for (String segment : parentPath) {
+ path.addLast(segment);
+ }
+ path.addLast(parquetType.getName());
+ return convert(parquetType);
+ }
+
+ DataField convert(Type parquetType) {
+ LogicalTypeAnnotation logicalType =
parquetType.getLogicalTypeAnnotation();
+ DataType paimonDataType;
+
+ if (parquetType.isPrimitive()) {
+ switch (parquetType.asPrimitiveType().getPrimitiveTypeName()) {
+ case BINARY:
+ if (logicalType
+ instanceof
LogicalTypeAnnotation.StringLogicalTypeAnnotation) {
+ paimonDataType = DataTypes.STRING();
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.GeometryLogicalTypeAnnotation) {
+ paimonDataType =
+ DataTypes.GEOMETRY(
+
((LogicalTypeAnnotation.GeometryLogicalTypeAnnotation)
+ logicalType)
+ .getCrs());
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.GeographyLogicalTypeAnnotation) {
+
LogicalTypeAnnotation.GeographyLogicalTypeAnnotation geography =
+
(LogicalTypeAnnotation.GeographyLogicalTypeAnnotation)
+ logicalType;
+ EdgeAlgorithm algorithm =
+ geography.getAlgorithm() == null
+ ? null
+ : EdgeAlgorithm.valueOf(
+
geography.getAlgorithm().name());
+ paimonDataType =
DataTypes.GEOGRAPHY(geography.getCrs(), algorithm);
+ } else {
+ paimonDataType = DataTypes.BYTES();
+ }
+ break;
+ case BOOLEAN:
+ paimonDataType = DataTypes.BOOLEAN();
+ break;
+ case FLOAT:
+ paimonDataType = DataTypes.FLOAT();
+ break;
+ case DOUBLE:
+ paimonDataType = DataTypes.DOUBLE();
+ break;
+ case INT32:
+ if (logicalType
+ instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.DecimalLogicalTypeAnnotation
decimalType =
+
(LogicalTypeAnnotation.DecimalLogicalTypeAnnotation)
+ logicalType;
+ paimonDataType =
+ new DecimalType(
+ decimalType.getPrecision(),
decimalType.getScale());
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.IntLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.IntLogicalTypeAnnotation
intType =
+
(LogicalTypeAnnotation.IntLogicalTypeAnnotation) logicalType;
+ int bitWidth = intType.getBitWidth();
+ if (bitWidth == 8) {
+ paimonDataType = DataTypes.TINYINT();
+ } else if (bitWidth == 16) {
+ paimonDataType = DataTypes.SMALLINT();
+ } else {
+ paimonDataType = DataTypes.INT();
+ }
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.DateLogicalTypeAnnotation) {
+ paimonDataType = DataTypes.DATE();
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.TimeLogicalTypeAnnotation) {
+ paimonDataType = DataTypes.TIME();
} else {
paimonDataType = DataTypes.INT();
}
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.DateLogicalTypeAnnotation) {
- paimonDataType = DataTypes.DATE();
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.TimeLogicalTypeAnnotation) {
- paimonDataType = DataTypes.TIME();
- } else {
- paimonDataType = DataTypes.INT();
- }
- break;
- case INT64:
- if (logicalType instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ break;
+ case INT64:
+ if (logicalType
+ instanceof
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) {
+ LogicalTypeAnnotation.DecimalLogicalTypeAnnotation
decimalType =
+
(LogicalTypeAnnotation.DecimalLogicalTypeAnnotation)
+ logicalType;
+ paimonDataType =
+ new DecimalType(
+ decimalType.getPrecision(),
decimalType.getScale());
+ } else if (logicalType
+ instanceof
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) {
+
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation timestampType =
+
(LogicalTypeAnnotation.TimestampLogicalTypeAnnotation)
+ logicalType;
+ int precision;
+ if (timestampType
+ .getUnit()
+
.equals(LogicalTypeAnnotation.TimeUnit.MILLIS)) {
+ precision = 3;
+ } else if (timestampType
+ .getUnit()
+
.equals(LogicalTypeAnnotation.TimeUnit.MICROS)) {
+ precision = 6;
+ } else {
+ precision = 9;
+ }
+ paimonDataType =
+ timestampType.isAdjustedToUTC()
+ ? new
LocalZonedTimestampType(precision)
+ : new TimestampType(precision);
+ } else {
+ paimonDataType = DataTypes.BIGINT();
+ }
+ break;
+ case INT96:
+ paimonDataType = new TimestampType(9);
+ break;
+ case FIXED_LEN_BYTE_ARRAY:
LogicalTypeAnnotation.DecimalLogicalTypeAnnotation
decimalType =
(LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) logicalType;
paimonDataType =
new DecimalType(decimalType.getPrecision(),
decimalType.getScale());
- } else if (logicalType
- instanceof
LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) {
- LogicalTypeAnnotation.TimestampLogicalTypeAnnotation
timestampType =
-
(LogicalTypeAnnotation.TimestampLogicalTypeAnnotation) logicalType;
- int precision;
- if
(timestampType.getUnit().equals(LogicalTypeAnnotation.TimeUnit.MILLIS)) {
- precision = 3;
- } else if (timestampType
- .getUnit()
-
.equals(LogicalTypeAnnotation.TimeUnit.MICROS)) {
- precision = 6;
- } else {
- precision = 9;
- }
- paimonDataType =
- timestampType.isAdjustedToUTC()
- ? new
LocalZonedTimestampType(precision)
- : new TimestampType(precision);
- } else {
- paimonDataType = DataTypes.BIGINT();
- }
- break;
- case INT96:
- paimonDataType = new TimestampType(9);
- break;
- case FIXED_LEN_BYTE_ARRAY:
- LogicalTypeAnnotation.DecimalLogicalTypeAnnotation
decimalType =
-
(LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) logicalType;
- paimonDataType =
- new DecimalType(decimalType.getPrecision(),
decimalType.getScale());
- break;
- default:
- throw new UnsupportedOperationException("Unsupported type:
" + parquetType);
+ break;
+ default:
+ throw new UnsupportedOperationException("Unsupported
type: " + parquetType);
+ }
+ if
(parquetType.getRepetition().equals(Type.Repetition.REQUIRED)) {
+ paimonDataType = paimonDataType.notNull();
+ }
+ return new DataField(
+ parquetType.getId().intValue(), parquetType.getName(),
paimonDataType);
+ } else {
+ GroupType groupType = parquetType.asGroupType();
+ if (ParquetListLayoutResolver.isList(groupType)) {
+ paimonDataType = convertList(groupType);
+ } else if (logicalType instanceof
LogicalTypeAnnotation.MapLogicalTypeAnnotation) {
+ paimonDataType = convertMap(groupType);
+ } else if
(ParquetListLayoutResolver.isLegacyNestedList(groupType)) {
+ paimonDataType = convertTwoLevelList(groupType);
+ } else {
+ paimonDataType = convertRow(groupType);
+ }
}
+
if (parquetType.getRepetition().equals(Type.Repetition.REQUIRED)) {
paimonDataType = paimonDataType.notNull();
}
+
return new DataField(
parquetType.getId().intValue(), parquetType.getName(),
paimonDataType);
- } else {
- GroupType groupType = parquetType.asGroupType();
- if (logicalType instanceof
LogicalTypeAnnotation.ListLogicalTypeAnnotation) {
- paimonDataType =
- new ArrayType(
-
convertToPaimonField(parquetListElementType(groupType)).type());
- } else if (logicalType instanceof
LogicalTypeAnnotation.MapLogicalTypeAnnotation) {
- Pair<Type, Type> keyValueType =
parquetMapKeyValueType(groupType);
- paimonDataType =
- new MapType(
- // Since parquet does not support nullable
key, when converting
- // back to Paimon, set as nullable by default.
-
convertToPaimonField(keyValueType.getLeft()).type().nullable(),
-
convertToPaimonField(keyValueType.getRight()).type());
- } else {
- paimonDataType =
- new RowType(
- groupType.getFields().stream()
-
.map(ParquetSchemaConverter::convertToPaimonField)
- .collect(Collectors.toList()));
- }
}
- if (parquetType.getRepetition().equals(Type.Repetition.REQUIRED)) {
- paimonDataType = paimonDataType.notNull();
+ private DataType convertList(GroupType groupType) {
+ return listLayout.isThreeLevelList(groupType, path)
+ ? convertThreeLevelList(groupType)
+ : convertTwoLevelList(groupType);
}
- return new DataField(parquetType.getId().intValue(),
parquetType.getName(), paimonDataType);
- }
+ private DataType convertThreeLevelList(GroupType groupType) {
+ GroupType repeated = groupType.getType(0).asGroupType();
+ DataType elementType = convertChild(repeated.getType(0),
repeated.getName()).type();
+ return new ArrayType(elementType);
+ }
- public static Type parquetListElementType(GroupType listType) {
- int level = listType.getType(0) instanceof GroupType ? 3 : 2;
- if (level == 3) {
- // Level 3 representation of list type.
- // List type should only have one middle group type, which is
repeated, and one element
- // type, which is optional.
- return listType.getType(0).asGroupType().getType(0);
- } else if (level == 2) {
- // Level 2 representation of list type
- return listType.getType(0);
- } else {
- throw new UnsupportedOperationException(
- "Parquet list type only have two level representation and
three level representation.");
+ /**
+ * Rules 1-4: the repeated node itself is the element. A REPEATED node
is never null, so the
+ * element type is not nullable.
+ */
+ private DataType convertTwoLevelList(GroupType groupType) {
+ return new
ArrayType(convertChild(groupType.getType(0)).type().notNull());
+ }
+
+ private DataType convertRow(GroupType groupType) {
+ return new RowType(
+ groupType.getFields().stream()
+ .map(this::convertChild)
+ .collect(Collectors.toList()));
+ }
+
+ private DataType convertMap(GroupType groupType) {
+ Pair<Type, Type> keyValueType = parquetMapKeyValueType(groupType);
+ String keyValueName = groupType.getType(0).getName();
+ // Since parquet does not support nullable key, when converting
back to Paimon, set as
+ // nullable by default.
+ return new MapType(
+ convertChild(keyValueType.getLeft(),
keyValueName).type().nullable(),
+ convertChild(keyValueType.getRight(),
keyValueName).type());
+ }
+
+ private DataField convertChild(Type child) {
+ path.addLast(child.getName());
+ try {
+ return convert(child);
+ } finally {
+ path.removeLast();
+ }
+ }
+
+ private DataField convertChild(Type child, String parentName) {
+ path.addLast(parentName);
+ try {
+ return convertChild(child);
+ } finally {
+ path.removeLast();
+ }
}
}
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingReadPlanFactory.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingReadPlanFactory.java
index 0e902c56e3..3d33f6c7d3 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingReadPlanFactory.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingReadPlanFactory.java
@@ -56,7 +56,7 @@ import java.util.Map;
import static org.apache.paimon.data.variant.Variant.METADATA;
import static org.apache.paimon.data.variant.Variant.VALUE;
-import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetListElementType;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.resolveElementType;
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetMapKeyValueType;
/**
@@ -194,7 +194,7 @@ public class VariantShreddingReadPlanFactory implements
ShreddingReadPlanFactory
arrayType.isNullable(),
buildPhysicalType(
arrayType.getElementType(),
- parquetListElementType(fileType.asGroupType()),
+ resolveElementType(fileType.asGroupType()),
caseSensitive));
case VECTOR:
VectorType vectorType = (VectorType) logicalType;
@@ -203,7 +203,7 @@ public class VariantShreddingReadPlanFactory implements
ShreddingReadPlanFactory
vectorType.getLength(),
buildPhysicalType(
vectorType.getElementType(),
- parquetListElementType(fileType.asGroupType()),
+ resolveElementType(fileType.asGroupType()),
caseSensitive));
case MAP:
MapType mapType = (MapType) logicalType;
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingTypePruner.java
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingTypePruner.java
index f9e5075342..860c5df9d9 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingTypePruner.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/VariantShreddingTypePruner.java
@@ -25,7 +25,6 @@ import org.apache.paimon.types.DataField;
import org.apache.paimon.types.RowType;
import org.apache.parquet.schema.GroupType;
-import org.apache.parquet.schema.LogicalTypeAnnotation;
import org.apache.parquet.schema.Type;
import javax.annotation.Nullable;
@@ -38,7 +37,8 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
-import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetListElementType;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.isCanonicalList;
+import static
org.apache.paimon.format.parquet.ParquetListLayoutResolver.resolveElementType;
import static org.apache.paimon.utils.Preconditions.checkArgument;
/**
@@ -53,9 +53,6 @@ import static
org.apache.paimon.utils.Preconditions.checkArgument;
* objectSchemaMap}.
*/
public class VariantShreddingTypePruner {
- private static final String LIST_WRAPPER_NAME = "list";
- private static final String LIST_ELEMENT_NAME = "element";
-
@Nullable private final PathNode root;
VariantShreddingTypePruner(RowType variantRowType) {
@@ -197,7 +194,7 @@ public class VariantShreddingTypePruner {
newFields.add(group.getType(PaimonShreddingUtils.VARIANT_VALUE_FIELD_NAME));
}
- Type elementType = parquetListElementType(listGroup);
+ Type elementType = resolveElementType(listGroup);
Type clippedElement = clipShreddingRow(elementType.asGroupType(),
node.arrayElement);
GroupType repeated = listGroup.getType(0).asGroupType();
GroupType clippedRepeated =
@@ -206,48 +203,6 @@ public class VariantShreddingTypePruner {
return group.withNewFields(newFields);
}
- /**
- * Returns true if the given group follows the canonical three-level
Parquet list layout.
- *
- * <p>The canonical layout is described in the Parquet spec: <a
- *
href="https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#lists">LogicalTypes#Lists</a>
- */
- private static boolean isCanonicalList(Type type) {
- if (type.isPrimitive()) {
- return false;
- }
-
- GroupType listGroup = type.asGroupType();
- // 1. Must be a LIST logical type.
- if (!(listGroup.getLogicalTypeAnnotation()
- instanceof LogicalTypeAnnotation.ListLogicalTypeAnnotation)) {
- return false;
- }
-
- // 2. LIST group must have exactly one child named "list".
- if (listGroup.getFieldCount() != 1) {
- return false;
- }
- Type middle = listGroup.getType(0);
- if (!LIST_WRAPPER_NAME.equals(middle.getName())) {
- return false;
- }
-
- // 3. The child must be a repeated group.
- if (middle.isPrimitive() || middle.getRepetition() !=
Type.Repetition.REPEATED) {
- return false;
- }
- GroupType repeatedWrapper = middle.asGroupType();
-
- // 4. The repeated wrapper must contain exactly one child named
"element".
- if (repeatedWrapper.getFieldCount() != 1) {
- return false;
- }
-
- Type element = repeatedWrapper.getType(0);
- return LIST_ELEMENT_NAME.equals(element.getName());
- }
-
/** Returns true if the given group is a plain struct (not a Parquet list
or map). */
private static boolean isObjectGroup(Type type) {
if (type.isPrimitive()) {
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 48f98b785b..d2ea25e455 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
@@ -18,6 +18,7 @@
package org.apache.paimon.format.parquet.reader;
+import org.apache.paimon.format.parquet.ParquetListLayoutResolver;
import org.apache.paimon.format.parquet.type.ParquetField;
import org.apache.paimon.format.parquet.type.ParquetGroupField;
import org.apache.paimon.format.parquet.type.ParquetPrimitiveField;
@@ -30,7 +31,6 @@ import org.apache.paimon.types.MultisetType;
import org.apache.paimon.types.RowType;
import org.apache.paimon.types.VectorType;
import org.apache.paimon.utils.Pair;
-import org.apache.paimon.utils.StringUtils;
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList;
@@ -43,38 +43,43 @@ import org.apache.parquet.schema.MessageType;
import org.apache.parquet.schema.Type;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.convertToPaimonField;
-import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetListElementType;
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.parquetMapKeyValueType;
-import static org.apache.parquet.schema.Type.Repetition.REPEATED;
import static org.apache.parquet.schema.Type.Repetition.REQUIRED;
/** Util for generating parquet readers. */
public class ParquetReaderUtil {
public static List<ParquetField> buildFieldsList(
- DataField[] readFields, MessageColumnIO columnIO, MessageType
requestedFileSchema) {
+ DataField[] readFields,
+ MessageColumnIO columnIO,
+ MessageType requestedSchema,
+ ParquetListLayoutResolver.LayoutContext listLayout) {
List<ParquetField> list = new ArrayList<>();
for (int i = 0; i < readFields.length; i++) {
list.add(
constructField(
readFields[i],
lookupColumnByName(columnIO, readFields[i].name()),
- requestedFileSchema.getType(i)));
+ requestedSchema.getType(i),
+ listLayout));
}
return list;
}
private static ParquetField constructField(
- DataField dataField, ColumnIO columnIO, Type parquetType) {
+ DataField dataField,
+ ColumnIO columnIO,
+ Type parquetType,
+ ParquetListLayoutResolver.LayoutContext listLayout) {
boolean required = columnIO.getType().getRepetition() == REQUIRED;
int repetitionLevel = columnIO.getRepetitionLevel();
int definitionLevel = columnIO.getDefinitionLevel();
DataType type = dataField.type();
- String fieldName = dataField.name();
if (type instanceof RowType) {
GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO;
RowType rowType = (RowType) type;
@@ -88,17 +93,23 @@ public class ParquetReaderUtil {
constructField(
children.get(i),
lookupColumnByName(groupColumnIO, childName),
- getTypeIgnoreCase(parquetType.asGroupType(),
childName)));
+ getTypeIgnoreCase(parquetType.asGroupType(),
childName),
+ listLayout));
}
GroupType parquetGroup = parquetType.asGroupType();
for (int i = children.size(); i < parquetGroup.getFieldCount();
i++) {
Type extraType = parquetGroup.getType(i);
- DataField extraField =
convertToPaimonField(addFallbackFieldIds(extraType));
+ DataField extraField =
+ convertToPaimonField(
+ addFallbackFieldIds(extraType),
+ listLayout,
+ groupColumnIO.getFieldPath());
fieldsBuilder.add(
constructField(
extraField,
lookupColumnByName(groupColumnIO,
extraType.getName()),
- extraType));
+ extraType,
+ listLayout));
}
return new ParquetGroupField(
@@ -119,12 +130,14 @@ public class ParquetReaderUtil {
constructField(
new DataField(0, "", mapType.getKeyType()),
keyValueColumnIO.getChild(0),
- keyValueType.getKey());
+ keyValueType.getKey(),
+ listLayout);
ParquetField valueField =
constructField(
new DataField(0, "", mapType.getValueType()),
keyValueColumnIO.getChild(1),
- keyValueType.getValue());
+ keyValueType.getValue(),
+ listLayout);
return new ParquetGroupField(
type,
repetitionLevel,
@@ -143,12 +156,14 @@ public class ParquetReaderUtil {
constructField(
new DataField(0, "",
multisetType.getElementType()),
keyValueColumnIO.getChild(0),
- keyValueType.getKey());
+ keyValueType.getKey(),
+ listLayout);
ParquetField valueField =
constructField(
new DataField(0, "", new IntType()),
keyValueColumnIO.getChild(1),
- keyValueType.getValue());
+ keyValueType.getValue(),
+ listLayout);
return new ParquetGroupField(
type,
repetitionLevel,
@@ -163,34 +178,27 @@ public class ParquetReaderUtil {
type instanceof ArrayType
? ((ArrayType) type).getElementType()
: ((VectorType) type).getElementType();
- ColumnIO elementTypeColumnIO;
- if (columnIO instanceof GroupColumnIO) {
- GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO;
- if (!StringUtils.isNullOrWhitespaceOnly(fieldName)) {
- // Column lookup is case-insensitive; the wrapper-group
names can
- // therefore differ in case from the requested field name.
- while
(!groupColumnIO.getName().equalsIgnoreCase(fieldName)) {
- groupColumnIO = (GroupColumnIO)
groupColumnIO.getChild(0);
- }
- elementTypeColumnIO = groupColumnIO;
- } else {
- if (elementType instanceof RowType) {
- elementTypeColumnIO = groupColumnIO;
- } else {
- elementTypeColumnIO = groupColumnIO.getChild(0);
- }
- }
- } else if (columnIO instanceof PrimitiveColumnIO) {
- elementTypeColumnIO = columnIO;
- } else {
- throw new RuntimeException(String.format("Unknown ColumnIO,
%s", columnIO));
- }
+ GroupColumnIO groupColumnIO = (GroupColumnIO) columnIO;
+ GroupType requestedGroup = parquetType.asGroupType();
+
+ boolean threeLevel =
+ listLayout.isThreeLevelList(
+ requestedGroup,
Arrays.asList(groupColumnIO.getFieldPath()));
+ Type requestedElementType =
+ threeLevel
+ ?
requestedGroup.getType(0).asGroupType().getType(0)
+ : requestedGroup.getType(0);
+
+ ColumnIO middleColumnIO = groupColumnIO.getChild(0);
+ ColumnIO elementColumnIO =
+ threeLevel ? ((GroupColumnIO) middleColumnIO).getChild(0)
: middleColumnIO;
ParquetField field =
constructField(
new DataField(0, "", elementType),
- getArrayElementColumn(elementTypeColumnIO),
- parquetListElementType(parquetType.asGroupType()));
+ elementColumnIO,
+ requestedElementType,
+ listLayout);
if (repetitionLevel == field.getRepetitionLevel()) {
repetitionLevel = columnIO.getParent().getRepetitionLevel();
}
@@ -270,32 +278,4 @@ public class ParquetReaderUtil {
}
return groupColumnIO;
}
-
- public static ColumnIO getArrayElementColumn(ColumnIO columnIO) {
- while (columnIO instanceof GroupColumnIO &&
!columnIO.getType().isRepetition(REPEATED)) {
- columnIO = ((GroupColumnIO) columnIO).getChild(0);
- }
-
- /* Compatible with array has a standard 3-level structure:
- * optional group my_list (LIST) {
- * repeated group element {
- * required binary str (UTF8);
- * };
- * }
- */
- if (columnIO instanceof GroupColumnIO
- && columnIO.getType().getLogicalTypeAnnotation() == null
- && ((GroupColumnIO) columnIO).getChildrenCount() == 1
- && !columnIO.getName().equals("array")
- && !columnIO.getName().equals(columnIO.getParent().getName() +
"_tuple")) {
- return ((GroupColumnIO) columnIO).getChild(0);
- }
-
- /* Compatible with array for 2-level arrays where a repeated field is
not a group:
- * optional group my_list (LIST) {
- * repeated int32 element;
- * }
- */
- return columnIO;
- }
}
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetLegacyListReadTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetLegacyListReadTest.java
new file mode 100644
index 0000000000..af3a309bd8
--- /dev/null
+++
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetLegacyListReadTest.java
@@ -0,0 +1,595 @@
+/*
+ * 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.BinaryString;
+import org.apache.paimon.data.InternalArray;
+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.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.VarCharType;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileWriter;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.hadoop.util.HadoopOutputFile;
+import org.apache.parquet.io.api.Binary;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.Type;
+import org.apache.parquet.schema.Types;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * End-to-end tests for reading Parquet files that use legacy list encodings,
as described by the
+ * backward-compatibility rules in the Parquet spec.
+ */
+public class ParquetLegacyListReadTest {
+
+ private static final int ROW_COUNT = 10;
+
+ @TempDir public File folder;
+
+ /** Backward-compatibility Rule 1: a repeated primitive field is itself
the element type. */
+ @Test
+ public void testReadRepeatedPrimitiveElementList() throws Exception {
+ // optional group my_list (LIST) { repeated int32 element; }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .repeated(INT32)
+ .named("element")
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.append("element", i * 10 + j);
+ }
+ writer.write(row);
+ }
+ }
+
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(new IntType())))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ assertThat(array.getInt(j)).isEqualTo(index * 10 +
j);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 2: a repeated group with multiple fields is
itself the element
+ * type.
+ */
+ @Test
+ public void testReadRepeatedStructElementList() throws Exception {
+ // optional group my_list (LIST) { repeated group element { optional
int32 x; optional int32
+ // y; } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("x")
+ .optional(INT32)
+ .named("y")
+ .named("element"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.addGroup("element").append("x", i + j).append("y", i
* j);
+ }
+ writer.write(row);
+ }
+ }
+
+ RowType elementType =
+ new RowType(
+ Arrays.asList(
+ new DataField(0, "x", new IntType()),
+ new DataField(1, "y", new IntType())));
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(elementType)))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ InternalRow element = array.getRow(j, 2);
+ assertThat(element.getInt(0)).isEqualTo(index + j);
+ assertThat(element.getInt(1)).isEqualTo(index * j);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 2 with projection: a repeated group with
multiple fields is the
+ * element type, and projecting it to a single field must not reclassify
the clipped element
+ * group as a Rule 5 wrapper.
+ */
+ @Test
+ public void testReadStructElementWithProjection() throws Exception {
+ // optional group my_list (LIST) { repeated group element { optional
int32 x; optional int32
+ // y; } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("x")
+ .optional(INT32)
+ .named("y")
+ .named("element"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.addGroup("element").append("x", i + j).append("y", i
* j);
+ }
+ writer.write(row);
+ }
+ }
+
+ RowType elementType =
+ new RowType(Collections.singletonList(new DataField(0, "x",
new IntType())));
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(elementType)))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ assertThat(array.getRow(j,
1).getInt(0)).isEqualTo(index + j);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 3: a repeated group whose single child is
also repeated is the
+ * element type, and the repeated child is a nested list. Reading {@code
[[8, 9], ...]} must
+ * return every inner value, not just the first one.
+ */
+ @Test
+ public void testReadNestedLegacyList() throws Exception {
+ // optional group my_list (LIST) { repeated group element { repeated
int32 array; } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .repeated(INT32)
+ .named("array")
+ .named("array"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ Group element = array.addGroup("array");
+ element.append("array", i * 10 + j);
+ element.append("array", i * 10 + j + 100);
+ }
+ writer.write(row);
+ }
+ }
+
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0,
+ "my_list",
+ new ArrayType(new
ArrayType(new IntType()))))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ InternalArray nested = array.getArray(j);
+ assertThat(nested.size()).isEqualTo(2);
+ assertThat(nested.getInt(0)).isEqualTo(index * 10
+ j);
+ assertThat(nested.getInt(1)).isEqualTo(index * 10
+ j + 100);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 4: a repeated group named {@code "array"}
with one field is the
+ * element type.
+ */
+ @Test
+ public void testReadArrayNamedGroupElementList() throws Exception {
+ // optional group my_list (LIST) { repeated group array { optional
int32 foo; } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("foo")
+ .named("array"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.addGroup("array").append("foo", i * 10 + j);
+ }
+ writer.write(row);
+ }
+ }
+
+ RowType elementType =
+ new RowType(Collections.singletonList(new DataField(0, "foo",
new IntType())));
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(elementType)))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ assertThat(array.getRow(j,
1).getInt(0)).isEqualTo(index * 10 + j);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 4: a repeated group named {@code
"<list>_tuple"} with one field
+ * is the element type.
+ */
+ @Test
+ public void testReadListTupleNamedGroupElementList() throws Exception {
+ // optional group my_list (LIST) { repeated group my_list_tuple {
required binary str
+ // (STRING); } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .required(BINARY)
+
.as(LogicalTypeAnnotation.stringType())
+ .named("str")
+ .named("my_list_tuple"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.addGroup("my_list_tuple")
+ .append("str", Binary.fromString("v" + (i * 10 +
j)));
+ }
+ writer.write(row);
+ }
+ }
+
+ RowType elementType =
+ new RowType(
+ Collections.singletonList(
+ new DataField(0, "str", new
VarCharType(VarCharType.MAX_LENGTH))));
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(elementType)))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ assertThat(array.getRow(j, 1).getString(0))
+ .isEqualTo(BinaryString.fromString("v" +
(index * 10 + j)));
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * Backward-compatibility Rule 5: a repeated group with a single
non-repeated child is a
+ * wrapper, so the child is the element (Hive bag style).
+ */
+ @Test
+ public void testReadElementWrappedList() throws Exception {
+ // optional group my_list (LIST) { repeated group element { optional
int32 num; } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("num")
+ .named("element"))
+ .named("my_list"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ Group array = row.addGroup("my_list");
+ int size = i % 3 + 1;
+ for (int j = 0; j < size; j++) {
+ array.addGroup("element").append("num", i * 10 + j);
+ }
+ writer.write(row);
+ }
+ }
+
+ AtomicInteger i = new AtomicInteger();
+ try (RecordReader<InternalRow> reader =
+ createReader(
+ path,
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "my_list", new
ArrayType(new IntType())))))) {
+ reader.forEachRemaining(
+ row -> {
+ int index = i.getAndIncrement();
+ InternalArray array = row.getArray(0);
+ int size = index % 3 + 1;
+ assertThat(array.size()).isEqualTo(size);
+ for (int j = 0; j < size; j++) {
+ assertThat(array.getInt(j)).isEqualTo(index * 10 +
j);
+ }
+ });
+ }
+ assertThat(i.get()).isEqualTo(ROW_COUNT);
+ }
+
+ /**
+ * A plain struct group is neither LIST-annotated nor a legacy nested
list, so reading it as an
+ * ARRAY must fail fast in schema clipping instead of silently producing
undefined data.
+ */
+ @Test
+ public void testReadNonListGroupAsArrayFails() throws Exception {
+ // optional group arr { optional int32 x; optional int32 y; }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .addField(
+
Types.buildGroup(Type.Repetition.OPTIONAL)
+ .optional(INT32)
+ .named("x")
+ .optional(INT32)
+ .named("y")
+ .named("tuple"))
+ .named("arr"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ Group row = factory.newGroup();
+ row.addGroup("arr").addGroup("tuple").append("x",
i).append("y", i * 2);
+ writer.write(row);
+ }
+ }
+
+ RowType readType =
+ new RowType(
+ Collections.singletonList(
+ new DataField(0, "arr", new ArrayType(new
IntType()))));
+ assertThatThrownBy(() -> createReader(path, readType))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Cannot read Parquet group 'arr' as an
ARRAY");
+ }
+
+ @Test
+ public void testReadMissingNestedFieldWithListFallback() throws Exception {
+ // optional group s { optional group a (LIST) { repeated group element
{ optional int32 x;
+ // optional int32 y; } } }
+ MessageType schema =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .addField(
+
Types.buildGroup(Type.Repetition.OPTIONAL)
+
.as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+
.optional(INT32)
+ .named("x")
+
.optional(INT32)
+ .named("y")
+
.named("element"))
+ .named("a"))
+ .named("s"));
+ Path path = new Path(folder.getPath(), UUID.randomUUID().toString());
+ try (ParquetWriter<Group> writer = createWriter(path, schema)) {
+ SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+ // row0: a null parent struct
+ writer.write(factory.newGroup());
+ // row1: a present struct with an empty list
+ Group emptyListRow = factory.newGroup();
+ emptyListRow.addGroup("s").addGroup("a");
+ writer.write(emptyListRow);
+ // row2: a present struct with one element
+ Group row = factory.newGroup();
+ row.addGroup("s").addGroup("a").addGroup("element").append("x",
1).append("y", 2);
+ writer.write(row);
+ }
+
+ RowType readType =
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0,
+ "s",
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 1, "newField",
new IntType()))))));
+
+ List<Boolean> sNulls = new ArrayList<>();
+ List<Boolean> newFieldNulls = new ArrayList<>();
+ try (RecordReader<InternalRow> reader = createReader(path, readType)) {
+ reader.forEachRemaining(
+ row -> {
+ sNulls.add(row.isNullAt(0));
+ newFieldNulls.add(row.isNullAt(0) || row.getRow(0,
1).isNullAt(0));
+ });
+ }
+ assertThat(sNulls).containsExactly(true, false, false);
+ assertThat(newFieldNulls).containsExactly(true, true, true);
+ }
+
+ private ParquetWriter<Group> createWriter(Path path, MessageType schema)
throws IOException {
+ Configuration conf = new Configuration();
+ return ExampleParquetWriter.builder(
+ HadoopOutputFile.fromPath(
+ new
org.apache.hadoop.fs.Path(path.toString()), conf))
+ .withWriteMode(ParquetFileWriter.Mode.OVERWRITE)
+ .withConf(conf)
+ .withType(schema)
+ .build();
+ }
+
+ private RecordReader<InternalRow> createReader(Path path, RowType
readType) throws IOException {
+ ParquetReaderFactory factory =
+ new ParquetReaderFactory(new Options(), readType, 1024, null);
+ LocalFileIO fileIO = new LocalFileIO();
+ return factory.createReader(
+ new FormatReaderContext(fileIO, path,
fileIO.getFileSize(path), null, null));
+ }
+}
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetListLayoutResolverTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetListLayoutResolverTest.java
new file mode 100644
index 0000000000..e190cda801
--- /dev/null
+++
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetListLayoutResolverTest.java
@@ -0,0 +1,191 @@
+/*
+ * 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.parquet.schema.GroupType;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.Type;
+import org.apache.parquet.schema.Types;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ParquetListLayoutResolver}.
+ *
+ * <p>All list layout decisions should be made through {@link
ParquetListLayoutResolver} so that
+ * schema inference, requested-schema clipping and reader construction share a
single
+ * interpretation; the layouts below follow the backward-compatibility rules
in the Parquet spec.
+ */
+public class ParquetListLayoutResolverTest {
+
+ @Test
+ public void testResolveThreeLevelElementType() {
+ // Rule 5: canonical three-level list (list -> element) with a
primitive element.
+ GroupType threeLevelPrimitiveList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("element")
+ .named("list"))
+ .named("my_list");
+ Type threeLevelPrimitiveElement =
+
ParquetListLayoutResolver.resolveElementType(threeLevelPrimitiveList);
+ assertThat(threeLevelPrimitiveElement.isPrimitive()).isEqualTo(true);
+ assertThat(threeLevelPrimitiveElement.getName()).isEqualTo("element");
+
+ // Rule 5: canonical three-level list (list -> element) with a group
element.
+ GroupType threeLevelGroupElement =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .optional(INT32)
+ .named("x")
+ .optional(INT32)
+ .named("y")
+ .named("element");
+ GroupType threeLevelGroupList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(threeLevelGroupElement)
+ .named("list"))
+ .named("my_list");
+ Type threeLevelGroupElementType =
+
ParquetListLayoutResolver.resolveElementType(threeLevelGroupList);
+ assertThat(threeLevelGroupElementType.isPrimitive()).isEqualTo(false);
+ assertThat(threeLevelGroupElementType.getName()).isEqualTo("element");
+
assertThat(threeLevelGroupElementType.asGroupType().getFieldCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void testResolveTwoLevelElementType() {
+ // Rule 1: a repeated primitive field is itself the element type.
+ GroupType twoLevelPrimitiveList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .repeated(INT32)
+ .named("element")
+ .named("my_list");
+ Type twoLevelPrimitiveElement =
+
ParquetListLayoutResolver.resolveElementType(twoLevelPrimitiveList);
+ assertThat(twoLevelPrimitiveElement.isPrimitive()).isEqualTo(true);
+ assertThat(twoLevelPrimitiveElement.getName()).isEqualTo("element");
+
+ // Rule 2: a repeated group with multiple fields is itself the element
type.
+ GroupType twoLevelGroupElement =
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("x")
+ .optional(INT32)
+ .named("y")
+ .named("element");
+ GroupType twoLevelGroupList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(twoLevelGroupElement)
+ .named("my_list");
+ Type twoLevelGroupElementType =
+
ParquetListLayoutResolver.resolveElementType(twoLevelGroupList);
+ assertThat(twoLevelGroupElementType.isPrimitive()).isEqualTo(false);
+ assertThat(twoLevelGroupElementType.getName()).isEqualTo("element");
+
assertThat(twoLevelGroupElementType.asGroupType().getFieldCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void testResolveLegacyNestedElementType() {
+ // Rule 3: a repeated group with a single repeated field is the
element type.
+ GroupType nestedRepeatedWrapper =
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .repeated(INT32)
+ .named("array")
+ .named("array");
+ GroupType nestedRepeatedList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(nestedRepeatedWrapper)
+ .named("my_list");
+ Type nestedRepeatedElement =
+
ParquetListLayoutResolver.resolveElementType(nestedRepeatedList);
+ assertThat(nestedRepeatedElement.isPrimitive()).isEqualTo(false);
+ assertThat(nestedRepeatedElement.getName()).isEqualTo("array");
+
assertThat(nestedRepeatedElement.asGroupType().getFieldCount()).isEqualTo(1);
+
assertThat(nestedRepeatedElement.asGroupType().getType(0).getName()).isEqualTo("array");
+ }
+
+ @Test
+ public void testResolveLegacyNamedElementType() {
+ // Rule 4: a repeated group named "array" with one field is the
element type.
+ GroupType arrayWrapper =
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("foo")
+ .named("array");
+ GroupType arrayWrapperList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(arrayWrapper)
+ .named("my_list");
+ Type arrayWrapperElement =
ParquetListLayoutResolver.resolveElementType(arrayWrapperList);
+ assertThat(arrayWrapperElement.isPrimitive()).isEqualTo(false);
+ assertThat(arrayWrapperElement.getName()).isEqualTo("array");
+
+ // Rule 4: a repeated group named "<list>_tuple" with one field is the
element type.
+ GroupType tupleWrapper =
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .required(BINARY)
+ .as(LogicalTypeAnnotation.stringType())
+ .named("str")
+ .named("my_list_tuple");
+ GroupType tupleWrapperList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(tupleWrapper)
+ .named("my_list");
+ Type tupleWrapperElement =
ParquetListLayoutResolver.resolveElementType(tupleWrapperList);
+ assertThat(tupleWrapperElement.isPrimitive()).isEqualTo(false);
+ assertThat(tupleWrapperElement.getName()).isEqualTo("my_list_tuple");
+
assertThat(tupleWrapperElement.asGroupType().getFieldCount()).isEqualTo(1);
+
assertThat(tupleWrapperElement.asGroupType().getType(0).getName()).isEqualTo("str");
+ }
+
+ @Test
+ public void testResolveLegacyBagElementType() {
+ // Rule 5: a repeated group with a single non-repeated field that is
neither "array" nor
+ // "<list>_tuple" unwraps to the single child (e.g. Hive's
bag/array_element encoding).
+ GroupType bagWrapper =
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .optional(INT32)
+ .named("array_element")
+ .named("bag");
+ GroupType bagWrapperList =
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(bagWrapper)
+ .named("my_list");
+
assertThat(ParquetListLayoutResolver.isThreeLevelList(bagWrapperList)).isTrue();
+
assertThat(ParquetListLayoutResolver.isCanonicalList(bagWrapperList)).isFalse();
+ Type bagWrapperElement =
ParquetListLayoutResolver.resolveElementType(bagWrapperList);
+ assertThat(bagWrapperElement.isPrimitive()).isEqualTo(true);
+ assertThat(bagWrapperElement.getName()).isEqualTo("array_element");
+ }
+}
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetSchemaConverterTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetSchemaConverterTest.java
index f40550831d..e93cf01b46 100644
---
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetSchemaConverterTest.java
+++
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetSchemaConverterTest.java
@@ -39,6 +39,7 @@ import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.convertToP
import static
org.apache.paimon.format.parquet.ParquetSchemaConverter.convertToParquetMessageType;
import static org.apache.paimon.types.DataTypesTest.assertThat;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
/** Test for {@link ParquetSchemaConverter}. */
@@ -206,4 +207,247 @@ public class ParquetSchemaConverterTest {
assertThat(expected).isEqualTo(convertToPaimonRowType(messageType));
}
+
+ /**
+ * Backward-compatibility Rules 1, 2 and 4: two-level lists (a repeated
primitive, a repeated
+ * struct, and a legacy {@code array} wrapper) infer a non-nullable
element, because a {@code
+ * REPEATED} node is never null. This matches parquet-cpp's {@code
SchemaManifest} contract and
+ * keeps the inferred schema symmetric with what Paimon's own writer
produces.
+ *
+ * <p>Rule 5 (three-level wrapper) is the contrast case: the wrapper
child's own nullability is
+ * preserved, so an {@code OPTIONAL} element stays nullable.
+ */
+ @Test
+ public void testInferTwoLevelListElementNotNull() {
+ // Rule 1: optional group my_list (LIST) { repeated int32 element; }
+ MessageType rule1 =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+ Types.primitive(INT32,
Type.Repetition.REPEATED)
+ .named("element")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new
ArrayType(DataTypes.INT().notNull())))))
+ .isEqualTo(convertToPaimonRowType(rule1));
+
+ // Rule 2: optional group my_list (LIST) { repeated group element {
optional int32 x;
+ // optional int32 y; } }
+ MessageType rule2 =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.OPTIONAL)
+ .named("x")
+ .withId(2))
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.OPTIONAL)
+ .named("y")
+ .withId(3))
+ .named("element")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ RowType rule2Element =
+ new RowType(
+ Arrays.asList(
+ new DataField(2, "x", DataTypes.INT()),
+ new DataField(3, "y", DataTypes.INT())));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new
ArrayType(rule2Element.notNull())))))
+ .isEqualTo(convertToPaimonRowType(rule2));
+
+ // Rule 4: optional group my_list (LIST) { repeated group array {
optional int32 foo; } }
+ MessageType rule4 =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.OPTIONAL)
+ .named("foo")
+ .withId(2))
+ .named("array")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ RowType rule4Element = new RowType(Arrays.asList(new DataField(2,
"foo", DataTypes.INT())));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new
ArrayType(rule4Element.notNull())))))
+ .isEqualTo(convertToPaimonRowType(rule4));
+
+ // Rule 5 (contrast): optional group my_list (LIST) { repeated group
bag { optional int32
+ // array_element; } } keeps the OPTIONAL element nullable.
+ MessageType rule5 =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.OPTIONAL)
+
.named("array_element")
+ .withId(2))
+ .named("bag")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0, "my_list", new
ArrayType(DataTypes.INT())))))
+ .isEqualTo(convertToPaimonRowType(rule5));
+ }
+
+ /**
+ * Backward-compatibility Rule 3: an annotated list whose element is a
nested legacy list infers
+ * {@code ARRAY<ARRAY<INT NOT NULL> NOT NULL>}.
+ */
+ @Test
+ public void testInferNestedLegacyList() {
+ // Rule 3: optional group my_list (LIST) { repeated group element {
repeated int32 array; }
+ // }
+ MessageType annotated =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.REPEATED)
+ .named("array")
+ .withId(2))
+ .named("element")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new ArrayType(
+ new
ArrayType(DataTypes.INT().notNull())
+ .notNull())))))
+ .isEqualTo(convertToPaimonRowType(annotated));
+
+ // Without the annotation: repeated group my_list { repeated group
array { optional int32
+ // x; } } infers ARRAY<ROW<x INT> NOT NULL>.
+ MessageType unannotated =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+ Types.primitive(
+ INT32,
+
Type.Repetition.OPTIONAL)
+ .named("x")
+ .withId(2))
+ .named("array")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ RowType unannotatedElement =
+ new RowType(Arrays.asList(new DataField(2, "x",
DataTypes.INT())));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new
ArrayType(unannotatedElement.notNull())))))
+ .isEqualTo(convertToPaimonRowType(unannotated));
+ }
+
+ @Test
+ public void testInferArrayOfMapsFromOldAvroList() {
+ MessageType oldAvroListOfMaps =
+ new MessageType(
+ "origin-parquet",
+ Types.buildGroup(Type.Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.listType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+
.as(LogicalTypeAnnotation.mapType())
+ .addField(
+
Types.buildGroup(Type.Repetition.REPEATED)
+ .addField(
+
Types.primitive(
+
BINARY,
+
Type
+
.Repetition
+
.REQUIRED)
+
.as(
+
LogicalTypeAnnotation
+
.stringType())
+
.named("key")
+
.withId(3))
+ .addField(
+
Types.primitive(
+
INT32,
+
Type
+
.Repetition
+
.OPTIONAL)
+
.named("value")
+
.withId(4))
+
.named("key_value")
+ .withId(2))
+ .named("array")
+ .withId(1))
+ .named("my_list")
+ .withId(0));
+ assertThat(
+ new RowType(
+ Arrays.asList(
+ new DataField(
+ 0,
+ "my_list",
+ new ArrayType(
+ new MapType(
+
DataTypes.STRING(),
+
DataTypes.INT())
+ .notNull())))))
+ .isEqualTo(convertToPaimonRowType(oldAvroListOfMaps));
+ }
}