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 b59f966438 [format] Fix reads and predicate pushdown when Parquet 
files differ from the declared schema (#8995)
b59f966438 is described below

commit b59f9664387b2c636bcfca0eadcfa16631dbd8a0
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Mon Aug 3 18:03:05 2026 +0800

    [format] Fix reads and predicate pushdown when Parquet files differ from 
the declared schema (#8995)
---
 .../paimon/table/format/FormatReadBuilder.java     |   9 +-
 .../format/parquet/ParquetSchemaConverter.java     |  11 +
 .../reader/ParquetVectorUpdaterFactory.java        | 132 +++++-
 .../parquet/filter2/predicate/ParquetFilters.java  | 272 ++++++++++--
 .../format/parquet/ParquetTypeWideningTest.java    | 479 +++++++++++++++++++++
 5 files changed, 859 insertions(+), 44 deletions(-)

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 30a567b436..d05f0ebc12 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
@@ -238,7 +238,14 @@ public class FormatReadBuilder implements ReadBuilder {
                     formatReaderContext.filePath());
         } catch (Exception e) {
             FileUtils.checkExists(formatReaderContext.fileIO(), 
formatReaderContext.filePath());
-            throw e;
+            // A split spans many files that a Format Table's writers may have 
written differently.
+            // Naming the one that failed is the only way to tell them apart 
from the outside.
+            throw new IOException(
+                    "Failed to read file "
+                            + formatReaderContext.filePath()
+                            + " of table "
+                            + table.fullName(),
+                    e);
         }
     }
 
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 3ce514cb75..12da716ef4 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
@@ -59,6 +59,17 @@ public class ParquetSchemaConverter {
     public static final String MAP_VALUE_NAME = "value";
     public static final String LIST_ELEMENT_NAME = "element";
 
+    /**
+     * Whether {@code type} is an unsigned integer. Such a column stores a 
signed value whose bits
+     * have to be reinterpreted on read, and its statistics are ordered 
unsigned, so both the reader
+     * and the predicate pushdown have to treat it apart from an ordinary int.
+     */
+    public static boolean isUnsignedInt(PrimitiveType type) {
+        LogicalTypeAnnotation logicalType = type.getLogicalTypeAnnotation();
+        return logicalType instanceof 
LogicalTypeAnnotation.IntLogicalTypeAnnotation
+                && !((LogicalTypeAnnotation.IntLogicalTypeAnnotation) 
logicalType).isSigned();
+    }
+
     /** Convert paimon {@link RowType} to parquet {@link MessageType}. */
     public static MessageType convertToParquetMessageType(RowType rowType) {
         return new MessageType(PAIMON_SCHEMA, convertToParquetTypes(rowType));
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetVectorUpdaterFactory.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetVectorUpdaterFactory.java
index 3dcd3fbd03..885d880514 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetVectorUpdaterFactory.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/ParquetVectorUpdaterFactory.java
@@ -73,6 +73,7 @@ import java.nio.ByteOrder;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 
+import static 
org.apache.paimon.format.parquet.ParquetSchemaConverter.isUnsignedInt;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Updater Factory to get {@link ParquetVectorUpdater}. */
@@ -180,7 +181,18 @@ public class ParquetVectorUpdaterFactory {
 
         @Override
         public UpdaterFactory visit(BigIntType bigIntType) {
-            return c -> new LongUpdater();
+            return c -> {
+                if (c.getPrimitiveType().getPrimitiveTypeName()
+                        == PrimitiveType.PrimitiveTypeName.INT32) {
+                    // The file kept the narrower int, either because the 
column was widened in
+                    // the metastore after the data was written, or because it 
is unsigned and
+                    // BIGINT is the only Paimon type that can hold every 
value.
+                    return isUnsignedInt(c.getPrimitiveType())
+                            ? new LongFromUnsignedIntegerUpdater()
+                            : new LongFromIntegerUpdater();
+                }
+                return new LongUpdater();
+            };
         }
 
         @Override
@@ -196,7 +208,13 @@ public class ParquetVectorUpdaterFactory {
 
         @Override
         public UpdaterFactory visit(DoubleType doubleType) {
-            return c -> new DoubleUpdater();
+            return c -> {
+                if (c.getPrimitiveType().getPrimitiveTypeName()
+                        == PrimitiveType.PrimitiveTypeName.FLOAT) {
+                    return new DoubleFromFloatUpdater();
+                }
+                return new DoubleUpdater();
+            };
         }
 
         @Override
@@ -563,6 +581,81 @@ public class ParquetVectorUpdaterFactory {
         }
     }
 
+    /** Reads a signed INT32 column into a BIGINT vector. */
+    private static class LongFromIntegerUpdater
+            implements ParquetVectorUpdater<WritableLongVector> {
+        @Override
+        public void readValues(
+                int total,
+                int offset,
+                WritableLongVector values,
+                VectorizedValuesReader valuesReader) {
+            for (int i = 0; i < total; i++) {
+                values.setLong(offset + i, valuesReader.readInteger());
+            }
+        }
+
+        @Override
+        public void skipValues(int total, VectorizedValuesReader valuesReader) 
{
+            valuesReader.skipIntegers(total);
+        }
+
+        @Override
+        public void readValue(
+                int offset, WritableLongVector values, VectorizedValuesReader 
valuesReader) {
+            values.setLong(offset, valuesReader.readInteger());
+        }
+
+        @Override
+        public void decodeSingleDictionaryId(
+                int offset,
+                WritableLongVector values,
+                WritableIntVector dictionaryIds,
+                Dictionary dictionary) {
+            values.setLong(offset, 
dictionary.decodeToInt(dictionaryIds.getInt(offset)));
+        }
+    }
+
+    /**
+     * Reads an unsigned INT32 column into a BIGINT vector. The stored bits 
are a signed int, so
+     * every value above {@link Integer#MAX_VALUE} arrives negative and has to 
be reinterpreted.
+     */
+    private static class LongFromUnsignedIntegerUpdater
+            implements ParquetVectorUpdater<WritableLongVector> {
+        @Override
+        public void readValues(
+                int total,
+                int offset,
+                WritableLongVector values,
+                VectorizedValuesReader valuesReader) {
+            for (int i = 0; i < total; i++) {
+                values.setLong(offset + i, 
Integer.toUnsignedLong(valuesReader.readInteger()));
+            }
+        }
+
+        @Override
+        public void skipValues(int total, VectorizedValuesReader valuesReader) 
{
+            valuesReader.skipIntegers(total);
+        }
+
+        @Override
+        public void readValue(
+                int offset, WritableLongVector values, VectorizedValuesReader 
valuesReader) {
+            values.setLong(offset, 
Integer.toUnsignedLong(valuesReader.readInteger()));
+        }
+
+        @Override
+        public void decodeSingleDictionaryId(
+                int offset,
+                WritableLongVector values,
+                WritableIntVector dictionaryIds,
+                Dictionary dictionary) {
+            values.setLong(
+                    offset,
+                    
Integer.toUnsignedLong(dictionary.decodeToInt(dictionaryIds.getInt(offset))));
+        }
+    }
+
     private abstract static class AbstractTimestampUpdater
             implements ParquetVectorUpdater<WritableColumnVector> {
 
@@ -876,6 +969,41 @@ public class ParquetVectorUpdaterFactory {
         }
     }
 
+    /** Reads a FLOAT column into a DOUBLE vector. */
+    private static class DoubleFromFloatUpdater
+            implements ParquetVectorUpdater<WritableDoubleVector> {
+        @Override
+        public void readValues(
+                int total,
+                int offset,
+                WritableDoubleVector values,
+                VectorizedValuesReader valuesReader) {
+            for (int i = 0; i < total; i++) {
+                values.setDouble(offset + i, valuesReader.readFloat());
+            }
+        }
+
+        @Override
+        public void skipValues(int total, VectorizedValuesReader valuesReader) 
{
+            valuesReader.skipFloats(total);
+        }
+
+        @Override
+        public void readValue(
+                int offset, WritableDoubleVector values, 
VectorizedValuesReader valuesReader) {
+            values.setDouble(offset, valuesReader.readFloat());
+        }
+
+        @Override
+        public void decodeSingleDictionaryId(
+                int offset,
+                WritableDoubleVector values,
+                WritableIntVector dictionaryIds,
+                Dictionary dictionary) {
+            values.setDouble(offset, 
dictionary.decodeToFloat(dictionaryIds.getInt(offset)));
+        }
+    }
+
     private static class BinaryUpdater implements 
ParquetVectorUpdater<WritableBytesVector> {
         @Override
         public void readValues(
diff --git 
a/paimon-format/src/main/java/org/apache/parquet/filter2/predicate/ParquetFilters.java
 
b/paimon-format/src/main/java/org/apache/parquet/filter2/predicate/ParquetFilters.java
index 27d414ad75..f7f5edbeeb 100644
--- 
a/paimon-format/src/main/java/org/apache/parquet/filter2/predicate/ParquetFilters.java
+++ 
b/paimon-format/src/main/java/org/apache/parquet/filter2/predicate/ParquetFilters.java
@@ -21,6 +21,7 @@ package org.apache.parquet.filter2.predicate;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.Decimal;
 import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.format.parquet.ParquetSchemaConverter;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.FunctionVisitor;
 import org.apache.paimon.predicate.LeafPredicate;
@@ -61,6 +62,8 @@ import org.apache.parquet.schema.MessageType;
 import org.apache.parquet.schema.PrimitiveType;
 import org.apache.parquet.schema.Type;
 
+import javax.annotation.Nullable;
+
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
@@ -306,20 +309,7 @@ public class ParquetFilters {
                 }
             }
 
-            if (value instanceof Number) {
-                if (value instanceof Byte) {
-                    return ((Byte) value).intValue();
-                } else if (value instanceof Short) {
-                    return ((Short) value).intValue();
-                }
-                return (Comparable<?>) value;
-            } else if (value instanceof String) {
-                return Binary.fromString((String) value);
-            } else if (value instanceof BinaryString) {
-                return Binary.fromString(value.toString());
-            } else if (value instanceof byte[]) {
-                return Binary.fromReusedByteArray((byte[]) value);
-            } else if (value instanceof Timestamp) {
+            if (value instanceof Timestamp) {
                 Timestamp timestamp = (Timestamp) value;
                 timestampPrimitiveType(fieldRef, fileSchema, caseSensitive);
                 int precision = getTimestampPrecision(type);
@@ -334,6 +324,72 @@ public class ParquetFilters {
                 throw new UnsupportedOperationException();
             }
 
+            // The literal has to speak whatever the file holds, not what the 
table declares.
+            switch (pushdownTarget(fieldRef, fileSchema, caseSensitive).type) {
+                case INT32:
+                    return toInt(value);
+                case INT64:
+                    return toLong(value);
+                case FLOAT:
+                    return toFloat(value);
+                case DOUBLE:
+                    return toDouble(value);
+                case BINARY:
+                case FIXED_LEN_BYTE_ARRAY:
+                    return toBinary(value);
+                default:
+                    throw new UnsupportedOperationException();
+            }
+        }
+
+        private Comparable<?> toInt(Object value) {
+            long asLong = toLongValue(value);
+            if (asLong < Integer.MIN_VALUE || asLong > Integer.MAX_VALUE) {
+                // Truncating would change what the predicate means: `< 
3000000000` would become
+                // `< -1294967296` and prune row groups that hold matching 
rows.
+                throw new UnsupportedOperationException();
+            }
+            return (int) asLong;
+        }
+
+        private Comparable<?> toLong(Object value) {
+            return toLongValue(value);
+        }
+
+        private long toLongValue(Object value) {
+            if (value instanceof Byte
+                    || value instanceof Short
+                    || value instanceof Integer
+                    || value instanceof Long) {
+                return ((Number) value).longValue();
+            }
+            throw new UnsupportedOperationException();
+        }
+
+        private Comparable<?> toFloat(Object value) {
+            if (value instanceof Float) {
+                return (Float) value;
+            }
+            throw new UnsupportedOperationException();
+        }
+
+        private Comparable<?> toDouble(Object value) {
+            // Float widens to double exactly; a double literal must never be 
narrowed to float,
+            // which would round the bound and drop rows that sit next to it.
+            if (value instanceof Float || value instanceof Double) {
+                return ((Number) value).doubleValue();
+            }
+            throw new UnsupportedOperationException();
+        }
+
+        private Comparable<?> toBinary(Object value) {
+            if (value instanceof String) {
+                return Binary.fromString((String) value);
+            } else if (value instanceof BinaryString) {
+                return Binary.fromString(value.toString());
+            } else if (value instanceof byte[]) {
+                return Binary.fromReusedByteArray((byte[]) value);
+            }
             throw new UnsupportedOperationException();
         }
 
@@ -430,23 +486,137 @@ public class ParquetFilters {
 
     private static PrimitiveType primitiveType(
             FieldRef fieldRef, MessageType fileSchema, boolean caseSensitive) {
-        Type matched = null;
+        PrimitiveType matched = findPrimitiveType(fieldRef, fileSchema, 
caseSensitive);
+        if (matched == null) {
+            throw new UnsupportedOperationException();
+        }
+        return matched;
+    }
+
+    /**
+     * The file's column for {@code fieldRef}, or null when the file has no 
such column. A column
+     * that exists but is not primitive cannot carry a predicate at all, so it 
is rejected outright.
+     */
+    @Nullable
+    private static PrimitiveType findPrimitiveType(
+            FieldRef fieldRef, MessageType fileSchema, boolean caseSensitive) {
         // Paimon predicates currently reference top-level fields only. Nested 
field
         // predicates are rejected before reaching the format reader.
         for (Type field : fileSchema.getFields()) {
             if (caseSensitive
                     ? field.getName().equals(fieldRef.name())
                     : field.getName().equalsIgnoreCase(fieldRef.name())) {
-                matched = field;
-                break;
+                if (!field.isPrimitive()) {
+                    throw new UnsupportedOperationException();
+                }
+                return field.asPrimitiveType();
             }
         }
+        return null;
+    }
 
-        if (matched == null || !matched.isPrimitive()) {
+    /**
+     * The physical type a pushed-down predicate on {@code fieldRef} has to be 
expressed in. A
+     * Format Table takes its schema from the metastore while its files are 
written by someone else,
+     * so the declared type is not necessarily what the file holds and the 
predicate has to follow
+     * the file.
+     *
+     * <p>Falls back to what the table declares when the file has no such 
column: parquet-mr skips
+     * validation for a column it cannot find and evaluates it as null, so the 
predicate still
+     * prunes.
+     *
+     * <p>Throws {@link UnsupportedOperationException} when the two types 
cannot be reconciled
+     * without changing what the predicate means. {@link #convert} swallows 
that and drops the
+     * predicate, which costs pruning but never rows.
+     */
+    private static PushdownTarget pushdownTarget(
+            FieldRef fieldRef, MessageType fileSchema, boolean caseSensitive) {
+        PrimitiveType.PrimitiveTypeName[] acceptable = 
acceptableTypes(fieldRef.type());
+        PrimitiveType fileType = findPrimitiveType(fieldRef, fileSchema, 
caseSensitive);
+        if (fileType == null) {
+            return new PushdownTarget(fieldRef.name(), acceptable[0]);
+        }
+
+        if (ParquetSchemaConverter.isUnsignedInt(fileType)) {
+            // An unsigned column orders its statistics unsigned, so a signed 
bound would prune the
+            // wrong row groups. The read still widens the column; only the 
pruning is given up.
             throw new UnsupportedOperationException();
         }
 
-        return matched.asPrimitiveType();
+        for (PrimitiveType.PrimitiveTypeName candidate : acceptable) {
+            if (fileType.getPrimitiveTypeName() == candidate) {
+                return new PushdownTarget(fileType.getName(), candidate);
+            }
+        }
+        throw new UnsupportedOperationException();
+    }
+
+    /**
+     * The file column a predicate has to name and the type it has to speak. 
The name matters in
+     * case-insensitive mode: parquet-mr resolves a predicate against the file 
by exact column path,
+     * so a predicate carrying the metastore's spelling of a column that the 
file spells differently
+     * reads as a column the file does not have - which the statistics filter 
takes for all-null and
+     * prunes away, silently losing every row.
+     */
+    private static class PushdownTarget {
+
+        private final String name;
+        private final PrimitiveType.PrimitiveTypeName type;
+
+        private PushdownTarget(String name, PrimitiveType.PrimitiveTypeName 
type) {
+            this.name = name;
+            this.type = type;
+        }
+    }
+
+    /**
+     * The physical types a predicate on this Paimon type can be expressed in, 
most preferred first.
+     * The head is what Paimon itself writes; the tail is the type widening 
the vectorized reader
+     * accepts, so pushdown stays available for exactly the files that can be 
read.
+     */
+    private static PrimitiveType.PrimitiveTypeName[] acceptableTypes(
+            org.apache.paimon.types.DataType type) {
+        switch (type.getTypeRoot()) {
+            case BOOLEAN:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.BOOLEAN
+                };
+            case TINYINT:
+            case SMALLINT:
+            case INTEGER:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.INT32, 
PrimitiveType.PrimitiveTypeName.INT64
+                };
+            case BIGINT:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.INT64, 
PrimitiveType.PrimitiveTypeName.INT32
+                };
+            case FLOAT:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.FLOAT, 
PrimitiveType.PrimitiveTypeName.DOUBLE
+                };
+            case DOUBLE:
+                // A double bound cannot be narrowed to float without rounding 
it, so a FLOAT file
+                // column is read but never filtered on.
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.DOUBLE
+                };
+            case DATE:
+            case TIME_WITHOUT_TIME_ZONE:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.INT32
+                };
+            case CHAR:
+            case VARCHAR:
+            case BINARY:
+            case VARBINARY:
+                return new PrimitiveType.PrimitiveTypeName[] {
+                    PrimitiveType.PrimitiveTypeName.BINARY,
+                    PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY
+                };
+            default:
+                throw new UnsupportedOperationException();
+        }
     }
 
     private static int getTimestampPrecision(org.apache.paimon.types.DataType 
type) {
@@ -462,81 +632,101 @@ public class ParquetFilters {
             implements DataTypeVisitor<Operators.Column<?>> {
 
         private final FieldRef fieldRef;
-        private final String name;
         private final MessageType fileSchema;
         private final boolean caseSensitive;
 
         public ConvertToColumnTypeVisitor(
                 FieldRef fieldRef, MessageType fileSchema, boolean 
caseSensitive) {
             this.fieldRef = fieldRef;
-            this.name = fieldRef.name();
             this.fileSchema = fileSchema;
             this.caseSensitive = caseSensitive;
         }
 
+        /** The column typed after the file, not after what the table 
declares. */
+        private Operators.Column<?> column() {
+            PushdownTarget target = pushdownTarget(fieldRef, fileSchema, 
caseSensitive);
+            switch (target.type) {
+                case BOOLEAN:
+                    return FilterApi.booleanColumn(target.name);
+                case INT32:
+                    return FilterApi.intColumn(target.name);
+                case INT64:
+                    return FilterApi.longColumn(target.name);
+                case FLOAT:
+                    return FilterApi.floatColumn(target.name);
+                case DOUBLE:
+                    return FilterApi.doubleColumn(target.name);
+                case BINARY:
+                case FIXED_LEN_BYTE_ARRAY:
+                    return FilterApi.binaryColumn(target.name);
+                default:
+                    throw new UnsupportedOperationException();
+            }
+        }
+
         @Override
         public Operators.Column<?> visit(CharType charType) {
-            return FilterApi.binaryColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(VarCharType varCharType) {
-            return FilterApi.binaryColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(BooleanType booleanType) {
-            return FilterApi.booleanColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(BinaryType binaryType) {
-            return FilterApi.binaryColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(VarBinaryType varBinaryType) {
-            return FilterApi.binaryColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(TinyIntType tinyIntType) {
-            return FilterApi.intColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(SmallIntType smallIntType) {
-            return FilterApi.intColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(IntType intType) {
-            return FilterApi.intColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(BigIntType bigIntType) {
-            return FilterApi.longColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(FloatType floatType) {
-            return FilterApi.floatColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(DoubleType doubleType) {
-            return FilterApi.doubleColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(DateType dateType) {
-            return FilterApi.intColumn(name);
+            return column();
         }
 
         @Override
         public Operators.Column<?> visit(TimeType timeType) {
-            return FilterApi.intColumn(name);
+            return column();
         }
 
         @Override
@@ -544,12 +734,12 @@ public class ParquetFilters {
             PrimitiveType primitiveType = decimalPrimitiveType(fieldRef, 
fileSchema, caseSensitive);
             switch (primitiveType.getPrimitiveTypeName()) {
                 case INT32:
-                    return FilterApi.intColumn(fieldRef.name());
+                    return FilterApi.intColumn(primitiveType.getName());
                 case INT64:
-                    return FilterApi.longColumn(fieldRef.name());
+                    return FilterApi.longColumn(primitiveType.getName());
                 case BINARY:
                 case FIXED_LEN_BYTE_ARRAY:
-                    return FilterApi.binaryColumn(fieldRef.name());
+                    return FilterApi.binaryColumn(primitiveType.getName());
                 default:
                     throw new UnsupportedOperationException();
             }
@@ -559,8 +749,8 @@ public class ParquetFilters {
         public Operators.Column<?> visit(TimestampType timestampType) {
             int precision = timestampType.getPrecision();
             if (precision <= 6) {
-                timestampPrimitiveType(fieldRef, fileSchema, caseSensitive);
-                return FilterApi.longColumn(name);
+                return FilterApi.longColumn(
+                        timestampPrimitiveType(fieldRef, fileSchema, 
caseSensitive).getName());
             }
             // precision > 6 uses INT96, not supported for filter pushdown
             throw new UnsupportedOperationException();
@@ -570,8 +760,8 @@ public class ParquetFilters {
         public Operators.Column<?> visit(LocalZonedTimestampType 
localZonedTimestampType) {
             int precision = localZonedTimestampType.getPrecision();
             if (precision <= 6) {
-                timestampPrimitiveType(fieldRef, fileSchema, caseSensitive);
-                return FilterApi.longColumn(name);
+                return FilterApi.longColumn(
+                        timestampPrimitiveType(fieldRef, fileSchema, 
caseSensitive).getName());
             }
             // precision > 6 uses INT96, not supported for filter pushdown
             throw new UnsupportedOperationException();
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetTypeWideningTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetTypeWideningTest.java
new file mode 100644
index 0000000000..d4d71f9014
--- /dev/null
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetTypeWideningTest.java
@@ -0,0 +1,479 @@
+/*
+ * 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.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.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+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.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.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reads where the declared type is wider than the type stored in the Parquet 
file.
+ *
+ * <p>A Format Table takes its schema from the metastore while its files are 
written by someone
+ * else, so the two can disagree: an {@code ALTER TABLE ... CHANGE c int 
BIGINT} leaves every
+ * existing file with an INT32 column, and an unsigned INT32 column is 
imported as BIGINT because
+ * that is the only Paimon type that can hold it. Both the vectorized read and 
the predicate
+ * pushdown have to cope.
+ *
+ * <p>Column names mirror midas_prod.tbl_imp, where this was found.
+ */
+class ParquetTypeWideningTest {
+
+    @TempDir File folder;
+
+    private static final RowType ECPM_BIGINT =
+            RowType.builder()
+                    .field("pageviewId", DataTypes.STRING())
+                    .field("ecpm", DataTypes.BIGINT())
+                    .field("revenue", DataTypes.BIGINT())
+                    .build();
+
+    private static final long[] ECPM_VALUES = {10L, 150L, 3000L};
+
+    private static final String[] PAGEVIEW_IDS = {"a", "b", "c"};
+
+    // ------------------------------------------------------------------
+    // Widening INT32 -> BIGINT, the shape reported from production.
+    // ------------------------------------------------------------------
+
+    @Test
+    void testSignedInt32ReadAsBigInt() throws Exception {
+        Path path = write(ecpmSchema("int32 ecpm"));
+
+        assertThat(longs(read(ECPM_BIGINT, path, null), 
1)).containsExactly(10L, 150L, 3000L);
+    }
+
+    @Test
+    void testSignedInt32ReadAsBigIntWithPushdown() throws Exception {
+        Path path = write(ecpmSchema("int32 ecpm"));
+        PredicateBuilder builder = new PredicateBuilder(ECPM_BIGINT);
+
+        List<Object[]> rows =
+                read(
+                        ECPM_BIGINT,
+                        path,
+                        Arrays.asList(builder.lessThan(1, 5000L), 
builder.greaterThan(2, 0L)));
+
+        assertThat(longs(rows, 1)).containsExactly(10L, 150L, 3000L);
+    }
+
+    /** A predicate that excludes the whole row group must still prune it 
after the type fix. */
+    @Test
+    void testSignedInt32PushdownStillPrunes() throws Exception {
+        Path path = write(ecpmSchema("int32 ecpm"));
+        PredicateBuilder builder = new PredicateBuilder(ECPM_BIGINT);
+
+        List<Object[]> rows =
+                read(ECPM_BIGINT, path, 
Collections.singletonList(builder.greaterThan(1, 99999L)));
+
+        assertThat(rows).isEmpty();
+    }
+
+    /**
+     * The literal does not fit in INT32. Narrowing it would turn a bound of 
3000000000 into one of
+     * -1294967296, which prunes the row group and silently drops every row. 
The predicate has to be
+     * abandoned instead.
+     */
+    @Test
+    void testLiteralOutsideInt32RangeIsNotTruncated() throws Exception {
+        Path path = write(ecpmSchema("int32 ecpm"));
+        PredicateBuilder builder = new PredicateBuilder(ECPM_BIGINT);
+
+        List<Object[]> rows =
+                read(
+                        ECPM_BIGINT,
+                        path,
+                        Collections.singletonList(builder.lessThan(1, 
3_000_000_000L)));
+
+        assertThat(longs(rows, 1)).containsExactly(10L, 150L, 3000L);
+    }
+
+    // ------------------------------------------------------------------
+    // Unsigned INT32 -> BIGINT. Spark and Trino both surface this as bigint,
+    // so an import writes BIGINT into the metastore while the file stays 
INT32.
+    // ------------------------------------------------------------------
+
+    @Test
+    void testUnsignedInt32ReadAsBigInt() throws Exception {
+        Path path =
+                write(
+                        ecpmSchema("int32 ecpm (INTEGER(32,false))"),
+                        (group, i) -> group.append("ecpm", UNSIGNED_RAW[i]));
+
+        // The raw ints are negative; read as BIGINT they must come back as 
their unsigned value.
+        assertThat(longs(read(ECPM_BIGINT, path, null), 1))
+                .containsExactly(10L, 3_000_000_000L, 4_294_967_295L);
+    }
+
+    /**
+     * Statistics on an unsigned column are ordered unsigned, so a signed 
predicate would prune the
+     * wrong row groups. The predicate must be abandoned rather than pushed.
+     */
+    @Test
+    void testUnsignedInt32PushdownDoesNotDropRows() throws Exception {
+        Path path =
+                write(
+                        ecpmSchema("int32 ecpm (INTEGER(32,false))"),
+                        (group, i) -> group.append("ecpm", UNSIGNED_RAW[i]));
+        PredicateBuilder builder = new PredicateBuilder(ECPM_BIGINT);
+
+        List<Object[]> rows =
+                read(ECPM_BIGINT, path, 
Collections.singletonList(builder.greaterThan(1, 5L)));
+
+        assertThat(longs(rows, 1)).containsExactly(10L, 3_000_000_000L, 
4_294_967_295L);
+    }
+
+    // ------------------------------------------------------------------
+    // FLOAT -> DOUBLE, the same hole in the other numeric family.
+    // ------------------------------------------------------------------
+
+    @Test
+    void testFloatReadAsDouble() throws Exception {
+        RowType readType =
+                RowType.builder()
+                        .field("pageviewId", DataTypes.STRING())
+                        .field("rate", DataTypes.DOUBLE())
+                        .build();
+        Path path =
+                writeSchema(
+                        "message root {\n"
+                                + "  optional binary pageviewId (UTF8);\n"
+                                + "  optional float rate;\n"
+                                + "}",
+                        (group, i) ->
+                                group.append("pageviewId", PAGEVIEW_IDS[i])
+                                        .append("rate", (float) (i + 1)));
+
+        List<Object[]> rows = read(readType, path, null);
+
+        assertThat(rows).hasSize(3);
+        assertThat((Double) rows.get(0)[1]).isEqualTo(1.0d);
+        assertThat((Double) rows.get(2)[1]).isEqualTo(3.0d);
+    }
+
+    @Test
+    void testFloatReadAsDoubleWithPushdown() throws Exception {
+        RowType readType =
+                RowType.builder()
+                        .field("pageviewId", DataTypes.STRING())
+                        .field("rate", DataTypes.DOUBLE())
+                        .build();
+        Path path =
+                writeSchema(
+                        "message root {\n"
+                                + "  optional binary pageviewId (UTF8);\n"
+                                + "  optional float rate;\n"
+                                + "}",
+                        (group, i) ->
+                                group.append("pageviewId", PAGEVIEW_IDS[i])
+                                        .append("rate", (float) (i + 1)));
+        PredicateBuilder builder = new PredicateBuilder(readType);
+
+        List<Object[]> rows =
+                read(readType, path, 
Collections.singletonList(builder.greaterThan(1, 0.5d)));
+
+        assertThat(rows).hasSize(3);
+    }
+
+    // ------------------------------------------------------------------
+    // Narrowing INT64 -> INT. The reader already handles it via
+    // IntegerFromLongUpdater; only the pushdown was left behind.
+    // ------------------------------------------------------------------
+
+    @Test
+    void testInt64ReadAsIntWithPushdown() throws Exception {
+        RowType readType =
+                RowType.builder()
+                        .field("pageviewId", DataTypes.STRING())
+                        .field("ecpm", DataTypes.INT())
+                        .build();
+        Path path = write(ecpmSchema("int64 ecpm"));
+        PredicateBuilder builder = new PredicateBuilder(readType);
+
+        List<Object[]> rows =
+                read(readType, path, 
Collections.singletonList(builder.lessThan(1, 5000)));
+
+        assertThat(rows).hasSize(3);
+        assertThat((Integer) rows.get(2)[1]).isEqualTo(3000);
+    }
+
+    // ------------------------------------------------------------------
+    // Control and mixed-file cases.
+    // ------------------------------------------------------------------
+
+    /** A file that really holds int64 was never broken; it must stay that 
way. */
+    @Test
+    void testInt64ReadAsBigIntIsUnchanged() throws Exception {
+        Path path = write(ecpmSchema("int64 ecpm"));
+        PredicateBuilder builder = new PredicateBuilder(ECPM_BIGINT);
+
+        List<Object[]> rows =
+                read(ECPM_BIGINT, path, 
Collections.singletonList(builder.lessThan(1, 5000L)));
+
+        assertThat(longs(rows, 1)).containsExactly(10L, 150L, 3000L);
+    }
+
+    /**
+     * One reader factory serves every file of a split, so a partition that 
mixes writers puts both
+     * physical types through the same factory. Neither file may break the 
other.
+     */
+    @Test
+    void testMixedFilesInOneFactory() throws Exception {
+        Path int32File = write(ecpmSchema("int32 ecpm"));
+        Path int64File = write(ecpmSchema("int64 ecpm"));
+
+        ParquetReaderFactory factory =
+                new ParquetReaderFactory(
+                        new Options(),
+                        ECPM_BIGINT,
+                        1024,
+                        Collections.singletonList(
+                                new PredicateBuilder(ECPM_BIGINT).lessThan(1, 
5000L)));
+
+        assertThat(longs(read(factory, ECPM_BIGINT, int64File), 1))
+                .containsExactly(10L, 150L, 3000L);
+        assertThat(longs(read(factory, ECPM_BIGINT, int32File), 1))
+                .containsExactly(10L, 150L, 3000L);
+        assertThat(longs(read(factory, ECPM_BIGINT, int64File), 1))
+                .containsExactly(10L, 150L, 3000L);
+    }
+
+    // ------------------------------------------------------------------
+    // Case-insensitive resolution. The metastore lowercases column names 
while a
+    // Spark-written file keeps the original spelling, so a predicate carrying 
the
+    // metastore's spelling names a column the file does not have - which 
parquet-mr
+    // takes for all-null and prunes away, losing every row without a word.
+    // ------------------------------------------------------------------
+
+    @Test
+    void testCaseInsensitivePushdownKeepsRows() throws Exception {
+        Path path =
+                writeSchema(
+                        "message root {\n"
+                                + "  optional binary PageviewId (UTF8);\n"
+                                + "  optional int32 Ecpm;\n"
+                                + "}",
+                        (group, i) ->
+                                group.append("PageviewId", "id" + 
i).append("Ecpm", (i + 1) * 100));
+        RowType readType =
+                RowType.builder()
+                        .field("pageviewId", DataTypes.STRING())
+                        .field("ecpm", DataTypes.BIGINT())
+                        .build();
+        PredicateBuilder builder = new PredicateBuilder(readType);
+
+        List<Object[]> rows =
+                read(
+                        readType,
+                        path,
+                        Collections.singletonList(builder.lessThan(1, 
100_000L)),
+                        false);
+
+        assertThat(longs(rows, 1)).containsExactly(100L, 200L, 300L);
+    }
+
+    /** Same, with no type widening in play at all, so only the spelling is at 
stake. */
+    @Test
+    void testCaseInsensitivePushdownKeepsRowsWithoutWidening() throws 
Exception {
+        Path path =
+                writeSchema(
+                        "message root {\n"
+                                + "  optional binary PageviewId (UTF8);\n"
+                                + "  optional int64 Ecpm;\n"
+                                + "}",
+                        (group, i) ->
+                                group.append("PageviewId", "id" + i)
+                                        .append("Ecpm", (long) ((i + 1) * 
100)));
+        RowType readType =
+                RowType.builder()
+                        .field("pageviewId", DataTypes.STRING())
+                        .field("ecpm", DataTypes.BIGINT())
+                        .build();
+        PredicateBuilder builder = new PredicateBuilder(readType);
+
+        List<Object[]> rows =
+                read(
+                        readType,
+                        path,
+                        Collections.singletonList(builder.lessThan(1, 
100_000L)),
+                        false);
+
+        assertThat(longs(rows, 1)).containsExactly(100L, 200L, 300L);
+    }
+
+    // ------------------------------------------------------------------
+    // Helpers
+    // ------------------------------------------------------------------
+
+    /** Raw int32 bit patterns for 10, 3000000000 and 4294967295 read as 
unsigned. */
+    private static final int[] UNSIGNED_RAW = {10, (int) 3_000_000_000L, -1};
+
+    private static List<Long> longs(List<Object[]> rows, int field) {
+        List<Long> values = new ArrayList<>();
+        rows.forEach(row -> values.add((Long) row[field]));
+        return values;
+    }
+
+    private static String ecpmSchema(String ecpmType) {
+        return "message root {\n"
+                + "  optional binary pageviewId (UTF8);\n"
+                + "  optional "
+                + ecpmType
+                + ";\n"
+                + "  optional int64 revenue;\n"
+                + "}";
+    }
+
+    private List<Object[]> read(RowType readType, Path path, List<Predicate> 
filters)
+            throws Exception {
+        return read(readType, path, filters, true);
+    }
+
+    private List<Object[]> read(
+            RowType readType, Path path, List<Predicate> filters, boolean 
caseSensitive)
+            throws Exception {
+        Options options = new Options();
+        options.set(CatalogOptions.CASE_SENSITIVE, caseSensitive);
+        return read(new ParquetReaderFactory(options, readType, 1024, 
filters), readType, path);
+    }
+
+    private List<Object[]> read(ParquetReaderFactory factory, RowType 
readType, Path path)
+            throws Exception {
+        LocalFileIO fileIO = new LocalFileIO();
+        List<Object[]> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                factory.createReader(
+                        new FormatReaderContext(fileIO, path, 
fileIO.getFileSize(path)))) {
+            // Row instances are reused across iterations, so materialize 
every value.
+            reader.forEachRemaining(row -> rows.add(materialize(row, 
readType)));
+        }
+        return rows;
+    }
+
+    private static Object[] materialize(InternalRow row, RowType readType) {
+        Object[] values = new Object[readType.getFieldCount()];
+        for (int i = 0; i < values.length; i++) {
+            if (row.isNullAt(i)) {
+                continue;
+            }
+            switch (readType.getTypeAt(i).getTypeRoot()) {
+                case VARCHAR:
+                    values[i] = row.getString(i).toString();
+                    break;
+                case INTEGER:
+                    values[i] = row.getInt(i);
+                    break;
+                case BIGINT:
+                    values[i] = row.getLong(i);
+                    break;
+                case DOUBLE:
+                    values[i] = row.getDouble(i);
+                    break;
+                default:
+                    throw new UnsupportedOperationException(
+                            "Unhandled type in test: " + 
readType.getTypeAt(i));
+            }
+        }
+        return values;
+    }
+
+    private Path write(String schemaText) throws Exception {
+        return write(schemaText, null);
+    }
+
+    private Path write(String schemaText, BiConsumer<Group, Integer> 
ecpmAppender)
+            throws Exception {
+        MessageType schema = MessageTypeParser.parseMessageType(schemaText);
+        boolean ecpmIsLong =
+                schema.getType("ecpm")
+                        .asPrimitiveType()
+                        .getPrimitiveTypeName()
+                        .name()
+                        .equals("INT64");
+        return writeGroups(
+                schema,
+                (group, i) -> {
+                    group.append("pageviewId", PAGEVIEW_IDS[i]);
+                    if (ecpmAppender != null) {
+                        ecpmAppender.accept(group, i);
+                    } else if (ecpmIsLong) {
+                        group.append("ecpm", ECPM_VALUES[i]);
+                    } else {
+                        group.append("ecpm", (int) ECPM_VALUES[i]);
+                    }
+                    group.append("revenue", (long) (i + 1));
+                });
+    }
+
+    private Path writeSchema(String schemaText, BiConsumer<Group, Integer> 
appender)
+            throws Exception {
+        return writeGroups(MessageTypeParser.parseMessageType(schemaText), 
appender);
+    }
+
+    private Path writeGroups(MessageType schema, BiConsumer<Group, Integer> 
appender)
+            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 (int i = 0; i < 3; i++) {
+                Group group = new SimpleGroupFactory(schema).newGroup();
+                appender.accept(group, i);
+                writer.write(group);
+            }
+        }
+        return path;
+    }
+}

Reply via email to