This is an automated email from the ASF dual-hosted git repository.

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 29b7a52564f4 fix(trino): skip predicate pushdown on type-evolved 
parquet columns (#19467)
29b7a52564f4 is described below

commit 29b7a52564f4a3fe3979994c97605098733d6997
Author: Vova Kolmakov <[email protected]>
AuthorDate: Wed Aug 26 15:18:50 2026 +0700

    fix(trino): skip predicate pushdown on type-evolved parquet columns (#19467)
    
    * fix(trino): skip predicate pushdown on type-evolved parquet columns
    
    * test(trino): remove the dead INT96 branch in the parquet statistics test 
helper
    
    * fix(trino): drop domains the parquet bloom filter and decimal rescale 
would misread
    
    * test(trino): name the test that actually pins the bloom-width mechanism
    
    The type-table comment on the int -> long / long -> int rows pointed at
    testABigintLookupNeverFindsAnInt32ColumnsBloomHashes, which does not exist.
    The test that pins the bloom miss on a real read is
    
TestHudiEvolvedColumnPredicates.testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows,
    which is what the class Javadoc and ParquetStatisticsDomains already name.
    
    ---------
    
    Co-authored-by: Vova Kolmakov <[email protected]>
    Co-authored-by: voon <[email protected]>
---
 .../trino/plugin/hudi/HudiPageSourceProvider.java  |   6 +-
 .../plugin/hudi/util/ParquetStatisticsDomains.java | 207 +++++++++++
 .../TestHudiConnectorParquetColumnNamesTest.java   |   7 +-
 .../hudi/TestHudiEvolvedColumnPredicates.java      | 387 ++++++++++++++++++++
 .../hudi/TestHudiPageSourceProviderTest.java       | 144 +-------
 .../io/trino/plugin/hudi/TestHudiSmokeTest.java    |  98 ++++--
 .../plugin/hudi/TestingBaseFilePageSource.java     | 201 +++++++++++
 .../SchemaEvolutionHudiTablesInitializer.java      | 174 +++++++++
 .../hudi/util/TestParquetStatisticsDomains.java    | 390 +++++++++++++++++++++
 9 files changed, 1453 insertions(+), 161 deletions(-)

diff --git 
a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java
index 1d58eb7adc1c..aafc7ea1c5ff 100644
--- a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java
+++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java
@@ -114,6 +114,7 @@ import static 
io.trino.plugin.hudi.HudiUtil.getLatestTableSchema;
 import static 
io.trino.plugin.hudi.HudiUtil.prependHudiMetaAndMergeRequiredColumns;
 import static io.trino.plugin.hudi.HudiUtil.resolveMergeModeAndStrategyId;
 import static io.trino.plugin.hudi.HudiUtil.usesNonProjectionCompatibleMerger;
+import static 
io.trino.plugin.hudi.util.ParquetStatisticsDomains.dropIncomparableDomains;
 import static io.trino.spi.StandardErrorCode.NOT_SUPPORTED;
 import static java.lang.String.format;
 import static java.util.Objects.requireNonNull;
@@ -409,9 +410,12 @@ public class HudiPageSourceProvider
 
             Map<List<String>, ColumnDescriptor> descriptorsByPath = 
getDescriptors(fileSchema, requestedSchema);
 
+            // A domain typed by the metastore cannot be matched against the 
statistics of a column the file stores
+            // under the type it had before a schema evolution, so those are 
dropped before the parquet predicate
+            // ever sees them. See ParquetStatisticsDomains.
             TupleDomain<ColumnDescriptor> parquetTupleDomain = 
options.isIgnoreStatistics() || !enablePredicatePushDown
                     ? TupleDomain.all()
-                    : getParquetTupleDomain(descriptorsByPath, 
getPushdownPredicate(hudiSplit, dynamicFilter, physicalIndexMap), fileSchema, 
useColumnNames);
+                    : 
dropIncomparableDomains(getParquetTupleDomain(descriptorsByPath, 
getPushdownPredicate(hudiSplit, dynamicFilter, physicalIndexMap), fileSchema, 
useColumnNames));
 
             TupleDomainParquetPredicate parquetPredicate = 
buildPredicate(requestedSchema, parquetTupleDomain, descriptorsByPath, 
timeZone);
 
diff --git 
a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/ParquetStatisticsDomains.java
 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/ParquetStatisticsDomains.java
new file mode 100644
index 000000000000..00b16562d504
--- /dev/null
+++ 
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/ParquetStatisticsDomains.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.util;
+
+import io.airlift.log.Logger;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.type.CharType;
+import io.trino.spi.type.DecimalType;
+import io.trino.spi.type.TimestampType;
+import io.trino.spi.type.Type;
+import io.trino.spi.type.UuidType;
+import io.trino.spi.type.VarbinaryType;
+import io.trino.spi.type.VarcharType;
+import org.apache.parquet.column.ColumnDescriptor;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import 
org.apache.parquet.schema.LogicalTypeAnnotation.DecimalLogicalTypeAnnotation;
+import 
org.apache.parquet.schema.LogicalTypeAnnotation.TimestampLogicalTypeAnnotation;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static io.trino.spi.type.BigintType.BIGINT;
+import static io.trino.spi.type.BooleanType.BOOLEAN;
+import static io.trino.spi.type.DateType.DATE;
+import static io.trino.spi.type.DoubleType.DOUBLE;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.spi.type.RealType.REAL;
+import static io.trino.spi.type.SmallintType.SMALLINT;
+import static io.trino.spi.type.TinyintType.TINYINT;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static 
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FLOAT;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT96;
+
+/**
+ * Keeps a pushed-down predicate from being matched against statistics it 
cannot be compared with.
+ * <p>
+ * {@code TupleDomainParquetPredicate.getDomain} selects its branch on the 
type of the pushed-down DOMAIN and then
+ * reads the parquet statistics as that type - {@code Double min = (Double) 
minimums.get(i)} and so on. The domain's
+ * type comes from the metastore while the statistics come from the file, and 
Hudi's type evolution is exactly what
+ * makes those two disagree: after a column evolves and the metastore is 
synced, every base file written before the
+ * evolution still stores the old physical type. Handing such a domain to the 
parquet predicate either fails the
+ * whole split with {@code Malformed Parquet file. Corrupted statistics for 
column ...} wrapping a
+ * {@link ClassCastException}, or - where the two types happen to share a 
representation, as a decimal and a varchar
+ * both do through {@code Slice} - silently prunes row groups on a comparison 
that means nothing.
+ * <p>
+ * The read path has no such problem: {@code ColumnReaderFactory} decodes 
parquet {@code FLOAT} into Trino
+ * {@code DOUBLE} and {@code INT32} into {@code BIGINT} natively, and {@code 
ParquetTypeTranslator.createCoercer}
+ * covers the rest of the promotions the page source is asked for. Only the 
statistics side is blind, so only the
+ * statistics side needs the guard.
+ * <p>
+ * This is a stopgap for the Trino the module is pinned to. 
trinodb/trino#30545 moves the same check down into
+ * {@code lib/trino-parquet}, where it can reconcile the two types rather than 
drop the domain - so it keeps
+ * {@code float -> double} pruning - and it covers the bloom filter path too. 
Once {@code trino.sha} in the root pom
+ * passes that commit, delete this class and its test instead of maintaining 
the two side by side.
+ */
+public final class ParquetStatisticsDomains
+{
+    private static final Logger log = 
Logger.get(ParquetStatisticsDomains.class);
+
+    private ParquetStatisticsDomains() {}
+
+    /**
+     * Drops every domain whose type cannot be compared against its column's 
statistics, keeping the rest untouched.
+     * <p>
+     * Dropping loses row group pruning for that column but never a row: 
{@code HudiMetadata.applyFilter} hands the
+     * whole regular predicate back to the engine as the remaining filter and 
does not precalculate statistics for
+     * the pushdown, so a connector-side domain is an optimization and nothing 
else. The dynamic half of the
+     * predicate is redundant with the join above the scan by construction. It 
is the same trade
+     * {@code HudiPageSourceProvider.remapPredicateColumnIndicesToPhysical} 
already makes for a predicate column the
+     * file does not carry, and the same one {@code 
HudiColumnStatsIndexSupport.getDomainFromColumnStats} makes when
+     * the metadata table's column statistics do not match the column's type.
+     * <p>
+     * The filter runs on the descriptor-keyed domain rather than on the 
column handles it was built from. That is
+     * what the parquet predicate itself will be evaluated against, so the 
check and the evaluation cannot disagree
+     * about which column or which physical type is meant; it covers both 
values of
+     * {@code hudi.parquet.use-column-names} in one pass, since a handle is 
resolved to a descriptor before either;
+     * and a dereference handle contributes the leaf field's type without any 
extra work.
+     */
+    public static TupleDomain<ColumnDescriptor> 
dropIncomparableDomains(TupleDomain<ColumnDescriptor> parquetTupleDomain)
+    {
+        if (parquetTupleDomain.isAll() || parquetTupleDomain.isNone()) {
+            return parquetTupleDomain;
+        }
+
+        Map<ColumnDescriptor, Domain> domains = 
parquetTupleDomain.getDomains().orElseThrow();
+        Map<ColumnDescriptor, Domain> comparableDomains = new 
LinkedHashMap<>();
+        for (Map.Entry<ColumnDescriptor, Domain> entry : domains.entrySet()) {
+            if (hasComparableStatistics(entry.getValue().getType(), 
entry.getKey().getPrimitiveType())) {
+                comparableDomains.put(entry.getKey(), entry.getValue());
+            }
+            else {
+                log.debug("Not pushing down a %s predicate on %s: the file 
stores it as %s, so the column statistics cannot answer it",
+                        entry.getValue().getType(), entry.getKey(), 
entry.getKey().getPrimitiveType());
+            }
+        }
+        if (comparableDomains.size() == domains.size()) {
+            return parquetTupleDomain;
+        }
+        return TupleDomain.withColumnDomains(comparableDomains);
+    }
+
+    /**
+     * Whether the parquet predicate can read a {@code fileType} column as 
{@code domainType} without misreading it,
+     * which is the case only when the two describe the same physical values.
+     * <p>
+     * Two dispatch tables have to agree here, not one. {@code 
TupleDomainParquetPredicate.getDomain} picks its branch
+     * on the domain type and reads the min/max statistics as that type; 
{@code checkInBloomFilter} picks a branch of
+     * its own and hashes the looked-up value at the DOMAIN's width, while 
parquet-mr hashed the column at the FILE's.
+     * A pair the first reads happily can therefore still make the second miss 
every lookup, and a bloom miss drops
+     * the row group outright - that loses rows rather than merely failing to 
prune them. A pair is kept only when
+     * each of the two either reads the file's own representation or declines 
to use it.
+     * <p>
+     * Dropping is not free either, so nothing is dropped without cause. 
{@code getDomain} answers a null-count
+     * predicate before it dispatches on type at all - all-null statistics 
become {@code Domain.onlyNull} and a zero
+     * null count becomes a not-null domain - so a dropped domain also 
forfeits {@code IS NULL} / {@code IS NOT NULL}
+     * pruning, and a dropped {@code VARBINARY} or {@code UUID} domain 
forfeits bloom pruning that would have worked.
+     * That is why the types {@code getDomain} has no branch for are kept 
rather than rejected: its fallthrough
+     * returns a domain covering every value, which is safe whatever the file 
holds, and the null count and the bloom
+     * filter still pay for themselves.
+     * <p>
+     * {@code TestParquetStatisticsDomains} pins every pair below against the 
real {@code getDomain}. The bloom half
+     * leaves no domain to inspect, so it is pinned where the harm shows up 
instead, on the rows a real read returns:
+     * {@code 
TestHudiEvolvedColumnPredicates.testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows}.
+     */
+    public static boolean hasComparableStatistics(Type domainType, 
PrimitiveType fileType)
+    {
+        PrimitiveTypeName primitiveType = fileType.getPrimitiveTypeName();
+        LogicalTypeAnnotation annotation = fileType.getLogicalTypeAnnotation();
+
+        if (BOOLEAN.equals(domainType)) {
+            return primitiveType == PrimitiveTypeName.BOOLEAN;
+        }
+        if (TINYINT.equals(domainType) || SMALLINT.equals(domainType) || 
INTEGER.equals(domainType)
+                || BIGINT.equals(domainType) || DATE.equals(domainType)) {
+            // asLong takes any integral box the statistics can hold, so the 
min/max side would read INT32 and INT64
+            // interchangeably. checkInBloomFilter would not: it hashes a 
bigint lookup at eight bytes and every
+            // narrower integral one at four, while parquet-mr hashed the 
column at its own width. A bigint over INT32
+            // then finds nothing in the filter and the row group is dropped, 
so require the same width.
+            // A decimal column reports its UNSCALED value though, which is 
the integer it stands for only at scale 0.
+            return (BIGINT.equals(domainType) ? primitiveType == INT64 : 
primitiveType == INT32) && !isScaledDecimal(annotation);
+        }
+        if (domainType instanceof DecimalType domainDecimal) {
+            // getShortDecimal and getLongDecimal rescale against the column's 
own annotation. Without one they read
+            // the raw value as an unscaled decimal at the DOMAIN's scale, so 
an int or a string that evolved into a
+            // decimal would be compared a factor of ten-to-the-scale off, or 
as raw UTF-8 bytes. The rescale itself
+            // throws NUMERIC_VALUE_OUT_OF_RANGE once a file bound no longer 
fits the domain at the new scale, which
+            // the predicate reports as the very corrupt-statistics failure 
this class exists to prevent. So keep
+            // exactly Hudi's own lossless widening, which cannot overflow: 
neither the integer digits nor the scale
+            // may shrink.
+            return isDecimalPrimitive(primitiveType) && annotation instanceof 
DecimalLogicalTypeAnnotation fileDecimal
+                    && fileDecimal.getPrecision() - fileDecimal.getScale() <= 
domainDecimal.getPrecision() - domainDecimal.getScale()
+                    && fileDecimal.getScale() <= domainDecimal.getScale();
+        }
+        if (REAL.equals(domainType)) {
+            return primitiveType == FLOAT;
+        }
+        if (DOUBLE.equals(domainType)) {
+            return primitiveType == PrimitiveTypeName.DOUBLE;
+        }
+        if (domainType instanceof VarcharType) {
+            // Both sides compare raw bytes, which is varchar's own ordering - 
unless the bytes are a decimal's
+            // big-endian two's complement, which orders nothing like the 
digits it prints as.
+            return (primitiveType == BINARY || primitiveType == 
FIXED_LEN_BYTE_ARRAY)
+                    && !(annotation instanceof DecimalLogicalTypeAnnotation);
+        }
+        if (domainType instanceof CharType || domainType instanceof 
VarbinaryType || domainType instanceof UuidType) {
+            // getDomain has no branch for any of these, so it falls through 
to a domain covering every value and only
+            // the null count prunes. checkInBloomFilter does have one for 
varbinary and uuid, hashing the lookup as
+            // raw bytes, so the column still has to be one parquet-mr hashed 
as bytes.
+            return primitiveType == BINARY || primitiveType == 
FIXED_LEN_BYTE_ARRAY;
+        }
+        if (domainType instanceof TimestampType) {
+            // INT96 is read from the binary statistics and INT64 from the 
long ones, but an INT64 column has to say
+            // which unit it counts in before its bounds mean anything.
+            return primitiveType == INT96
+                    || (primitiveType == INT64 && annotation instanceof 
TimestampLogicalTypeAnnotation timestampAnnotation && 
timestampAnnotation.getUnit() != null);
+        }
+        return false;
+    }
+
+    private static boolean isDecimalPrimitive(PrimitiveTypeName primitiveType)
+    {
+        return primitiveType == INT32 || primitiveType == INT64 || 
primitiveType == BINARY || primitiveType == FIXED_LEN_BYTE_ARRAY;
+    }
+
+    private static boolean isScaledDecimal(LogicalTypeAnnotation annotation)
+    {
+        return annotation instanceof DecimalLogicalTypeAnnotation 
decimalAnnotation && decimalAnnotation.getScale() != 0;
+    }
+}
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java
index fda63c8b4714..9b3ebc3836bc 100644
--- 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiConnectorParquetColumnNamesTest.java
@@ -16,6 +16,7 @@ package io.trino.plugin.hudi;
 import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer;
 import io.trino.plugin.hudi.testing.OmittedMetaColumnsHudiTablesInitializer;
 import io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer;
 import io.trino.testing.QueryRunner;
 import org.junit.jupiter.api.Test;
 
@@ -35,10 +36,12 @@ public class TestHudiConnectorParquetColumnNamesTest
                 .addConnectorProperty("hudi.parquet.use-column-names", "false")
                 // The resource tables all register the Hudi meta columns in 
the metastore, so their metastore
                 // ordinals already equal their physical ones and nothing here 
resolves a stale ordinal. The
-                // second fixture is the one whose metastore omits them.
+                // second fixture is the one whose metastore omits them, and 
the third is the type-evolution table
+                // TestHudiSmokeTest reads, carried here so its predicates are 
resolved positionally too.
                 .setDataLoader(new CompositeHudiTablesInitializer(
                         new ResourceHudiTablesInitializer(),
-                        new OmittedMetaColumnsHudiTablesInitializer()))
+                        new OmittedMetaColumnsHudiTablesInitializer(),
+                        new SchemaEvolutionHudiTablesInitializer()))
                 .build();
     }
 
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiEvolvedColumnPredicates.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiEvolvedColumnPredicates.java
new file mode 100644
index 000000000000..e609479092e7
--- /dev/null
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiEvolvedColumnPredicates.java
@@ -0,0 +1,387 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.airlift.slice.Slices;
+import io.trino.metastore.HiveType;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Types;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.TestingBaseFilePageSource.dynamicFilterOn;
+import static io.trino.plugin.hudi.TestingBaseFilePageSource.read;
+import static io.trino.plugin.hudi.TestingBaseFilePageSource.writeBaseFile;
+import static io.trino.spi.type.BigintType.BIGINT;
+import static io.trino.spi.type.DoubleType.DOUBLE;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.spi.type.VarcharType.VARCHAR;
+import static org.apache.hudi.common.model.HoodieRecord.HOODIE_META_COLUMNS;
+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;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Covers what a pushed-down predicate does to a base file written before the 
column it constrains was evolved.
+ * <p>
+ * Hudi lets a column's type widen and hive sync then reports the NEW type, 
while every base file written before the
+ * evolution keeps storing the old one. The parquet reader copes with that on 
its own -- {@code ColumnReaderFactory}
+ * decodes {@code FLOAT} into {@code DOUBLE} and {@code INT32} into {@code 
BIGINT}, and
+ * {@code ParquetTypeTranslator.createCoercer} handles the rest -- but the 
statistics do not: {@code
+ * TupleDomainParquetPredicate.getDomain} reads them as whatever the DOMAIN's 
type says, so a {@code DOUBLE} domain
+ * over a {@code FLOAT} column casts a {@code Float} to a {@code Double} and 
fails the whole split with {@code
+ * HUDI_BAD_DATA}. See apache/hudi#19457.
+ * <p>
+ * Two files, because that is the state one insert after an evolution leaves a 
table in: {@link #preEvolutionFile}
+ * stores every data column as the type it had BEFORE, {@link 
#postEvolutionFile} as the type it has after, and the
+ * handles carry the metastore's post-evolution types for both. A split over 
the first must drop its domain; a split
+ * over the second must keep it and go on pruning, which is what stops the 
guard from being written as "give up on
+ * this column".
+ * <p>
+ * Values grow with the row index so pruning stays observable: a predicate 
that survives pushdown reads fewer rows
+ * than the file holds, and one that was dropped reads all of them. Do not 
"simplify" that to asserting the matching
+ * rows alone -- both a working pushdown and no pushdown at all produce the 
same matching rows, since the connector's
+ * pushdown is an optimization and the engine re-applies the predicate above 
the scan.
+ */
+class TestHudiEvolvedColumnPredicates
+{
+    private static final String STABLE_COLUMN = "stable_int";
+    /** Written as parquet FLOAT before the evolution, DOUBLE after, reported 
by the metastore as double. */
+    private static final String FLOAT_TO_DOUBLE_COLUMN = "evolved_double";
+    /** Written as parquet INT32 before the evolution, INT64 after, reported 
by the metastore as bigint. */
+    private static final String INT_TO_BIGINT_COLUMN = "evolved_bigint";
+    /** Written as parquet INT32 before the evolution, a BINARY string after, 
reported by the metastore as string. */
+    private static final String INT_TO_VARCHAR_COLUMN = "evolved_varchar";
+
+    private static final int ROW_COUNT = 1000;
+    private static final long THRESHOLD = 900;
+    private static final int MATCHING_ROW_COUNT = (int) (ROW_COUNT - THRESHOLD 
- 1);
+    /** A value that exists in both files, for the equality predicates that 
reach the bloom filter. */
+    private static final long PRESENT_VALUE = 500;
+
+    @TempDir
+    static Path tempDir;
+
+    private static Path preEvolutionFile;
+    private static Path postEvolutionFile;
+
+    @BeforeAll
+    static void writeBaseFiles()
+            throws IOException
+    {
+        preEvolutionFile = tempDir.resolve("pre_evolution_base_file.parquet");
+        postEvolutionFile = 
tempDir.resolve("post_evolution_base_file.parquet");
+
+        // Bloom filters on the two integral columns, as a Hudi writer builds 
for every column set in
+        // parquet.bloom.filter.enabled#<column>. They are what makes an 
equality predicate on an evolved column
+        // dangerous rather than merely unhelpful: see 
testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows.
+        List<String> bloomFilterColumns = List.of(STABLE_COLUMN, 
INT_TO_BIGINT_COLUMN);
+        int preEvolutionRowGroups = writeBaseFile(preEvolutionFile, 
preEvolutionFileSchema(), ROW_COUNT, bloomFilterColumns,
+                (group, row) -> {
+                    appendMetaColumns(group, row);
+                    group.append(STABLE_COLUMN, row);
+                    group.append(FLOAT_TO_DOUBLE_COLUMN, (float) row);
+                    group.append(INT_TO_BIGINT_COLUMN, row);
+                    group.append(INT_TO_VARCHAR_COLUMN, row);
+                });
+        int postEvolutionRowGroups = writeBaseFile(postEvolutionFile, 
postEvolutionFileSchema(), ROW_COUNT, bloomFilterColumns,
+                (group, row) -> {
+                    appendMetaColumns(group, row);
+                    group.append(STABLE_COLUMN, row);
+                    group.append(FLOAT_TO_DOUBLE_COLUMN, (double) row);
+                    group.append(INT_TO_BIGINT_COLUMN, (long) row);
+                    group.append(INT_TO_VARCHAR_COLUMN, Integer.toString(row));
+                });
+
+        // With a single row group there would be nothing to prune and every 
"still prunes" assertion below would
+        // hold without proving anything, so assert the outcome rather than 
the writer knobs that produce it.
+        assertThat(preEvolutionRowGroups).as("row groups in the pre-evolution 
file").isGreaterThan(1);
+        assertThat(postEvolutionRowGroups).as("row groups in the 
post-evolution file").isGreaterThan(1);
+    }
+
+    /**
+     * The both-modes anchor. {@code column()} builds every handle on its 
physical ordinal, so the two values of
+     * {@code hudi.parquet.use-column-names} hand the guard the identical 
descriptor and the axis cannot discriminate
+     * anywhere in this class -- it is carried here alone, to pin that the 
guard runs after the resolution fork
+     * rather than inside one branch of it. Stale ordinals are {@code 
TestHudiPageSourceProviderTest}'s subject.
+     */
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void testPredicateOnFloatColumnEvolvedToDouble(boolean 
useParquetColumnNames)
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(FLOAT_TO_DOUBLE_COLUMN, 
HiveType.HIVE_DOUBLE, DOUBLE);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+
+        MaterializedResult result = read(preEvolutionFile, projection, 
greaterThanThreshold(evolved, DOUBLE, (double) THRESHOLD),
+                useParquetColumnNames, DynamicFilter.EMPTY);
+
+        // The domain cannot be matched against FLOAT statistics, so it is 
dropped and nothing is pruned. Before the
+        // fix this threw HUDI_BAD_DATA ("Corrupted statistics for column") 
instead of reading anything at all.
+        assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+        // The read itself promotes, so the rows the engine will filter carry 
the widened values
+        
assertThat(result.getMaterializedRows().get(7).getField(1)).as("promoted value 
of row 7").isEqualTo(7.0d);
+        assertThat(valuesOver(result, 1)).as("rows matching %s > %s", 
FLOAT_TO_DOUBLE_COLUMN, THRESHOLD).isEqualTo(MATCHING_ROW_COUNT);
+    }
+
+    @Test
+    public void testPredicateOnIntColumnEvolvedToVarchar()
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(INT_TO_VARCHAR_COLUMN, 
HiveType.HIVE_STRING, VARCHAR);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+
+        MaterializedResult result = read(preEvolutionFile, projection,
+                greaterThanThreshold(evolved, VARCHAR, 
Slices.utf8Slice("900")), false, DynamicFilter.EMPTY);
+
+        // A varchar domain over an INT32 column would cast an Integer to a 
Slice
+        assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+        
assertThat(result.getMaterializedRows().get(7).getField(1)).as("promoted value 
of row 7").isEqualTo("7");
+    }
+
+    /**
+     * apache/hudi#19457 comment thread: {@code asLong} takes an Integer as 
happily as a Long, so the min/max side
+     * reads this promotion perfectly -- and the guard still has to drop it, 
because {@code checkInBloomFilter} does
+     * not. See {@link 
#testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows} for what that costs 
when the pair
+     * is kept. Losing row-group pruning on pre-evolution files is the price;
+     * {@link #testThePostEvolutionFileStillPrunesTheSamePredicate} pins that 
files written after the evolution keep
+     * it.
+     */
+    @Test
+    public void testPredicateOnIntColumnEvolvedToBigintIsDropped()
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(INT_TO_BIGINT_COLUMN, 
HiveType.HIVE_LONG, BIGINT);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+
+        MaterializedResult result = read(preEvolutionFile, projection, 
greaterThanThreshold(evolved, BIGINT, THRESHOLD),
+                false, DynamicFilter.EMPTY);
+
+        assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+        assertThat(valuesOver(result, 1)).as("rows matching %s > %s", 
INT_TO_BIGINT_COLUMN, THRESHOLD).isEqualTo(MATCHING_ROW_COUNT);
+    }
+
+    /**
+     * The reason a bigint domain over an INT32 column has to be dropped even 
though its statistics read correctly.
+     * <p>
+     * {@code checkInBloomFilter} hashes the looked-up value at the DOMAIN's 
width, eight bytes for a bigint, while
+     * parquet-mr hashed the INT32 column at four. The lookup therefore finds 
nothing,
+     * {@code TupleDomainParquetPredicate.matches(BloomFilterStore, int)} 
reports no match, and every row group is
+     * dropped with the matching row still inside it -- no error, just missing 
rows. Trino reads bloom filters by
+     * default, so any Hudi table written with {@code 
parquet.bloom.filter.enabled#<column>=true} is exposed.
+     */
+    @Test
+    public void testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows()
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(INT_TO_BIGINT_COLUMN, 
HiveType.HIVE_LONG, BIGINT);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+
+        MaterializedResult result = read(preEvolutionFile, projection,
+                TupleDomain.withColumnDomains(Map.of(evolved, 
Domain.singleValue(BIGINT, PRESENT_VALUE))),
+                false, DynamicFilter.EMPTY);
+
+        // Dropped, so nothing is pruned -- and the row is there. With the 
pair kept the bloom filter answers "not
+        // present" for every row group and this reads 0 rows.
+        assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+        assertThat(valuesEqualTo(result, 1)).as("rows holding %s = %s", 
INT_TO_BIGINT_COLUMN, PRESENT_VALUE).isEqualTo(1);
+    }
+
+    /**
+     * The same predicate against a file written AFTER the evolution, where 
the column really is an INT64 and both
+     * the statistics and the bloom filter answer it. Without this the guard 
could be "never push down on a column
+     * the metastore widened" and every test above would still pass, while 
every table would lose pruning forever.
+     */
+    @Test
+    public void testThePostEvolutionFileStillPrunesTheSamePredicate()
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(INT_TO_BIGINT_COLUMN, 
HiveType.HIVE_LONG, BIGINT);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+        TupleDomain<HiveColumnHandle> predicate = 
TupleDomain.withColumnDomains(
+                Map.of(evolved, Domain.singleValue(BIGINT, PRESENT_VALUE)));
+
+        MaterializedResult result = read(postEvolutionFile, projection, 
predicate, false, DynamicFilter.EMPTY);
+
+        assertThat(result.getRowCount()).as("rows read out of %s", 
ROW_COUNT).isLessThan(ROW_COUNT);
+        assertThat(valuesEqualTo(result, 1)).as("rows holding %s = %s", 
INT_TO_BIGINT_COLUMN, PRESENT_VALUE).isEqualTo(1);
+
+        // ... and the range predicate the pre-evolution file could not prune 
on at all
+        MaterializedResult rangeResult = read(postEvolutionFile, projection,
+                greaterThanThreshold(evolved, BIGINT, THRESHOLD), false, 
DynamicFilter.EMPTY);
+        assertThat(rangeResult.getRowCount()).as("rows read out of %s", 
ROW_COUNT).isLessThan(ROW_COUNT);
+        assertThat(valuesOver(rangeResult, 1)).as("rows matching %s > %s after 
pruning", INT_TO_BIGINT_COLUMN, THRESHOLD)
+                .isEqualTo(MATCHING_ROW_COUNT);
+    }
+
+    @Test
+    public void testOnlyTheEvolvedColumnsDomainIsDropped()
+            throws Exception
+    {
+        HiveColumnHandle stable = column(STABLE_COLUMN, HiveType.HIVE_INT, 
INTEGER);
+        HiveColumnHandle evolved = column(FLOAT_TO_DOUBLE_COLUMN, 
HiveType.HIVE_DOUBLE, DOUBLE);
+        List<HiveColumnHandle> projection = List.of(stable, evolved);
+
+        MaterializedResult result = read(preEvolutionFile, projection,
+                greaterThanThreshold(stable, INTEGER, THRESHOLD)
+                        .intersect(greaterThanThreshold(evolved, DOUBLE, 
(double) THRESHOLD)),
+                false, DynamicFilter.EMPTY);
+
+        // One unusable domain must not cost the whole predicate its pushdown: 
the stable column's domain still
+        // prunes, which is only visible because reading everything and 
reading nothing are both wrong here.
+        assertThat(result.getRowCount()).as("rows read out of %s", 
ROW_COUNT).isLessThan(ROW_COUNT);
+        assertThat(valuesOver(result, 0)).as("rows matching %s > %s after 
pruning", STABLE_COLUMN, THRESHOLD).isEqualTo(MATCHING_ROW_COUNT);
+    }
+
+    @Test
+    public void testEvolvedColumnArrivingThroughADynamicFilter()
+            throws Exception
+    {
+        HiveColumnHandle evolved = column(FLOAT_TO_DOUBLE_COLUMN, 
HiveType.HIVE_DOUBLE, DOUBLE);
+        List<HiveColumnHandle> projection = List.of(column(STABLE_COLUMN, 
HiveType.HIVE_INT, INTEGER), evolved);
+
+        // A dynamic filter reaches getCombinedPredicate by its own route and 
its handles carry the metastore type
+        // just the same, so it has to be guarded on the same path
+        MaterializedResult result = read(preEvolutionFile, projection, 
TupleDomain.all(), false,
+                dynamicFilterOn(greaterThanThreshold(evolved, DOUBLE, (double) 
THRESHOLD)));
+
+        assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+    }
+
+    /**
+     * The control for the test above, which on its own cannot tell a guarded 
dynamic filter from one that never
+     * reached {@code getPushdownPredicate} at all -- both read every row. 
Putting the same shape of filter on a
+     * column the guard keeps has to prune, so a dynamic filter that quietly 
stopped being pushed down fails here.
+     */
+    @Test
+    public void testDynamicFilterOnTheStableColumnStillPrunes()
+            throws Exception
+    {
+        HiveColumnHandle stable = column(STABLE_COLUMN, HiveType.HIVE_INT, 
INTEGER);
+        List<HiveColumnHandle> projection = List.of(stable);
+
+        MaterializedResult result = read(preEvolutionFile, projection, 
TupleDomain.all(), false,
+                dynamicFilterOn(greaterThanThreshold(stable, INTEGER, 
THRESHOLD)));
+
+        assertThat(result.getRowCount()).as("rows read out of %s", 
ROW_COUNT).isLessThan(ROW_COUNT);
+        assertThat(valuesOver(result, 0)).as("rows matching %s > %s after 
pruning", STABLE_COLUMN, THRESHOLD).isEqualTo(MATCHING_ROW_COUNT);
+    }
+
+    private static void 
appendMetaColumns(org.apache.parquet.example.data.Group group, int row)
+    {
+        for (String metaColumn : HOODIE_META_COLUMNS) {
+            group.append(metaColumn, metaColumn + "_" + row);
+        }
+    }
+
+    /**
+     * The base file as it was written BEFORE the evolution: the five {@code 
_hoodie_*} meta columns followed by the
+     * data columns in their original types. The metastore column list the 
handles below model reports the widened
+     * types instead, which is the whole point of the fixture.
+     */
+    private static MessageType preEvolutionFileSchema()
+    {
+        return fileSchema(
+                Types.primitive(PrimitiveType.PrimitiveTypeName.FLOAT, 
OPTIONAL).named(FLOAT_TO_DOUBLE_COLUMN),
+                Types.primitive(INT32, OPTIONAL).named(INT_TO_BIGINT_COLUMN),
+                Types.primitive(INT32, OPTIONAL).named(INT_TO_VARCHAR_COLUMN));
+    }
+
+    /** The same table one insert later: every column now written as the type 
the metastore already reported. */
+    private static MessageType postEvolutionFileSchema()
+    {
+        return fileSchema(
+                Types.primitive(PrimitiveType.PrimitiveTypeName.DOUBLE, 
OPTIONAL).named(FLOAT_TO_DOUBLE_COLUMN),
+                Types.primitive(INT64, OPTIONAL).named(INT_TO_BIGINT_COLUMN),
+                Types.primitive(BINARY, 
OPTIONAL).as(LogicalTypeAnnotation.stringType()).named(INT_TO_VARCHAR_COLUMN));
+    }
+
+    private static MessageType fileSchema(org.apache.parquet.schema.Type... 
evolvedColumns)
+    {
+        List<org.apache.parquet.schema.Type> fields = new ArrayList<>();
+        for (String metaColumn : HOODIE_META_COLUMNS) {
+            fields.add(Types.primitive(BINARY, 
OPTIONAL).as(LogicalTypeAnnotation.stringType()).named(metaColumn));
+        }
+        fields.add(Types.primitive(INT32, OPTIONAL).named(STABLE_COLUMN));
+        fields.addAll(List.of(evolvedColumns));
+        return new MessageType("hudi_base_file", fields);
+    }
+
+    /**
+     * A handle as the metastore reports the column AFTER the evolution, on 
its physical ordinal. Both files lay the
+     * columns out identically, so one handle serves either.
+     */
+    private static HiveColumnHandle column(String columnName, HiveType 
hiveType, Type trinoType)
+    {
+        return createBaseColumn(columnName, physicalIndexOf(columnName), 
hiveType, trinoType,
+                HiveColumnHandle.ColumnType.REGULAR, Optional.empty());
+    }
+
+    private static int physicalIndexOf(String columnName)
+    {
+        List<org.apache.parquet.schema.Type> fields = 
preEvolutionFileSchema().getFields();
+        for (int i = 0; i < fields.size(); i++) {
+            if (fields.get(i).getName().equals(columnName)) {
+                return i;
+            }
+        }
+        throw new IllegalArgumentException("No such column in the fixture: " + 
columnName);
+    }
+
+    private static TupleDomain<HiveColumnHandle> 
greaterThanThreshold(HiveColumnHandle handle, Type type, Object threshold)
+    {
+        return TupleDomain.withColumnDomains(Map.of(handle,
+                Domain.create(ValueSet.ofRanges(Range.greaterThan(type, 
threshold)), false)));
+    }
+
+    /** Counts the rows whose {@code fieldIndex}-th field is over {@link 
#THRESHOLD}, whatever numeric type it read as. */
+    private static long valuesOver(MaterializedResult result, int fieldIndex)
+    {
+        return result.getMaterializedRows().stream()
+                .map(row -> row.getField(fieldIndex))
+                .filter(value -> value != null && ((Number) 
value).doubleValue() > THRESHOLD)
+                .count();
+    }
+
+    private static long valuesEqualTo(MaterializedResult result, int 
fieldIndex)
+    {
+        return result.getMaterializedRows().stream()
+                .map(row -> row.getField(fieldIndex))
+                .filter(value -> value != null && ((Number) value).longValue() 
== PRESENT_VALUE)
+                .count();
+    }
+}
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java
index a224f2ffb6c2..c677e1c5b2ce 100644
--- 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java
@@ -13,18 +13,9 @@
  */
 package io.trino.plugin.hudi;
 
-import io.trino.filesystem.local.LocalInputFile;
 import io.trino.metastore.HiveType;
-import io.trino.parquet.ParquetReaderOptions;
-import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
 import io.trino.plugin.hive.HiveColumnHandle;
 import io.trino.plugin.hive.HiveColumnProjectionInfo;
-import io.trino.plugin.hive.parquet.ParquetReaderConfig;
-import io.trino.plugin.hudi.file.HudiBaseFile;
-import io.trino.spi.SplitWeight;
-import io.trino.spi.connector.ColumnHandle;
-import io.trino.spi.connector.ConnectorPageSource;
-import io.trino.spi.connector.ConnectorSession;
 import io.trino.spi.connector.DynamicFilter;
 import io.trino.spi.predicate.Domain;
 import io.trino.spi.predicate.Range;
@@ -34,43 +25,30 @@ import io.trino.spi.type.BigintType;
 import io.trino.spi.type.RowType;
 import io.trino.spi.type.Type;
 import io.trino.testing.MaterializedResult;
-import io.trino.testing.TestingConnectorSession;
-import org.apache.parquet.conf.PlainParquetConfiguration;
-import org.apache.parquet.example.data.Group;
-import org.apache.parquet.example.data.simple.SimpleGroupFactory;
-import org.apache.parquet.hadoop.ParquetFileReader;
-import org.apache.parquet.hadoop.ParquetWriter;
-import org.apache.parquet.hadoop.example.ExampleParquetWriter;
-import org.apache.parquet.io.LocalOutputFile;
 import org.apache.parquet.schema.LogicalTypeAnnotation;
 import org.apache.parquet.schema.MessageType;
 import org.apache.parquet.schema.PrimitiveType;
 import org.apache.parquet.schema.Types;
-import org.joda.time.DateTimeZone;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import java.io.IOException;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.OptionalLong;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
 
 import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
-import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
 import static 
io.trino.plugin.hudi.HudiPageSourceProvider.remapColumnIndicesToPhysical;
 import static 
io.trino.plugin.hudi.HudiPageSourceProvider.remapPredicateColumnIndicesToPhysical;
+import static io.trino.plugin.hudi.TestingBaseFilePageSource.dynamicFilterOn;
+import static io.trino.plugin.hudi.TestingBaseFilePageSource.writeBaseFile;
 import static io.trino.spi.type.DoubleType.DOUBLE;
 import static io.trino.spi.type.IntegerType.INTEGER;
 import static io.trino.spi.type.VarcharType.VARCHAR;
-import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
 import static java.lang.Integer.parseInt;
 import static org.apache.hudi.common.model.HoodieRecord.HOODIE_META_COLUMNS;
 import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
@@ -113,35 +91,22 @@ class TestHudiPageSourceProviderTest
     private static Path baseFile;
 
     @BeforeAll
-    static void writeBaseFile()
+    static void writeFixture()
             throws IOException
     {
-        MessageType schema = hudiFileSchema(DATA_COLUMN_COUNT);
         baseFile = tempDir.resolve("base_file.parquet");
-        SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema);
-        try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new 
LocalOutputFile(baseFile))
-                .withType(schema)
-                .withConf(new PlainParquetConfiguration())
-                .withRowGroupSize(1024L)
-                .withPageSize(512)
-                .build()) {
-            for (int row = 0; row < ROW_COUNT; row++) {
-                Group group = groupFactory.newGroup();
-                for (String metaColumn : HOODIE_META_COLUMNS) {
-                    group.append(metaColumn, metaColumn + "_" + row);
-                }
-                for (int column = 0; column < DATA_COLUMN_COUNT; column++) {
-                    String columnName = "c" + column;
-                    group.append(columnName, 
columnName.equals(PREDICATE_COLUMN) ? row : row % 10);
-                }
-                writer.write(group);
+        int rowGroups = writeBaseFile(baseFile, 
hudiFileSchema(DATA_COLUMN_COUNT), ROW_COUNT, (group, row) -> {
+            for (String metaColumn : HOODIE_META_COLUMNS) {
+                group.append(metaColumn, metaColumn + "_" + row);
             }
-        }
-        // The writer flushes a row group whenever the buffered size is over 
withRowGroupSize, checked every
-        // parquet.page.size.row.check.min records (100 by default), which is 
what actually splits this file.
-        // Assert the outcome rather than the knobs: with a single row group 
there would be nothing to prune,
-        // and every test below would pass without proving anything.
-        assertThat(rowGroupCount(baseFile)).as("row groups 
written").isGreaterThan(1);
+            for (int column = 0; column < DATA_COLUMN_COUNT; column++) {
+                String columnName = "c" + column;
+                group.append(columnName, columnName.equals(PREDICATE_COLUMN) ? 
row : row % 10);
+            }
+        });
+        // Assert the outcome rather than the writer knobs: with a single row 
group there would be nothing to prune,
+        // and every reading test below would pass without proving anything.
+        assertThat(rowGroups).as("row groups written").isGreaterThan(1);
     }
 
     @Test
@@ -579,10 +544,6 @@ class TestHudiPageSourceProviderTest
                 .isEqualTo(byName.getMaterializedRows());
     }
 
-    /**
-     * Reads the whole base file through the page source the connector builds 
for a split with no log files, which
-     * is the only path on which it enables predicate pushdown.
-     */
     private static MaterializedResult read(
             List<HiveColumnHandle> projection,
             TupleDomain<HiveColumnHandle> predicate,
@@ -590,38 +551,7 @@ class TestHudiPageSourceProviderTest
             DynamicFilter dynamicFilter)
             throws Exception
     {
-        long fileSize = Files.size(baseFile);
-        HudiSplit split = new HudiSplit(
-                new HudiBaseFile(baseFile.toString(), 
baseFile.getFileName().toString(), fileSize, 0, 0, fileSize),
-                List.of(),
-                "000",
-                predicate,
-                List.of(),
-                SplitWeight.standard());
-        HudiSessionProperties sessionProperties = new HudiSessionProperties(
-                new 
HudiConfig().setUseParquetColumnNames(useParquetColumnNames),
-                new ParquetReaderConfig());
-        ConnectorSession session = TestingConnectorSession.builder()
-                .setPropertyMetadata(sessionProperties.getSessionProperties())
-                .build();
-
-        List<Type> types = 
projection.stream().map(HiveColumnHandle::getType).toList();
-        try (ConnectorPageSource pageSource = createPageSource(
-                session,
-                projection,
-                split,
-                new LocalInputFile(baseFile.toFile()),
-                baseFile.toString(),
-                0L,
-                fileSize,
-                OptionalLong.of(fileSize),
-                new FileFormatDataSourceStats(),
-                ParquetReaderOptions.builder().build(),
-                DateTimeZone.UTC,
-                dynamicFilter,
-                true)) {
-            return materializeSourceDataStream(session, pageSource, 
types).toTestTypes();
-        }
+        return TestingBaseFilePageSource.read(baseFile, projection, predicate, 
useParquetColumnNames, dynamicFilter);
     }
 
     /**
@@ -687,42 +617,6 @@ class TestHudiPageSourceProviderTest
                 Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 
THRESHOLD)), false)));
     }
 
-    private static DynamicFilter dynamicFilterOn(TupleDomain<HiveColumnHandle> 
predicate)
-    {
-        return new DynamicFilter()
-        {
-            @Override
-            public Set<ColumnHandle> getColumnsCovered()
-            {
-                return 
Set.copyOf(predicate.getDomains().orElseThrow().keySet());
-            }
-
-            @Override
-            public CompletableFuture<?> isBlocked()
-            {
-                return CompletableFuture.completedFuture(null);
-            }
-
-            @Override
-            public boolean isComplete()
-            {
-                return true;
-            }
-
-            @Override
-            public boolean isAwaitable()
-            {
-                return false;
-            }
-
-            @Override
-            public TupleDomain<ColumnHandle> getCurrentPredicate()
-            {
-                return predicate.transformKeys(ColumnHandle.class::cast);
-            }
-        };
-    }
-
     private static long matchingRowCount(MaterializedResult result, 
List<HiveColumnHandle> projection, String columnName)
     {
         int fieldIndex = projection.indexOf(dataColumn(columnName));
@@ -732,14 +626,6 @@ class TestHudiPageSourceProviderTest
                 .count();
     }
 
-    private static int rowGroupCount(Path path)
-            throws IOException
-    {
-        try (ParquetFileReader reader = ParquetFileReader.open(new 
org.apache.parquet.io.LocalInputFile(path))) {
-            return reader.getRowGroups().size();
-        }
-    }
-
     /**
      * Returns the single remapped handle carrying the given base column name.
      */
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
index 51d0ae1a375a..981191962f28 100644
--- a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
+++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
@@ -27,7 +27,9 @@ import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
 import io.trino.plugin.hive.HiveTimestampPrecision;
 import io.trino.plugin.hive.parquet.ParquetReaderConfig;
 import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer;
 import io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer;
 import io.trino.spi.SplitWeight;
 import io.trino.spi.connector.ConnectorPageSource;
 import io.trino.spi.connector.ConnectorSession;
@@ -71,6 +73,12 @@ import java.util.stream.Stream;
 import static io.trino.metastore.HiveType.HIVE_TIMESTAMP;
 import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
 import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.DOUBLE_THRESHOLD;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.FLOAT_TO_DOUBLE_COLUMN;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.INT_TO_BIGINT_COLUMN;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.INT_TO_VARCHAR_COLUMN;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.VARCHAR_THRESHOLD;
+import static 
io.trino.plugin.hudi.testing.SchemaEvolutionHudiTablesInitializer.expectedRowsFrom;
 import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
 import static 
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_COMPREHENSIVE_TYPES_V6_MOR;
 import static 
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_COMPREHENSIVE_TYPES_V8_MOR;
@@ -102,7 +110,14 @@ public class TestHudiSmokeTest
             throws Exception
     {
         HudiQueryRunner.Builder builder = HudiQueryRunner.builder()
-                .setDataLoader(new ResourceHudiTablesInitializer())
+                // The resource tables cover the connector's read surface; the 
second fixture is a table whose base
+                // file predates a type widening, which is what 
testPredicateOnColumnEvolvedFromFloatToDouble and its
+                // neighbours below read. Loading it here rather than from a 
suite of its own is what gets it run in
+                // both hudi.parquet.use-column-names modes, since 
TestHudiConnectorParquetColumnNamesTest reruns
+                // this class positionally.
+                .setDataLoader(new CompositeHudiTablesInitializer(
+                        new ResourceHudiTablesInitializer(),
+                        new SchemaEvolutionHudiTablesInitializer()))
                 .addConnectorProperties(getAdditionalHudiProperties());
         getBlobCacheProperties().ifPresent(cacheProperties -> builder
                 .withPlugin(new AlluxioBlobCachePlugin())
@@ -1326,41 +1341,66 @@ public class TestHudiSmokeTest
         assertQuery(session, actualQuery, expectedQuery);
     }
 
+    /**
+     * apache/hudi#19457: a predicate on a column whose type was widened after 
a base file was written used to fail
+     * the whole query with {@code Malformed Parquet file. Corrupted 
statistics for column ...}, because the domain
+     * carries the metastore's widened type while the file's statistics are 
still of the original one. The connector
+     * now leaves such a domain out of the parquet predicate, and the engine 
applies it above the scan as it always
+     * did.
+     * <p>
+     * Selecting the evolved column without constraining it was never 
affected, so the predicate has to be ON it --
+     * and the projection has to include it, or the domain would resolve to no 
descriptor and be discarded for an
+     * unrelated reason, which is exactly the shape that passes against the 
unfixed code.
+     * {@link #testReadingAnEvolvedColumnWithoutAPredicate} is the anchor 
showing the read path itself was always
+     * fine.
+     * <p>
+     * {@link TestHudiConnectorParquetColumnNamesTest} reruns this class with
+     * {@code hudi.parquet.use-column-names=false}. The guard runs on 
descriptor keys, after the resolution fork, so
+     * the two modes cannot diverge here; the second run is there because the 
issue was reported against both.
+     * {@code TestHudiEvolvedColumnPredicates} is where the pruning these 
queries cannot observe is asserted.
+     */
+    @Test
+    public void testPredicateOnColumnEvolvedFromFloatToDouble()
+    {
+        assertQuery(selectEvolvedColumnsWhere(FLOAT_TO_DOUBLE_COLUMN + " > " + 
DOUBLE_THRESHOLD), expectedRowsFrom(3));
+    }
+
+    @Test
+    public void testPredicateOnColumnEvolvedFromIntToVarchar()
+    {
+        assertQuery(selectEvolvedColumnsWhere("%s > 
'%s'".formatted(INT_TO_VARCHAR_COLUMN, VARCHAR_THRESHOLD)), 
expectedRowsFrom(4));
+    }
+
+    @Test
+    public void testReadingAnEvolvedColumnWithoutAPredicate()
+    {
+        // The anchor: widening is a read-path feature that already worked, so 
a regression here would mean the
+        // fixture stopped modelling an evolved table rather than that the 
guard misbehaved
+        assertQuery(selectEvolvedColumnsWhere("true"), expectedRowsFrom(1));
+    }
+
+    private static String selectEvolvedColumnsWhere(String predicate)
+    {
+        return "SELECT key, %s, %s, %s FROM %s WHERE %s ORDER BY 
key".formatted(
+                FLOAT_TO_DOUBLE_COLUMN, INT_TO_BIGINT_COLUMN, 
INT_TO_VARCHAR_COLUMN,
+                SchemaEvolutionHudiTablesInitializer.TABLE_NAME, predicate);
+    }
+
     private void testTimestampMicros(HiveTimestampPrecision 
timestampPrecision, LocalDateTime expected)
             throws Exception
     {
         File parquetFile = new 
File(Resources.getResource("long_timestamp.parquet").toURI());
         Type columnType = 
createTimestampType(timestampPrecision.getPrecision());
-        HudiSplit hudiSplit = new HudiSplit(
-                new HudiBaseFile(parquetFile.getPath(), parquetFile.getName(), 
parquetFile.length(), parquetFile.lastModified(), 0, parquetFile.length()),
-                ImmutableList.of(),
-                "000",
-                TupleDomain.all(),
-                ImmutableList.of(),
-                SplitWeight.standard());
 
-        HudiConfig config = new HudiConfig().setUseParquetColumnNames(false);
-        HudiSessionProperties sessionProperties = new 
HudiSessionProperties(config, new ParquetReaderConfig());
-        ConnectorSession session = TestingConnectorSession.builder()
-                .setPropertyMetadata(sessionProperties.getSessionProperties())
-                .build();
-
-        try (ConnectorPageSource pageSource = createPageSource(
-                session,
+        MaterializedResult result = TestingBaseFilePageSource.read(
+                parquetFile.toPath(),
                 List.of(createBaseColumn("created", 0, HIVE_TIMESTAMP, 
columnType, REGULAR, Optional.empty())),
-                hudiSplit,
-                new LocalInputFile(parquetFile),
-                parquetFile.getPath(),
-                0L,
-                parquetFile.length(),
-                OptionalLong.of(parquetFile.length()),
-                new FileFormatDataSourceStats(),
-                ParquetReaderOptions.builder().build(),
-                DateTimeZone.UTC, DynamicFilter.EMPTY, true)) {
-            MaterializedResult result = materializeSourceDataStream(session, 
pageSource, List.of(columnType)).toTestTypes();
-            assertThat(result.getMaterializedRows())
-                    .containsOnly(new MaterializedRow(List.of(expected)));
-        }
+                TupleDomain.all(),
+                false,
+                DynamicFilter.EMPTY);
+
+        assertThat(result.getMaterializedRows())
+                .containsOnly(new MaterializedRow(List.of(expected)));
     }
 
     private static Pattern getScanFilterInputRowsPattern(String 
tableIdentifier)
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestingBaseFilePageSource.java 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestingBaseFilePageSource.java
new file mode 100644
index 000000000000..d0eefa4f57df
--- /dev/null
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestingBaseFilePageSource.java
@@ -0,0 +1,201 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.filesystem.local.LocalInputFile;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.connector.ColumnHandle;
+import io.trino.spi.connector.ConnectorPageSource;
+import io.trino.spi.connector.ConnectorSession;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import io.trino.testing.TestingConnectorSession;
+import org.apache.parquet.conf.PlainParquetConfiguration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.LocalOutputFile;
+import org.apache.parquet.schema.MessageType;
+import org.joda.time.DateTimeZone;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.ObjIntConsumer;
+
+import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
+import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
+
+/**
+ * Drives {@link HudiPageSourceProvider#createPageSource} over a single base 
file, which is the only path on which
+ * the connector enables predicate pushdown - a split carrying log files takes 
the merge path instead.
+ * <p>
+ * {@code createPageSource} is package-private, so anything exercising it 
directly has to live in this package. Three
+ * suites do: {@link TestHudiPageSourceProviderTest} for column resolution, 
{@link TestHudiEvolvedColumnPredicates}
+ * for type evolution, and {@link TestHudiSmokeTest} for timestamp precision. 
They had a writer, a reader, a dynamic
+ * filter and a row-group counter each, byte-for-byte the same; keeping one 
copy is what stops the three from
+ * drifting into testing subtly different page sources.
+ */
+final class TestingBaseFilePageSource
+{
+    private TestingBaseFilePageSource() {}
+
+    /**
+     * Reads the whole file through the page source the connector builds for a 
split with no log files.
+     * <p>
+     * The predicate is handed over as the split's, the dynamic filter 
separately, exactly as
+     * {@code HudiPageSourceProvider} receives them, so both routes into 
{@code getCombinedPredicate} stay reachable
+     * from a test.
+     */
+    static MaterializedResult read(
+            Path baseFile,
+            List<HiveColumnHandle> projection,
+            TupleDomain<HiveColumnHandle> predicate,
+            boolean useParquetColumnNames,
+            DynamicFilter dynamicFilter)
+            throws Exception
+    {
+        long fileSize = Files.size(baseFile);
+        HudiSplit split = new HudiSplit(
+                new HudiBaseFile(baseFile.toString(), 
baseFile.getFileName().toString(), fileSize, 0, 0, fileSize),
+                List.of(),
+                "000",
+                predicate,
+                List.of(),
+                SplitWeight.standard());
+        HudiSessionProperties sessionProperties = new HudiSessionProperties(
+                new 
HudiConfig().setUseParquetColumnNames(useParquetColumnNames),
+                new ParquetReaderConfig());
+        ConnectorSession session = TestingConnectorSession.builder()
+                .setPropertyMetadata(sessionProperties.getSessionProperties())
+                .build();
+
+        List<Type> types = 
projection.stream().map(HiveColumnHandle::getType).toList();
+        try (ConnectorPageSource pageSource = createPageSource(
+                session,
+                projection,
+                split,
+                new LocalInputFile(baseFile.toFile()),
+                baseFile.toString(),
+                0L,
+                fileSize,
+                OptionalLong.of(fileSize),
+                new FileFormatDataSourceStats(),
+                ParquetReaderOptions.builder().build(),
+                DateTimeZone.UTC,
+                dynamicFilter,
+                true)) {
+            return materializeSourceDataStream(session, pageSource, 
types).toTestTypes();
+        }
+    }
+
+    /**
+     * Writes {@code rowCount} rows of {@code schema} to {@code baseFile} and 
returns how many row groups that took.
+     * <p>
+     * The writer flushes a row group whenever the buffered size passes {@code 
withRowGroupSize}, checked every
+     * {@code parquet.page.size.row.check.min} records (100 by default), so 
the small sizes below are what actually
+     * split the file. Callers assert on the returned count rather than on 
those knobs: with a single row group there
+     * would be nothing to prune and every "still prunes" assertion would hold 
without proving anything.
+     * <p>
+     * {@code bloomFilterColumns} names the columns to build a parquet bloom 
filter for, which is what a Hudi writer
+     * does for every column set in {@code 
parquet.bloom.filter.enabled#<column>}. Trino reads bloom filters by
+     * default, so a column listed here is checked by {@code 
TupleDomainParquetPredicate.matches(BloomFilterStore,
+     * int)} as well as by the statistics.
+     */
+    static int writeBaseFile(Path baseFile, MessageType schema, int rowCount, 
List<String> bloomFilterColumns, ObjIntConsumer<Group> fillRow)
+            throws IOException
+    {
+        SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema);
+        ExampleParquetWriter.Builder builder = 
ExampleParquetWriter.builder(new LocalOutputFile(baseFile))
+                .withType(schema)
+                .withConf(new PlainParquetConfiguration())
+                .withRowGroupSize(1024L)
+                .withPageSize(512);
+        for (String column : bloomFilterColumns) {
+            builder = builder.withBloomFilterEnabled(column, true);
+        }
+        try (ParquetWriter<Group> writer = builder.build()) {
+            for (int row = 0; row < rowCount; row++) {
+                Group group = groupFactory.newGroup();
+                fillRow.accept(group, row);
+                writer.write(group);
+            }
+        }
+        return rowGroupCount(baseFile);
+    }
+
+    static int writeBaseFile(Path baseFile, MessageType schema, int rowCount, 
ObjIntConsumer<Group> fillRow)
+            throws IOException
+    {
+        return writeBaseFile(baseFile, schema, rowCount, List.of(), fillRow);
+    }
+
+    static int rowGroupCount(Path path)
+            throws IOException
+    {
+        try (ParquetFileReader reader = ParquetFileReader.open(new 
org.apache.parquet.io.LocalInputFile(path))) {
+            return reader.getRowGroups().size();
+        }
+    }
+
+    /** A completed dynamic filter carrying {@code predicate}, the shape 
{@code getCombinedPredicate} intersects in. */
+    static DynamicFilter dynamicFilterOn(TupleDomain<HiveColumnHandle> 
predicate)
+    {
+        return new DynamicFilter()
+        {
+            @Override
+            public Set<ColumnHandle> getColumnsCovered()
+            {
+                return 
Set.copyOf(predicate.getDomains().orElseThrow().keySet());
+            }
+
+            @Override
+            public CompletableFuture<?> isBlocked()
+            {
+                return CompletableFuture.completedFuture(null);
+            }
+
+            @Override
+            public boolean isComplete()
+            {
+                return true;
+            }
+
+            @Override
+            public boolean isAwaitable()
+            {
+                return false;
+            }
+
+            @Override
+            public TupleDomain<ColumnHandle> getCurrentPredicate()
+            {
+                return predicate.transformKeys(ColumnHandle.class::cast);
+            }
+        };
+    }
+}
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SchemaEvolutionHudiTablesInitializer.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SchemaEvolutionHudiTablesInitializer.java
new file mode 100644
index 000000000000..3387c94f9474
--- /dev/null
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SchemaEvolutionHudiTablesInitializer.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_DOUBLE;
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+
+/**
+ * Creates a table whose base file was written BEFORE its columns were 
widened, while the metastore reports the types
+ * they were widened TO -- the state every unrewritten base file is in after a 
Hudi type evolution followed by a hive
+ * sync. The two halves are declared independently on purpose: {@link 
#avroSchema()} is what the write client puts in
+ * the file, {@link #dataColumns()} is what the metastore hands the connector, 
and only their disagreement is being
+ * modelled.
+ * <p>
+ * No schema-evolution write path is exercised, because none is needed to 
reproduce apache/hudi#19457 and because
+ * neither route can run here. A real evolution is produced either by a 
DataFrame append carrying a widened schema -
+ * schema-on-write, which is what apache/hudi#19457 was reported against - or 
by
+ * {@code ALTER TABLE ... ALTER COLUMN ... TYPE}, which needs {@code 
hoodie.schema.on.read.enable=true}:
+ * {@code AlterHoodieTableChangeColumnCommand} throws {@code 
HoodieAnalysisException} for any type change. Both want
+ * a Spark writer, so the end-to-end version of this fixture belongs with the 
docker-based integ tests rather than in
+ * a connector unit test; it is tracked in apache/hudi#19743.
+ * <p>
+ * The three widenings are the ones Hudi allows and the parquet reader can 
serve: {@code float -> double},
+ * {@code int -> long} and {@code int -> string}. Reading them has always 
worked. Putting a predicate on one is what
+ * used to fail the query, because the statistics in the file are still of the 
original type while the pushed-down
+ * domain carries the widened one.
+ * <p>
+ * Unlike {@link OmittedMetaColumnsHudiTablesInitializer} this fixture 
registers the Hudi meta columns, so every
+ * metastore ordinal equals its physical one and nothing here depends on how 
columns are resolved. What the two
+ * {@code hudi.parquet.use-column-names} modes must agree on is the TYPE 
handling alone; both run it, because
+ * {@code TestHudiSmokeTest} loads this fixture and {@code 
TestHudiConnectorParquetColumnNamesTest} reruns that class
+ * positionally.
+ * <p>
+ * A single bulk-insert commit, so the file slice has no log files: predicate 
pushdown is only enabled for
+ * base-file-only splits.
+ */
+public class SchemaEvolutionHudiTablesInitializer
+        extends AbstractMergerHudiTablesInitializer
+{
+    public static final String TABLE_NAME = "schema_evolved_mor";
+
+    /** Written as Avro {@code float}, reported by the metastore as {@code 
double}. */
+    public static final String FLOAT_TO_DOUBLE_COLUMN = "float_value";
+    /** Written as Avro {@code int}, reported by the metastore as {@code 
bigint}. */
+    public static final String INT_TO_BIGINT_COLUMN = "int_value";
+    /** Written as Avro {@code int}, reported by the metastore as {@code 
string}. */
+    public static final String INT_TO_VARCHAR_COLUMN = "string_value";
+
+    /** Sits between the third and the fourth row, so a predicate on it keeps 
some rows and drops others. */
+    public static final String DOUBLE_THRESHOLD = "1003.0";
+    public static final long BIGINT_THRESHOLD = 1003;
+    public static final String VARCHAR_THRESHOLD = "1003";
+
+    private static final int ROW_COUNT = 5;
+    private static final int BASE_VALUE = 1000;
+
+    public SchemaEvolutionHudiTablesInitializer()
+    {
+        super(TABLE_NAME);
+    }
+
+    @Override
+    protected List<Column> dataColumns()
+    {
+        return ImmutableList.of(
+                new Column(FLOAT_TO_DOUBLE_COLUMN, HIVE_DOUBLE, 
Optional.empty(), Map.of()),
+                new Column(INT_TO_BIGINT_COLUMN, HIVE_LONG, Optional.empty(), 
Map.of()),
+                new Column(INT_TO_VARCHAR_COLUMN, HIVE_STRING, 
Optional.empty(), Map.of()),
+                new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), 
Map.of()),
+                new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), 
Map.of()));
+    }
+
+    @Override
+    protected Schema avroSchema()
+    {
+        List<Schema.Field> fields = new ArrayList<>();
+        fields.add(new Schema.Field(FLOAT_TO_DOUBLE_COLUMN, 
Schema.create(Schema.Type.FLOAT)));
+        fields.add(new Schema.Field(INT_TO_BIGINT_COLUMN, 
Schema.create(Schema.Type.INT)));
+        fields.add(new Schema.Field(INT_TO_VARCHAR_COLUMN, 
Schema.create(Schema.Type.INT)));
+        fields.add(new Schema.Field(RECORD_KEY_FIELD, 
Schema.create(Schema.Type.STRING)));
+        fields.add(new Schema.Field(ORDERING_FIELD, 
Schema.create(Schema.Type.LONG)));
+        return Schema.createRecord(TABLE_NAME, null, null, false, fields);
+    }
+
+    @Override
+    protected void configureTableConfig(HoodieTableMetaClient.TableBuilder 
tableBuilder)
+    {
+        tableBuilder.setRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING);
+    }
+
+    @Override
+    protected void configureWriteConfig(HoodieWriteConfig.Builder 
writeConfigBuilder)
+    {
+        
writeConfigBuilder.withRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING);
+    }
+
+    @Override
+    protected void 
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+    {
+        Schema schema = avroSchema();
+        List<HoodieRecord<HoodieAvroPayload>> records = new ArrayList<>();
+        for (int row = 1; row <= ROW_COUNT; row++) {
+            records.add(record(schema, row));
+        }
+        // One commit only: a file slice with log files would take the merge 
path, which disables pushdown.
+        String commit = client.startCommit();
+        List<WriteStatus> statuses = client.bulkInsert(records, commit);
+        client.commit(commit, statuses);
+    }
+
+    /**
+     * The expected rows of {@code SELECT key, float_value, int_value, 
string_value ... WHERE <column> > <threshold>}
+     * for the rows whose index is over {@code firstMatchingRow}.
+     * <p>
+     * Every float value is an exact binary fraction, so widening it to double 
is lossless and the expected literals
+     * can be written out in full rather than compared with a tolerance.
+     */
+    public static String expectedRowsFrom(int firstMatchingRow)
+    {
+        List<String> rows = new ArrayList<>();
+        for (int row = firstMatchingRow; row <= ROW_COUNT; row++) {
+            rows.add("('k%s', CAST(%s AS DOUBLE), CAST(%s AS BIGINT), 
'%s')".formatted(
+                    row, floatValue(row), BASE_VALUE + row, BASE_VALUE + row));
+        }
+        return "VALUES " + String.join(", ", rows);
+    }
+
+    private static float floatValue(int row)
+    {
+        return BASE_VALUE + row + 0.5f;
+    }
+
+    private static HoodieRecord<HoodieAvroPayload> record(Schema schema, int 
row)
+    {
+        String key = "k" + row;
+        GenericRecord record = new GenericData.Record(schema);
+        record.put(FLOAT_TO_DOUBLE_COLUMN, floatValue(row));
+        record.put(INT_TO_BIGINT_COLUMN, BASE_VALUE + row);
+        record.put(INT_TO_VARCHAR_COLUMN, BASE_VALUE + row);
+        record.put(RECORD_KEY_FIELD, key);
+        record.put(ORDERING_FIELD, 100L);
+        return avroRecord(record, key);
+    }
+}
diff --git 
a/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestParquetStatisticsDomains.java
 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestParquetStatisticsDomains.java
new file mode 100644
index 000000000000..34c65e5c751a
--- /dev/null
+++ 
b/hudi-trino/src/test/java/io/trino/plugin/hudi/util/TestParquetStatisticsDomains.java
@@ -0,0 +1,390 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.util;
+
+import io.airlift.slice.Slices;
+import io.trino.parquet.ParquetDataSourceId;
+import io.trino.parquet.predicate.TupleDomainParquetPredicate;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.CharType;
+import io.trino.spi.type.DecimalType;
+import io.trino.spi.type.Int128;
+import io.trino.spi.type.Type;
+import io.trino.spi.type.UuidType;
+import io.trino.spi.type.VarbinaryType;
+import org.apache.parquet.column.ColumnDescriptor;
+import org.apache.parquet.column.statistics.Statistics;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import 
org.apache.parquet.schema.LogicalTypeAnnotation.DecimalLogicalTypeAnnotation;
+import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
+import org.apache.parquet.schema.Types;
+import org.joda.time.DateTimeZone;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static 
io.trino.plugin.hudi.util.ParquetStatisticsDomains.dropIncomparableDomains;
+import static 
io.trino.plugin.hudi.util.ParquetStatisticsDomains.hasComparableStatistics;
+import static 
io.trino.plugin.hudi.util.TestParquetStatisticsDomains.LibraryOutcome.ALL;
+import static 
io.trino.plugin.hudi.util.TestParquetStatisticsDomains.LibraryOutcome.NARROW;
+import static 
io.trino.plugin.hudi.util.TestParquetStatisticsDomains.LibraryOutcome.THROWS;
+import static io.trino.spi.type.BigintType.BIGINT;
+import static io.trino.spi.type.BooleanType.BOOLEAN;
+import static io.trino.spi.type.CharType.createCharType;
+import static io.trino.spi.type.DateType.DATE;
+import static io.trino.spi.type.DoubleType.DOUBLE;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.spi.type.RealType.REAL;
+import static io.trino.spi.type.TimestampType.TIMESTAMP_MILLIS;
+import static io.trino.spi.type.TinyintType.TINYINT;
+import static io.trino.spi.type.UuidType.UUID;
+import static io.trino.spi.type.VarbinaryType.VARBINARY;
+import static io.trino.spi.type.VarcharType.VARCHAR;
+import static java.nio.ByteOrder.LITTLE_ENDIAN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
+import static 
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.FLOAT;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
+import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT96;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Pins {@link ParquetStatisticsDomains#hasComparableStatistics} against the 
two methods it exists to protect. Every
+ * case below states BOTH what the guard decides and what the library actually 
does with the same pair, and the check
+ * is run against the real {@code TupleDomainParquetPredicate}, not a 
description of it. A Trino upgrade that moves a
+ * branch therefore fails here, where the mismatch is a line of test output, 
instead of in a query.
+ * <p>
+ * The three library outcomes are worth telling apart, because the guard 
exists for more than one reason:
+ * <ul>
+ *     <li>{@link LibraryOutcome#THROWS} - the cast fails and the whole split 
dies with {@code HUDI_BAD_DATA}. This
+ *     is apache/hudi#19457 as reported.</li>
+ *     <li>{@link LibraryOutcome#NARROW} on a pair the guard drops - far 
worse: a domain IS produced, from bytes that
+ *     mean something else entirely, and row groups get pruned on a comparison 
that is simply false. Nothing fails,
+ *     rows just go missing.</li>
+ *     <li>{@link LibraryOutcome#ALL} - {@code getDomain} declines the pair 
itself. Keeping such a pair is still
+ *     worth it when the bloom filter can answer it, and safe either way, 
because the declined domain covers every
+ *     value and only the column's null count prunes.</li>
+ * </ul>
+ * A kept pair must never be one the library reads out of a representation 
that means something else, so every
+ * cross-type kept pair states the exact domain {@code getDomain} has to come 
back with. Asserting only that the
+ * result is narrower than "any value" would pass a rescale that silently 
landed a factor of a hundred out.
+ * <p>
+ * {@code getDomain} is only half of what the guard has to mirror. The other 
half is {@code checkInBloomFilter},
+ * which dispatches on the domain type of its own accord and hashes the lookup 
at that type's width - the reason the
+ * integral arm insists on a matching physical width and the reason the 
byte-typed arm exists at all. Its bounds are
+ * not visible in a domain, so it is pinned where the harm shows up instead, 
on the row groups a real read returns:
+ * {@code 
TestHudiEvolvedColumnPredicates.testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows}.
+ */
+class TestParquetStatisticsDomains
+{
+    private static final ParquetDataSourceId DATA_SOURCE_ID = new 
ParquetDataSourceId("test");
+    private static final long VALUE_COUNT = 10;
+    private static final int MIN_VALUE = 1;
+    private static final int MAX_VALUE = 2;
+
+    enum LibraryOutcome
+    {
+        /** getDomain read the statistics and returned a range narrower than 
"any value". */
+        NARROW,
+        /** getDomain declined to use the statistics and returned a domain 
covering every value. */
+        ALL,
+        /** getDomain failed, which the connector reports as a 
corrupt-statistics error over the whole split. */
+        THROWS,
+    }
+
+    private record TypePair(String description, Type domainType, PrimitiveType 
fileType, boolean comparable,
+                            LibraryOutcome outcome, Optional<Domain> 
expectedDomain)
+    {
+        @Override
+        public String toString()
+        {
+            return description;
+        }
+    }
+
+    private static List<TypePair> typePairs()
+    {
+        return List.of(
+                // A column that never evolved: the domain's type is the one 
the file was written with
+                kept("boolean over BOOLEAN", BOOLEAN, 
plain(PrimitiveTypeName.BOOLEAN), NARROW),
+                kept("integer over INT32", INTEGER, plain(INT32), NARROW),
+                kept("bigint over INT64", BIGINT, plain(INT64), NARROW),
+                kept("tinyint over INT32", TINYINT, plain(INT32), NARROW),
+                kept("date over INT32 date", DATE, annotated(INT32, 
LogicalTypeAnnotation.dateType()), NARROW),
+                kept("real over FLOAT", REAL, plain(FLOAT), NARROW),
+                kept("double over DOUBLE", DOUBLE, 
plain(PrimitiveTypeName.DOUBLE), NARROW),
+                kept("varchar over BINARY string", VARCHAR, annotated(BINARY, 
LogicalTypeAnnotation.stringType()), NARROW),
+                kept("decimal(9,2) over INT32 decimal(9,2)", 
DecimalType.createDecimalType(9, 2), decimal(INT32, 9, 2), NARROW),
+                kept("timestamp over INT64 timestamp", TIMESTAMP_MILLIS, 
annotated(INT64, LogicalTypeAnnotation.timestampType(false, TimeUnit.MILLIS)), 
NARROW),
+                kept("timestamp over INT96", TIMESTAMP_MILLIS, plain(INT96), 
NARROW),
+                // A char, a varbinary and a uuid all fall past every 
getDomain branch, so it declines them and only
+                // the null count prunes. Keeping them costs nothing and buys 
back the bloom filter, which does have
+                // a branch for the last two.
+                kept("char over BINARY", createCharType(4), plain(BINARY), 
ALL),
+                kept("varbinary over BINARY", VARBINARY, plain(BINARY), ALL),
+                kept("uuid over FIXED_LEN_BYTE_ARRAY", UUID, 
plain(FIXED_LEN_BYTE_ARRAY), ALL),
+
+                // Promotions the statistics can answer, so pushdown must 
survive them. The domain each one has to
+                // come back with is spelled out: a rescale that lands a 
factor of ten out is still "narrow".
+                keptReading("decimal(20,2) -> decimal(38,4)", 
DecimalType.createDecimalType(38, 4), decimal(FIXED_LEN_BYTE_ARRAY, 20, 2),
+                        rangeOf(DecimalType.createDecimalType(38, 4), 
Int128.valueOf(100), Int128.valueOf(200))),
+                keptReading("decimal(9,2) over BINARY decimal(9,2)", 
DecimalType.createDecimalType(9, 2), decimal(BINARY, 9, 2),
+                        rangeOf(DecimalType.createDecimalType(9, 2), (long) 
MIN_VALUE, (long) MAX_VALUE)),
+                keptReading("integer over a zero-scale INT32 decimal", 
INTEGER, decimal(INT32, 9, 0),
+                        rangeOf(INTEGER, (long) MIN_VALUE, (long) MAX_VALUE)),
+                // Every table synced with hive_sync.support_timestamp=false 
carries this pair: HiveSchemaUtil maps
+                // TIMESTAMP to BIGINT, so the metastore says bigint while the 
file stays an annotated INT64.
+                keptReading("bigint over INT64 timestamp(micros)", BIGINT, 
annotated(INT64, LogicalTypeAnnotation.timestampType(true, TimeUnit.MICROS)),
+                        rangeOf(BIGINT, (long) MIN_VALUE, (long) MAX_VALUE)),
+                keptReading("varchar over a plain FIXED_LEN_BYTE_ARRAY", 
VARCHAR, plain(FIXED_LEN_BYTE_ARRAY),
+                        rangeOf(VARCHAR, sliceOf(plain(FIXED_LEN_BYTE_ARRAY), 
MIN_VALUE), sliceOf(plain(FIXED_LEN_BYTE_ARRAY), MAX_VALUE))),
+
+                // Promotions the min/max side reads correctly but the BLOOM 
side does not. checkInBloomFilter hashes
+                // the lookup at the domain's width, parquet-mr hashed the 
column at the file's, so the filter misses
+                // and the row group is dropped with its rows still in it. See 
apache/hudi#19457 and trinodb/trino#30544;
+                // 
TestHudiEvolvedColumnPredicates.testEqualityOnAnIntColumnEvolvedToBigintStillFindsItsRows
 pins the mechanism.
+                dropped("int -> long", BIGINT, plain(INT32), NARROW),
+                dropped("long -> int", INTEGER, plain(INT64), NARROW),
+                // Hudi rejects this evolution as lossy 
(HoodieSchemaCompatibilityChecker lets neither the integer
+                // digits nor the scale shrink) and so does the guard: the 
rescale in getShortDecimal multiplies by a
+                // hundred, which throws NUMERIC_VALUE_OUT_OF_RANGE for any 
file bound over 9999999 -- read as
+                // "Corrupted statistics", the very failure this class 
removes. Small bounds hide that, hence the drop
+                // is on the type pair rather than on the values.
+                dropped("decimal(9,2) -> decimal(9,4), lossy", 
DecimalType.createDecimalType(9, 4), decimal(INT32, 9, 2), NARROW),
+
+                // Promotions that fail the split today: apache/hudi#19457 and 
its neighbours
+                dropped("float -> double", DOUBLE, plain(FLOAT), THROWS),
+                dropped("int -> double", DOUBLE, plain(INT32), THROWS),
+                dropped("long -> double", DOUBLE, plain(INT64), THROWS),
+                dropped("int -> float", REAL, plain(INT32), THROWS),
+                dropped("int -> string", VARCHAR, plain(INT32), THROWS),
+                dropped("long -> string", VARCHAR, plain(INT64), THROWS),
+                dropped("float -> string", VARCHAR, plain(FLOAT), THROWS),
+                dropped("double -> string", VARCHAR, 
plain(PrimitiveTypeName.DOUBLE), THROWS),
+                dropped("string -> date", DATE, annotated(BINARY, 
LogicalTypeAnnotation.stringType()), THROWS),
+
+                // Promotions that silently prune on a comparison that means 
nothing, which is why the guard cannot
+                // be a try/catch around the cast
+                dropped("decimal -> string", VARCHAR, 
decimal(FIXED_LEN_BYTE_ARRAY, 20, 2), NARROW),
+                dropped("string -> decimal", DecimalType.createDecimalType(9, 
2), annotated(BINARY, LogicalTypeAnnotation.stringType()), NARROW),
+                dropped("int -> decimal", DecimalType.createDecimalType(9, 2), 
plain(INT32), NARROW),
+                dropped("integer over a scaled INT32 decimal", INTEGER, 
decimal(INT32, 9, 2), NARROW),
+
+                // Pairs getDomain declines on its own. Dropping costs nothing 
on the min/max side, but a varbinary
+                // over an INT32 column would still be hashed as raw bytes 
against a filter built from four-byte
+                // integer hashes, so the guard has to drop it rather than 
lean on getDomain declining.
+                dropped("timestamp over an unannotated INT64", 
TIMESTAMP_MILLIS, plain(INT64), ALL),
+                dropped("varbinary over INT32", VARBINARY, plain(INT32), ALL));
+    }
+
+    @ParameterizedTest
+    @MethodSource("typePairs")
+    public void testGuardMatchesTheParquetPredicate(TypePair pair)
+            throws Exception
+    {
+        assertThat(hasComparableStatistics(pair.domainType(), pair.fileType()))
+                .as("guard verdict for %s", pair)
+                .isEqualTo(pair.comparable());
+
+        ColumnDescriptor descriptor = descriptorOf(pair.fileType());
+        Statistics<?> statistics = statisticsOf(pair.fileType());
+        if (pair.outcome() == THROWS) {
+            assertThatThrownBy(() -> 
TupleDomainParquetPredicate.getDomain(descriptor, pair.domainType(), 
VALUE_COUNT, statistics, DATA_SOURCE_ID, DateTimeZone.UTC))
+                    .as("getDomain for %s", pair)
+                    .hasMessageContaining("Corrupted statistics");
+            return;
+        }
+
+        Domain domain = TupleDomainParquetPredicate.getDomain(descriptor, 
pair.domainType(), VALUE_COUNT, statistics, DATA_SOURCE_ID, DateTimeZone.UTC);
+        assertThat(domain.getValues().isAll())
+                .as("getDomain for %s returned %s", pair, domain)
+                .isEqualTo(pair.outcome() == ALL);
+        pair.expectedDomain().ifPresent(expected -> assertThat(domain)
+                .as("bounds getDomain read for %s", pair)
+                .isEqualTo(expected));
+    }
+
+    @ParameterizedTest
+    @MethodSource("typePairs")
+    public void testNoKeptPairIsMisread(TypePair pair)
+    {
+        // The invariant behind the whole table: a false negative only costs 
pruning, but a false positive is either a
+        // failed query or a wrong one. So nothing may be kept that the 
library cannot read -- either it produces a
+        // real range out of the file's own bytes, or it declines the 
statistics and leaves the null count to prune.
+        if (!hasComparableStatistics(pair.domainType(), pair.fileType())) {
+            return;
+        }
+        assertThat(pair.outcome()).as("library outcome for the kept pair %s", 
pair).isIn(NARROW, ALL);
+        // A pair getDomain declines is worth keeping only when something else 
pays for it: the bloom filter, which
+        // has a branch for varbinary and uuid, and the null count, which 
every type gets. Keeping a declined pair
+        // anywhere else would be a guard that mirrors getDomain's dispatch 
wrongly rather than deliberately.
+        if (pair.outcome() == ALL) {
+            assertThat(pair.domainType())
+                    .as("declined pair %s is kept only for its bloom filter 
and null count", pair)
+                    .isInstanceOfAny(CharType.class, VarbinaryType.class, 
UuidType.class);
+        }
+    }
+
+    @Test
+    public void testAllAndNonePassThroughUntouched()
+    {
+        
assertThat(dropIncomparableDomains(TupleDomain.all())).isEqualTo(TupleDomain.all());
+        
assertThat(dropIncomparableDomains(TupleDomain.none())).isEqualTo(TupleDomain.none());
+    }
+
+    @Test
+    public void testOnlyTheIncomparableDomainIsDropped()
+    {
+        // Distinct names on purpose: a ColumnDescriptor is keyed by its path, 
so two columns sharing one name would
+        // collapse into a single map entry and the test would pass without 
ever exercising the filtering
+        ColumnDescriptor evolved = descriptorNamed("evolved", FLOAT);
+        ColumnDescriptor stable = descriptorNamed("stable", INT32);
+        Domain doubleDomain = Domain.singleValue(DOUBLE, 1.0d);
+        Domain intDomain = Domain.singleValue(INTEGER, 1L);
+
+        TupleDomain<ColumnDescriptor> filtered = dropIncomparableDomains(
+                TupleDomain.withColumnDomains(Map.of(evolved, doubleDomain, 
stable, intDomain)));
+
+        
assertThat(filtered.getDomains().orElseThrow()).containsExactly(Map.entry(stable,
 intDomain));
+    }
+
+    @Test
+    public void testAPredicateWithNothingToDropIsReturnedAsIs()
+    {
+        TupleDomain<ColumnDescriptor> predicate = 
TupleDomain.withColumnDomains(
+                Map.of(descriptorOf(plain(INT32)), Domain.singleValue(INTEGER, 
1L)));
+
+        assertThat(dropIncomparableDomains(predicate)).isSameAs(predicate);
+    }
+
+    @Test
+    public void testDroppingEveryDomainLeavesAnUnconstrainedPredicate()
+    {
+        TupleDomain<ColumnDescriptor> predicate = 
TupleDomain.withColumnDomains(
+                Map.of(descriptorOf(plain(FLOAT)), Domain.singleValue(DOUBLE, 
1.0d)));
+
+        // Not TupleDomain.none(): dropping means "do not prune on this", 
never "this matches nothing"
+        assertThat(dropIncomparableDomains(predicate).isAll()).as("everything 
dropped").isTrue();
+    }
+
+    private static TypePair kept(String description, Type domainType, 
PrimitiveType fileType, LibraryOutcome outcome)
+    {
+        return new TypePair(description, domainType, fileType, true, outcome, 
Optional.empty());
+    }
+
+    /** A kept pair whose domain type is not the file's own, so the exact 
bounds it reads are worth pinning. */
+    private static TypePair keptReading(String description, Type domainType, 
PrimitiveType fileType, Domain expectedDomain)
+    {
+        return new TypePair(description, domainType, fileType, true, NARROW, 
Optional.of(expectedDomain));
+    }
+
+    private static TypePair dropped(String description, Type domainType, 
PrimitiveType fileType, LibraryOutcome outcome)
+    {
+        return new TypePair(description, domainType, fileType, false, outcome, 
Optional.empty());
+    }
+
+    /** The statistics fixture holds {@link #MIN_VALUE} and {@link 
#MAX_VALUE}, never null, so nulls are not allowed. */
+    private static Domain rangeOf(Type type, Object min, Object max)
+    {
+        return Domain.create(ValueSet.ofRanges(Range.range(type, min, true, 
max, true)), false);
+    }
+
+    private static PrimitiveType plain(PrimitiveTypeName primitiveTypeName)
+    {
+        if (primitiveTypeName == FIXED_LEN_BYTE_ARRAY) {
+            return Types.primitive(primitiveTypeName, 
OPTIONAL).length(16).named("c");
+        }
+        return Types.primitive(primitiveTypeName, OPTIONAL).named("c");
+    }
+
+    private static PrimitiveType annotated(PrimitiveTypeName 
primitiveTypeName, LogicalTypeAnnotation annotation)
+    {
+        return Types.primitive(primitiveTypeName, 
OPTIONAL).as(annotation).named("c");
+    }
+
+    private static PrimitiveType decimal(PrimitiveTypeName primitiveTypeName, 
int precision, int scale)
+    {
+        Types.PrimitiveBuilder<PrimitiveType> builder = 
Types.primitive(primitiveTypeName, OPTIONAL);
+        if (primitiveTypeName == FIXED_LEN_BYTE_ARRAY) {
+            builder = builder.length(16);
+        }
+        return builder.as(LogicalTypeAnnotation.decimalType(scale, 
precision)).named("c");
+    }
+
+    private static ColumnDescriptor descriptorOf(PrimitiveType fileType)
+    {
+        return new ColumnDescriptor(new String[] {fileType.getName()}, 
fileType, 0, 1);
+    }
+
+    private static ColumnDescriptor descriptorNamed(String name, 
PrimitiveTypeName primitiveTypeName)
+    {
+        return descriptorOf(Types.primitive(primitiveTypeName, 
OPTIONAL).named(name));
+    }
+
+    private static io.airlift.slice.Slice sliceOf(PrimitiveType fileType, int 
value)
+    {
+        return Slices.wrappedBuffer(statisticsBytes(fileType, value));
+    }
+
+    /**
+     * Statistics holding a small, non-degenerate range, so that a pair the 
library CAN read produces a domain
+     * narrower than "any value" and the {@link LibraryOutcome#NARROW} cases 
stay distinguishable from
+     * {@link LibraryOutcome#ALL}. Two nearby values also keep every integral 
type clear of
+     * {@code isStatisticsOverflow}, which would otherwise widen tinyint back 
to everything.
+     */
+    private static Statistics<?> statisticsOf(PrimitiveType fileType)
+    {
+        return Statistics.getBuilderForReading(fileType)
+                .withMin(statisticsBytes(fileType, MIN_VALUE))
+                .withMax(statisticsBytes(fileType, MAX_VALUE))
+                .withNumNulls(0)
+                .build();
+    }
+
+    private static byte[] statisticsBytes(PrimitiveType fileType, int value)
+    {
+        return switch (fileType.getPrimitiveTypeName()) {
+            // Both bounds false, so the boolean branch reports "only false" 
rather than "true and false", which it
+            // would report as every value
+            case BOOLEAN -> new byte[] {0};
+            case INT32 -> 
ByteBuffer.allocate(4).order(LITTLE_ENDIAN).putInt(value).array();
+            case INT64 -> 
ByteBuffer.allocate(8).order(LITTLE_ENDIAN).putLong(value).array();
+            case FLOAT -> 
ByteBuffer.allocate(4).order(LITTLE_ENDIAN).putFloat(value).array();
+            case DOUBLE -> 
ByteBuffer.allocate(8).order(LITTLE_ENDIAN).putDouble(value).array();
+            // INT96 statistics are only usable when the bounds are equal 
(PARQUET-1065), so ignore the value:
+            // 8 bytes of nanos-within-the-day followed by the julian day of 
the epoch
+            case INT96 -> 
ByteBuffer.allocate(12).order(LITTLE_ENDIAN).putLong(0).putInt(2440588).array();
+            // A decimal's unscaled value is big-endian two's complement 
whichever physical type carries it, so a
+            // BINARY decimal cannot reuse the digits a BINARY string is 
written as
+            case BINARY -> fileType.getLogicalTypeAnnotation() instanceof 
DecimalLogicalTypeAnnotation
+                    ? new byte[] {(byte) value}
+                    : Integer.toString(value).getBytes(UTF_8);
+            case FIXED_LEN_BYTE_ARRAY -> 
ByteBuffer.allocate(fileType.getTypeLength()).put(fileType.getTypeLength() - 1, 
(byte) value).array();
+        };
+    }
+}

Reply via email to