From a078b0f7bcf844f430542ab3e9424bb714d87dba Mon Sep 17 00:00:00 2001 From: Anupam Yadav Date: Wed, 22 Apr 2026 00:20:24 +0000 Subject: [PATCH] API: Fix Identity projection for mismatched transform types (#15502) Identity.project() and projectStrict() delegate to projectStrict() which creates an unbound predicate with the literal from the input predicate. When the predicate term is a transform (e.g. hours(ts) = 490674), the literal type (integer) does not match the partition field type (timestamptz), causing a ValidationException when the unbound predicate is later bound to the partition schema. Fix: Return null from projectStrict() when the predicate term is not a BoundReference, indicating the identity transform cannot project transform-based predicates. This causes the projection to fall back to alwaysTrue (inclusive) or alwaysFalse (strict), which is correct. --- .../apache/iceberg/transforms/Identity.java | 5 ++++ .../iceberg/transforms/TestProjection.java | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/api/src/main/java/org/apache/iceberg/transforms/Identity.java b/api/src/main/java/org/apache/iceberg/transforms/Identity.java index f52e63633578..99489f939f0f 100644 --- a/api/src/main/java/org/apache/iceberg/transforms/Identity.java +++ b/api/src/main/java/org/apache/iceberg/transforms/Identity.java @@ -21,6 +21,7 @@ import java.io.ObjectStreamException; import java.util.Set; import org.apache.iceberg.expressions.BoundPredicate; +import org.apache.iceberg.expressions.BoundReference; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.expressions.UnboundPredicate; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -146,6 +147,10 @@ public UnboundPredicate project(String name, BoundPredicate predicate) { @Override public UnboundPredicate projectStrict(String name, BoundPredicate predicate) { + if (!(predicate.term() instanceof BoundReference)) { + return null; + } + if (predicate.isUnaryPredicate()) { return Expressions.predicate(predicate.op(), name); } else if (predicate.isLiteralPredicate()) { diff --git a/api/src/test/java/org/apache/iceberg/transforms/TestProjection.java b/api/src/test/java/org/apache/iceberg/transforms/TestProjection.java index f6c699f7b616..2ad0cee80aed 100644 --- a/api/src/test/java/org/apache/iceberg/transforms/TestProjection.java +++ b/api/src/test/java/org/apache/iceberg/transforms/TestProjection.java @@ -394,4 +394,30 @@ public void testProjectionNames() { Projections.inclusive(partitionSpec).project(equal(truncate("string", 10), "abc")); assertThat(predicate.ref().name()).isEqualTo("string_trunc"); } + + @Test + public void testIdentityProjectionWithTransformPredicate() { + // Regression test for https://github.com/apache/iceberg/issues/15502 + // Identity-partitioned timestamptz field filtered with hours() should not throw + // ValidationException when projecting the predicate. + Schema schema = + new Schema( + required(1, "id", Types.LongType.get()), + required(2, "ts", Types.TimestampType.withZone())); + + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("ts").build(); + + // hours(ts) = 490674 produces an integer literal that cannot bind to timestamptz. + // Without the fix, projectStrict() attempts to create an UnboundPredicate with the + // integer literal for a timestamptz field, which fails during binding. + Expression hourFilter = equal(hour("ts"), 490674); + + // Inclusive projection: cannot project → falls back to alwaysTrue + Expression projected = Projections.inclusive(spec).project(hourFilter); + assertThat(projected).isEqualTo(Expressions.alwaysTrue()); + + // Strict projection: cannot project → falls back to alwaysFalse + Expression strictProjected = Projections.strict(spec).project(hourFilter); + assertThat(strictProjected).isEqualTo(Expressions.alwaysFalse()); + } }