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

szehon-ho pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 23774790e6 Spark 4.2: Push down opaque partition predicates (#17956)
23774790e6 is described below

commit 23774790e6928610e4e9ef7192373eb68b20b0e2
Author: Szehon Ho <[email protected]>
AuthorDate: Tue Sep 22 16:10:14 2026 -0700

    Spark 4.2: Push down opaque partition predicates (#17956)
---
 .../iceberg/spark/source/BaseSparkScanBuilder.java |  27 ++-
 .../iceberg/spark/source/SparkBatchQueryScan.java  |  15 +-
 .../iceberg/spark/source/SparkCopyOnWriteScan.java |  15 +-
 .../spark/source/SparkIncrementalAppendScan.java   |  15 +-
 .../spark/source/SparkPartitioningAwareScan.java   | 125 +++++++++++++-
 .../spark/source/SparkRuntimeFilterableScan.java   |  13 +-
 .../iceberg/spark/source/SparkScanBuilder.java     |   8 +
 .../iceberg/spark/source/StructInternalRow.java    |   6 +-
 .../iceberg/spark/source/TestFilteredScan.java     |  90 +++++++++-
 .../iceberg/spark/sql/TestFilterPushDown.java      | 191 +++++++++++++++++++++
 10 files changed, 490 insertions(+), 15 deletions(-)

diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java
index be34eaeed5..853c6059ae 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java
@@ -44,6 +44,7 @@ import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.TypeUtil.GetID;
 import org.apache.iceberg.types.Types;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.expressions.filter.Predicate;
 import org.apache.spark.sql.connector.read.ScanBuilder;
 import org.apache.spark.sql.types.StructField;
@@ -62,7 +63,6 @@ import org.slf4j.LoggerFactory;
 abstract class BaseSparkScanBuilder implements ScanBuilder {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(BaseSparkScanBuilder.class);
-  private static final Predicate[] NO_PREDICATES = new Predicate[0];
 
   private final SparkSession spark;
   private final Table table;
@@ -71,10 +71,11 @@ abstract class BaseSparkScanBuilder implements ScanBuilder {
   private final boolean caseSensitive;
   private final Set<String> metaFieldNames = Sets.newLinkedHashSet();
   private final InMemoryMetricsReporter metricsReporter = new 
InMemoryMetricsReporter();
+  private final List<PartitionPredicate> partitionPredicates = 
Lists.newArrayList();
+  private final List<Predicate> pushedPredicates = Lists.newArrayList();
+  private final List<Expression> filters = Lists.newArrayList();
 
   private Schema projection;
-  private List<Expression> filters = Lists.newArrayList();
-  private Predicate[] pushedPredicates = NO_PREDICATES;
   private Integer limit = null;
 
   protected BaseSparkScanBuilder(
@@ -119,6 +120,10 @@ abstract class BaseSparkScanBuilder implements ScanBuilder 
{
     return filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and);
   }
 
+  protected List<PartitionPredicate> partitionPredicates() {
+    return partitionPredicates;
+  }
+
   protected InMemoryMetricsReporter metricsReporter() {
     return metricsReporter;
   }
@@ -156,6 +161,16 @@ abstract class BaseSparkScanBuilder implements ScanBuilder 
{
     List<Predicate> postScanPredicates = 
Lists.newArrayListWithExpectedSize(predicates.length);
 
     for (Predicate predicate : predicates) {
+      if (predicate instanceof PartitionPredicate partitionPredicate) {
+        partitionPredicates.add(partitionPredicate);
+        pushablePredicates.add(predicate);
+
+        // TODO: Currently, all partition predicates are returned to Spark for 
re-evaluation.
+        // Optimize by returning only predicates that cannot be evaluated 
against all scanned specs.
+        postScanPredicates.add(predicate);
+        continue;
+      }
+
       try {
         Expression expr = SparkV2Filters.convert(predicate);
 
@@ -178,15 +193,15 @@ abstract class BaseSparkScanBuilder implements 
ScanBuilder {
       }
     }
 
-    this.filters = expressions;
-    this.pushedPredicates = pushablePredicates.toArray(new Predicate[0]);
+    this.filters.addAll(expressions);
+    this.pushedPredicates.addAll(pushablePredicates);
 
     return postScanPredicates.toArray(new Predicate[0]);
   }
 
   // logic necessary for SupportsPushDownV2Filters
   public Predicate[] pushedPredicates() {
-    return pushedPredicates;
+    return pushedPredicates.toArray(new Predicate[0]);
   }
 
   // logic necessary for SupportsPushDownLimit
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
index 814ca410d1..a7bfa9ecc0 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
@@ -31,6 +31,7 @@ import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.metrics.ScanReport;
 import org.apache.iceberg.spark.SparkReadConf;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.read.Statistics;
 
 class SparkBatchQueryScan extends SparkRuntimeFilterableScan {
@@ -48,8 +49,18 @@ class SparkBatchQueryScan extends SparkRuntimeFilterableScan 
{
       SparkReadConf readConf,
       Schema projection,
       List<Expression> filters,
+      List<PartitionPredicate> partitionPredicates,
       Supplier<ScanReport> scanReportSupplier) {
-    super(spark, table, schema, scan, readConf, projection, filters, 
scanReportSupplier);
+    super(
+        spark,
+        table,
+        schema,
+        scan,
+        readConf,
+        projection,
+        filters,
+        partitionPredicates,
+        scanReportSupplier);
     this.snapshot = snapshot;
     this.branch = branch;
   }
@@ -80,6 +91,7 @@ class SparkBatchQueryScan extends SparkRuntimeFilterableScan {
         && Objects.equals(branch, that.branch)
         && readSchema().equals(that.readSchema()) // compare Spark schemas to 
ignore field ids
         && filtersDesc().equals(that.filtersDesc())
+        && partitionPredicates().equals(that.partitionPredicates())
         && runtimeFiltersDesc().equals(that.runtimeFiltersDesc());
   }
 
@@ -92,6 +104,7 @@ class SparkBatchQueryScan extends SparkRuntimeFilterableScan 
{
         branch,
         readSchema(),
         filtersDesc(),
+        partitionPredicates(),
         runtimeFiltersDesc());
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index b8b3a0edea..528409f96b 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -37,6 +37,7 @@ import org.apache.iceberg.spark.SparkReadConf;
 import org.apache.spark.sql.SparkSession;
 import org.apache.spark.sql.connector.expressions.Literal;
 import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.expressions.filter.Predicate;
 import org.apache.spark.sql.connector.read.Statistics;
 import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering;
@@ -63,8 +64,18 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
       SparkReadConf readConf,
       Schema projection,
       List<Expression> filters,
+      List<PartitionPredicate> partitionPredicates,
       Supplier<ScanReport> scanReportSupplier) {
-    super(spark, table, schema, scan, readConf, projection, filters, 
scanReportSupplier);
+    super(
+        spark,
+        table,
+        schema,
+        scan,
+        readConf,
+        projection,
+        filters,
+        partitionPredicates,
+        scanReportSupplier);
     this.snapshot = snapshot;
     this.branch = branch;
     if (scan == null) {
@@ -140,6 +151,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
         && Objects.equals(branch, that.branch)
         && readSchema().equals(that.readSchema()) // compare Spark schemas to 
ignore field ids
         && filtersDesc().equals(that.filtersDesc())
+        && partitionPredicates().equals(that.partitionPredicates())
         && Objects.equals(filteredLocations, that.filteredLocations);
   }
 
@@ -152,6 +164,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
         branch,
         readSchema(),
         filtersDesc(),
+        partitionPredicates(),
         filteredLocations);
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java
index ecfe782a53..932639ddc3 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java
@@ -28,6 +28,7 @@ import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.metrics.ScanReport;
 import org.apache.iceberg.spark.SparkReadConf;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 
 class SparkIncrementalAppendScan extends SparkRuntimeFilterableScan {
 
@@ -43,8 +44,18 @@ class SparkIncrementalAppendScan extends 
SparkRuntimeFilterableScan {
       SparkReadConf readConf,
       Schema projection,
       List<Expression> filters,
+      List<PartitionPredicate> partitionPredicates,
       Supplier<ScanReport> scanReportSupplier) {
-    super(spark, table, table.schema(), scan, readConf, projection, filters, 
scanReportSupplier);
+    super(
+        spark,
+        table,
+        table.schema(),
+        scan,
+        readConf,
+        projection,
+        filters,
+        partitionPredicates,
+        scanReportSupplier);
     this.startSnapshotId = startSnapshotId;
     this.endSnapshotId = endSnapshotId;
   }
@@ -66,6 +77,7 @@ class SparkIncrementalAppendScan extends 
SparkRuntimeFilterableScan {
         && Objects.equals(endSnapshotId, that.endSnapshotId)
         && readSchema().equals(that.readSchema()) // compare Spark schemas to 
ignore field ids
         && filtersDesc().equals(that.filtersDesc())
+        && partitionPredicates().equals(that.partitionPredicates())
         && runtimeFiltersDesc().equals(that.runtimeFiltersDesc());
   }
 
@@ -78,6 +90,7 @@ class SparkIncrementalAppendScan extends 
SparkRuntimeFilterableScan {
         endSnapshotId,
         readSchema(),
         filtersDesc(),
+        partitionPredicates(),
         runtimeFiltersDesc());
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
index fe5eeee8fb..124f00f0b9 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
@@ -34,6 +34,7 @@ import org.apache.iceberg.Scan;
 import org.apache.iceberg.ScanTask;
 import org.apache.iceberg.ScanTaskGroup;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.StructLike;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.exceptions.ValidationException;
 import org.apache.iceberg.expressions.Expression;
@@ -46,9 +47,13 @@ import org.apache.iceberg.spark.SparkReadConf;
 import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.types.Types.StructType;
 import org.apache.iceberg.util.StructLikeSet;
+import org.apache.iceberg.util.StructProjection;
 import org.apache.iceberg.util.TableScanUtil;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.PartitionFieldReference;
 import org.apache.spark.sql.connector.expressions.Transform;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.read.SupportsReportPartitioning;
 import org.apache.spark.sql.connector.read.partitioning.KeyGroupedPartitioning;
 import org.apache.spark.sql.connector.read.partitioning.Partitioning;
@@ -63,6 +68,9 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
   private final Scan<?, ? extends ScanTask, ? extends ScanTaskGroup<?>> scan;
   private final boolean preserveDataGrouping;
+  private final List<PartitionPredicate> partitionPredicates;
+  private final List<PartitionField> partitionPredicateFields;
+  private final StructType partitionPredicateType;
 
   private Set<PartitionSpec> specs = null; // lazy cache of scanned specs
   private List<T> tasks = null; // lazy cache of uncombined tasks
@@ -78,6 +86,7 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
       SparkReadConf readConf,
       Schema projection,
       List<Expression> filters,
+      List<PartitionPredicate> partitionPredicates,
       Supplier<ScanReport> scanReportSupplier) {
     super(
         spark,
@@ -91,6 +100,9 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
     this.scan = scan;
     this.preserveDataGrouping = readConf.preserveDataGrouping();
+    this.partitionPredicates = Lists.newArrayList(partitionPredicates);
+    this.partitionPredicateFields = activePartitionFields(table.spec());
+    this.partitionPredicateType = activePartitionType(table.spec());
 
     if (scan == null) {
       this.specs = Collections.emptySet();
@@ -181,6 +193,8 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
     if (tasks == null) {
       try (CloseableIterable<? extends ScanTask> taskIterable = 
scan.planFiles()) {
         List<T> plannedTasks = Lists.newArrayList();
+        Map<Integer, PartitionPredicateEvaluator> evaluatorsBySpecId = 
Maps.newHashMap();
+        int numCandidateTasks = 0;
 
         for (ScanTask task : taskIterable) {
           ValidationException.check(
@@ -189,10 +203,25 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
               taskJavaClass().getName(),
               task.getClass().getName());
 
-          plannedTasks.add(taskJavaClass().cast(task));
+          T partitionTask = taskJavaClass().cast(task);
+          numCandidateTasks += 1;
+
+          if (partitionPredicates.isEmpty()
+              || matchesPartitionPredicates(partitionTask, 
evaluatorsBySpecId)) {
+            plannedTasks.add(partitionTask);
+          }
         }
 
         this.tasks = plannedTasks;
+
+        if (plannedTasks.size() < numCandidateTasks) {
+          LOG.info(
+              "{} of {} task(s) for table {} matched {} opaque Spark partition 
predicate(s)",
+              plannedTasks.size(),
+              numCandidateTasks,
+              table().name(),
+              partitionPredicates.size());
+        }
       } catch (IOException e) {
         throw new UncheckedIOException("Failed to close scan: " + scan, e);
       }
@@ -264,4 +293,98 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
         .map(NestedField::name)
         .collect(Collectors.joining(", "));
   }
+
+  protected List<PartitionPredicate> partitionPredicates() {
+    return partitionPredicates;
+  }
+
+  private boolean matchesPartitionPredicates(
+      T task, Map<Integer, PartitionPredicateEvaluator> evaluatorsBySpecId) {
+    PartitionPredicateEvaluator evaluator =
+        evaluatorsBySpecId.computeIfAbsent(
+            task.spec().specId(), ignored -> new 
PartitionPredicateEvaluator(task.spec()));
+    return evaluator.eval(task.partition());
+  }
+
+  private static List<PartitionField> activePartitionFields(PartitionSpec 
spec) {
+    return spec.fields().stream()
+        .filter(field -> !field.transform().isVoid())
+        .collect(Collectors.toList());
+  }
+
+  private static StructType activePartitionType(PartitionSpec spec) {
+    List<NestedField> activeFields = Lists.newArrayList();
+    List<NestedField> partitionTypeFields = spec.partitionType().fields();
+
+    for (int pos = 0; pos < spec.fields().size(); pos += 1) {
+      if (!spec.fields().get(pos).transform().isVoid()) {
+        activeFields.add(partitionTypeFields.get(pos));
+      }
+    }
+
+    return StructType.of(activeFields);
+  }
+
+  private class PartitionPredicateEvaluator {
+    private final StructProjection partitionProjection;
+    private final StructInternalRow partitionKey;
+    private final boolean[] evaluablePredicates;
+
+    private PartitionPredicateEvaluator(PartitionSpec spec) {
+      this.partitionProjection =
+          StructProjection.createAllowMissing(spec.partitionType(), 
partitionPredicateType);
+      this.partitionKey = new StructInternalRow(partitionPredicateType);
+      this.evaluablePredicates = findEvaluablePredicates(spec);
+    }
+
+    private boolean eval(StructLike partition) {
+      partitionKey.setStruct(partitionProjection.wrap(partition));
+
+      for (int pos = 0; pos < partitionPredicates.size(); pos += 1) {
+        if (evaluablePredicates[pos] && 
!partitionPredicates.get(pos).eval(partitionKey)) {
+          return false;
+        }
+      }
+
+      return true;
+    }
+
+    private boolean[] findEvaluablePredicates(PartitionSpec spec) {
+      Map<Integer, PartitionField> fieldsById = Maps.newHashMap();
+      for (PartitionField field : spec.fields()) {
+        if (!field.transform().isVoid()) {
+          fieldsById.put(field.fieldId(), field);
+        }
+      }
+
+      boolean[] result = new boolean[partitionPredicates.size()];
+      for (int pos = 0; pos < partitionPredicates.size(); pos += 1) {
+        result[pos] = canEvaluatePredicate(partitionPredicates.get(pos), 
fieldsById);
+      }
+
+      return result;
+    }
+
+    private boolean canEvaluatePredicate(
+        PartitionPredicate predicate, Map<Integer, PartitionField> fieldsById) 
{
+      for (NamedReference reference : predicate.references()) {
+        if (!(reference instanceof PartitionFieldReference 
partitionReference)) {
+          return false;
+        }
+
+        int ordinal = partitionReference.ordinal();
+        if (ordinal < 0 || ordinal >= partitionPredicateFields.size()) {
+          return false;
+        }
+
+        PartitionField currentField = partitionPredicateFields.get(ordinal);
+        PartitionField specField = fieldsById.get(currentField.fieldId());
+        if (specField == null || 
!specField.transform().equals(currentField.transform())) {
+          return false;
+        }
+      }
+
+      return true;
+    }
+  }
 }
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
index dfd38a1548..130e453196 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
@@ -53,6 +53,7 @@ import org.apache.iceberg.util.ContentFileUtil;
 import org.apache.iceberg.util.DeleteFileSet;
 import org.apache.spark.sql.SparkSession;
 import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.expressions.filter.Predicate;
 import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering;
 import org.slf4j.Logger;
@@ -73,8 +74,18 @@ abstract class SparkRuntimeFilterableScan extends 
SparkPartitioningAwareScan<Par
       SparkReadConf readConf,
       Schema projection,
       List<Expression> filters,
+      List<PartitionPredicate> partitionPredicates,
       Supplier<ScanReport> scanReportSupplier) {
-    super(spark, table, schema, scan, readConf, projection, filters, 
scanReportSupplier);
+    super(
+        spark,
+        table,
+        schema,
+        scan,
+        readConf,
+        projection,
+        filters,
+        partitionPredicates,
+        scanReportSupplier);
     this.runtimeFilters = Lists.newArrayList();
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java
index 6b9e314d35..d8e77ad7ff 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java
@@ -125,6 +125,11 @@ public class SparkScanBuilder extends BaseSparkScanBuilder
     SparkTableUtil.validateReadBranch(spark, table, branch, options);
   }
 
+  @Override
+  public boolean supportsIterativePushdown() {
+    return true;
+  }
+
   @Override
   public boolean pushAggregation(Aggregation aggregation) {
     if (!canPushDownAggregation(aggregation)) {
@@ -266,6 +271,7 @@ public class SparkScanBuilder extends BaseSparkScanBuilder
         readConf(),
         projection,
         filters(),
+        partitionPredicates(),
         metricsReporter()::scanReport);
   }
 
@@ -280,6 +286,7 @@ public class SparkScanBuilder extends BaseSparkScanBuilder
         readConf(),
         projection,
         filters(),
+        partitionPredicates(),
         metricsReporter()::scanReport);
   }
 
@@ -295,6 +302,7 @@ public class SparkScanBuilder extends BaseSparkScanBuilder
         readConf(),
         projection,
         filters(),
+        partitionPredicates(),
         metricsReporter()::scanReport);
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java
index 404cbc6519..78015cee1e 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java
@@ -35,6 +35,7 @@ import org.apache.iceberg.StructLike;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.spark.SparkSchemaUtil;
+import org.apache.iceberg.spark.SparkUtil;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.ByteBuffers;
@@ -183,8 +184,9 @@ class StructInternalRow extends InternalRow {
   }
 
   private UTF8String getUTF8StringInternal(int ordinal) {
-    CharSequence seq = struct.get(ordinal, CharSequence.class);
-    return UTF8String.fromString(seq.toString());
+    return (UTF8String)
+        SparkUtil.internalToSpark(
+            type.fields().get(ordinal).type(), struct.get(ordinal, 
Object.class));
   }
 
   @Override
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java
index 24fecf4eb2..57b833a16c 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java
@@ -61,7 +61,11 @@ import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Encoders;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.catalyst.InternalRow;
 import org.apache.spark.sql.catalyst.expressions.UnsafeRow;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.PartitionFieldReference;
+import org.apache.spark.sql.connector.expressions.filter.PartitionPredicate;
 import org.apache.spark.sql.connector.expressions.filter.Predicate;
 import org.apache.spark.sql.connector.read.Batch;
 import org.apache.spark.sql.connector.read.InputPartition;
@@ -75,6 +79,7 @@ import org.apache.spark.sql.sources.LessThan;
 import org.apache.spark.sql.sources.Not;
 import org.apache.spark.sql.sources.StringStartsWith;
 import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+import org.apache.spark.unsafe.types.UTF8String;
 import org.assertj.core.api.AbstractObjectAssert;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
@@ -196,10 +201,11 @@ public class TestFilteredScan {
   public void testUnpartitionedIDFilters() {
     CaseInsensitiveStringMap options =
         new CaseInsensitiveStringMap(ImmutableMap.of("path", 
unpartitioned.toString()));
-    SparkScanBuilder builder =
-        new SparkScanBuilder(spark, TABLES.load(options.get("path")), options);
 
     for (int i = 0; i < 10; i += 1) {
+      SparkScanBuilder builder =
+          new SparkScanBuilder(spark, TABLES.load(options.get("path")), 
options);
+
       pushFilters(builder, EqualTo.apply("id", i));
       Batch scan = builder.build().toBatch();
 
@@ -559,6 +565,53 @@ public class TestFilteredScan {
     assertThat(scan.planInputPartitions()).hasSize(9);
   }
 
+  @TestTemplate
+  public void partitionPredicatePrunesIdentityPartitions() {
+    Table table = buildPartitionedTable("partition_predicate", 
PARTITION_BY_DATA);
+    CaseInsensitiveStringMap options =
+        new CaseInsensitiveStringMap(ImmutableMap.of("path", 
table.location()));
+    SparkScanBuilder builder = new SparkScanBuilder(spark, table, options);
+    Predicate dataPredicate = GreaterThan.apply("id", -1L).toV2();
+    PartitionPredicate partitionPredicate = dataEndsWith("tion");
+
+    assertThat(builder.supportsIterativePushdown()).isTrue();
+    assertThat(builder.pushPredicates(new Predicate[] {dataPredicate}))
+        .containsExactly(dataPredicate);
+    assertThat(builder.pushPredicates(new Predicate[] {partitionPredicate}))
+        .containsExactly(partitionPredicate);
+    assertThat(builder.pushedPredicates()).containsExactly(dataPredicate, 
partitionPredicate);
+
+    assertThat(builder.build().toBatch().planInputPartitions()).hasSize(1);
+  }
+
+  @TestTemplate
+  public void partitionPredicateKeepsTasksWithMissingFields() {
+    assumeThat(fileFormat)
+        .as("no need to run this across the entire test matrix")
+        .isEqualTo(FileFormat.PARQUET);
+
+    Table table = TABLES.load(unpartitioned.toString());
+    Dataset<Row> rows = 
spark.read().format("iceberg").load(table.location()).cache();
+    rows.count();
+
+    table.updateSpec().addField("data").commit();
+    table.updateProperties().set("read.split.target-size", "2048").commit();
+    rows.write().format("iceberg").mode("append").save(table.location());
+    rows.unpersist();
+    table.refresh();
+
+    CaseInsensitiveStringMap options =
+        new CaseInsensitiveStringMap(ImmutableMap.of("path", 
table.location()));
+    SparkScanBuilder builder = new SparkScanBuilder(spark, table, options);
+    PartitionPredicate partitionPredicate = dataEndsWith("tion");
+
+    assertThat(builder.pushPredicates(new Predicate[] {partitionPredicate}))
+        .containsExactly(partitionPredicate);
+
+    // The matching current-spec task and the old unpartitioned task must both 
remain.
+    assertThat(builder.build().toBatch().planInputPartitions()).hasSize(2);
+  }
+
   @TestTemplate
   public void testPartitionedByIdStartsWith() {
     Table table = buildPartitionedTable("partitioned_by_id", PARTITION_BY_ID);
@@ -671,6 +724,39 @@ public class TestFilteredScan {
     
filterable.pushPredicates(Arrays.stream(filters).map(Filter::toV2).toArray(Predicate[]::new));
   }
 
+  private PartitionPredicate dataEndsWith(String suffix) {
+    PartitionFieldReference dataField =
+        new PartitionFieldReference() {
+          @Override
+          public int ordinal() {
+            return 0;
+          }
+
+          @Override
+          public String[] fieldNames() {
+            return new String[] {"data"};
+          }
+        };
+
+    return new PartitionPredicate() {
+      @Override
+      public String describe() {
+        return "data ends with " + suffix;
+      }
+
+      @Override
+      public NamedReference[] references() {
+        return new NamedReference[] {dataField};
+      }
+
+      @Override
+      public boolean eval(InternalRow partitionKey) {
+        UTF8String value = partitionKey.getUTF8String(0);
+        return value != null && value.toString().endsWith(suffix);
+      }
+    };
+  }
+
   private Table buildPartitionedTable(String desc, PartitionSpec spec) {
     File location = new File(parent, desc);
     Table table = TABLES.create(SCHEMA, spec, location.toString());
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java
index 55299f7b67..55d5199c96 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java
@@ -28,11 +28,15 @@ import java.nio.ByteOrder;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
+import java.util.Locale;
 import org.apache.iceberg.Parameter;
 import org.apache.iceberg.ParameterizedTestExtension;
 import org.apache.iceberg.Parameters;
+import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.PlanningMode;
+import org.apache.iceberg.Schema;
 import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.spark.SparkCatalogConfig;
@@ -43,10 +47,13 @@ import org.apache.iceberg.variants.ShreddedObject;
 import org.apache.iceberg.variants.VariantMetadata;
 import org.apache.iceberg.variants.Variants;
 import org.apache.spark.sql.execution.SparkPlan;
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec;
+import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.unsafe.types.VariantVal;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.TestTemplate;
 import org.junit.jupiter.api.extension.ExtendWith;
+import scala.collection.JavaConverters;
 
 @ExtendWith(ParameterizedTestExtension.class)
 public class TestFilterPushDown extends TestBaseWithCatalog {
@@ -209,6 +216,178 @@ public class TestFilterPushDown extends 
TestBaseWithCatalog {
         ImmutableList.of(row(5, 500, "d5")));
   }
 
+  @TestTemplate
+  public void partitionPredicatePushdownWithUDF() {
+    sql("CREATE TABLE %s (id INT, dep STRING) USING iceberg PARTITIONED BY 
(dep)", tableName);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    sql("INSERT INTO %s VALUES (1, 'd1')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'd2')", tableName);
+    sql("INSERT INTO %s VALUES (3, 'd3')", tableName);
+
+    spark
+        .udf()
+        .register(
+            "is_d2", (String value) -> value != null && value.equals("d2"), 
DataTypes.BooleanType);
+
+    SparkPlan plan =
+        executeAndKeepPlan(
+            () ->
+                assertEquals(
+                    "Rows must match",
+                    ImmutableList.of(row(2, "d2")),
+                    sql("SELECT * FROM %s WHERE is_d2(dep)", tableName)));
+
+    assertInputPartitions(plan, 1);
+  }
+
+  @TestTemplate
+  public void partitionPredicatePushdownWithUnsupportedV2Filter() {
+    sql("CREATE TABLE %s (id INT, dep STRING) USING iceberg PARTITIONED BY 
(dep)", tableName);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    sql("INSERT INTO %s VALUES (1, 'd1')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'd2')", tableName);
+    sql("INSERT INTO %s VALUES (3, 'd3')", tableName);
+
+    SparkPlan plan =
+        executeAndKeepPlan(
+            () ->
+                assertEquals(
+                    "Rows must match",
+                    ImmutableList.of(row(2, "d2")),
+                    sql("SELECT * FROM %s WHERE dep LIKE '%%2'", tableName)));
+
+    assertInputPartitions(plan, 1);
+  }
+
+  @TestTemplate
+  void partitionPredicatePushdownForUUIDField() {
+    Schema schema = new Schema(Types.NestedField.optional(1, "uuid", 
Types.UUIDType.get()));
+    PartitionSpec spec = 
PartitionSpec.builderFor(schema).identity("uuid").build();
+    validationCatalog.createTable(tableIdent, schema, spec);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    String matchingUuid = "00000000-0000-0000-0000-000000000001";
+    String otherUuid = "00000000-0000-0000-0000-000000000002";
+    sql("INSERT INTO %s VALUES ('%s')", tableName, matchingUuid);
+    sql("INSERT INTO %s VALUES ('%s')", tableName, otherUuid);
+
+    SparkPlan plan =
+        executeAndKeepPlan(
+            () ->
+                assertThat(sql("SELECT uuid FROM %s WHERE uuid LIKE '%%1'", 
tableName))
+                    .containsExactly(row(matchingUuid)));
+
+    assertInputPartitions(plan, 1);
+  }
+
+  @TestTemplate
+  public void partitionPredicatePushdownForNonFirstFields() {
+    sql(
+        "CREATE TABLE %s (p0 STRING, p1 STRING, p2 STRING, data STRING) "
+            + "USING iceberg PARTITIONED BY (p0, p1, p2)",
+        tableName);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    sql("INSERT INTO %s VALUES ('a', 'x', '1', 'd1')", tableName);
+    sql("INSERT INTO %s VALUES ('a', 'y', '1', 'd2')", tableName);
+    sql("INSERT INTO %s VALUES ('a', 'x', '2', 'd3')", tableName);
+    sql("INSERT INTO %s VALUES ('b', 'x', '1', 'd4')", tableName);
+
+    SparkPlan nonFirstFieldPlan =
+        executeAndKeepPlan(
+            () ->
+                assertThat(sql("SELECT * FROM %s WHERE p1 LIKE '%%x'", 
tableName))
+                    .as("Rows must match")
+                    .containsExactlyInAnyOrder(
+                        row("a", "x", "1", "d1"),
+                        row("a", "x", "2", "d3"),
+                        row("b", "x", "1", "d4")));
+    assertInputPartitions(nonFirstFieldPlan, 3);
+
+    spark
+        .udf()
+        .register(
+            "concat_partition_fields",
+            (String left, String right) -> left == null || right == null ? 
null : left + right,
+            DataTypes.StringType);
+
+    SparkPlan multipleFieldsPlan =
+        executeAndKeepPlan(
+            () ->
+                assertThat(
+                        sql(
+                            "SELECT * FROM %s WHERE 
concat_partition_fields(p1, p2) = 'x1'",
+                            tableName))
+                    .as("Rows must match")
+                    .containsExactlyInAnyOrder(row("a", "x", "1", "d1"), 
row("b", "x", "1", "d4")));
+    assertInputPartitions(multipleFieldsPlan, 2);
+  }
+
+  @TestTemplate
+  public void partitionPredicatePushdownAfterDroppingMiddlePartitionField() {
+    sql(
+        "CREATE TABLE %s (p0 STRING, p1 STRING, p2 STRING, data STRING) "
+            + "USING iceberg PARTITIONED BY (p0, p1, p2)",
+        tableName);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    sql("INSERT INTO %s VALUES ('a', 'x', 'keep', 'old-match')", tableName);
+    sql("INSERT INTO %s VALUES ('a', 'y', 'drop', 'old-non-match')", 
tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    table.updateSpec().removeField("p1").commit();
+    sql("REFRESH TABLE %s", tableName);
+
+    sql("INSERT INTO %s VALUES ('b', 'z', 'keep', 'new-match')", tableName);
+    sql("INSERT INTO %s VALUES ('b', 'z', 'drop', 'new-non-match')", 
tableName);
+
+    SparkPlan plan =
+        executeAndKeepPlan(
+            () ->
+                assertThat(sql("SELECT * FROM %s WHERE p2 LIKE '%%keep'", 
tableName))
+                    .as("Rows must match")
+                    .containsExactlyInAnyOrder(
+                        row("a", "x", "keep", "old-match"), row("b", "z", 
"keep", "new-match")));
+    assertInputPartitions(plan, 2);
+  }
+
+  @TestTemplate
+  public void partitionPredicatePushdownForNestedField() {
+    sql(
+        "CREATE TABLE %s (s STRUCT<tz: STRING, x: INT>, data STRING) "
+            + "USING iceberg PARTITIONED BY (s.tz)",
+        tableName);
+    configurePlanningMode(planningMode);
+
+    setSmallSplitSize();
+    sql("INSERT INTO %s VALUES (named_struct('tz', 'LA', 'x', 1), 'a')", 
tableName);
+    sql("INSERT INTO %s VALUES (named_struct('tz', 'NY', 'x', 2), 'b')", 
tableName);
+
+    spark
+        .udf()
+        .register(
+            "uppercase_tz",
+            (String value) -> value != null ? value.toUpperCase(Locale.ROOT) : 
null,
+            DataTypes.StringType);
+
+    SparkPlan plan =
+        executeAndKeepPlan(
+            () ->
+                assertEquals(
+                    "Rows must match",
+                    ImmutableList.of(row(row("LA", 1), "a")),
+                    sql("SELECT * FROM %s WHERE uppercase_tz(s.tz) = 'LA'", 
tableName)));
+
+    assertInputPartitions(plan, 1);
+  }
+
   @TestTemplate
   public void testFilterPushdownWithHoursTransform() {
     sql(
@@ -731,6 +910,18 @@ public class TestFilterPushDown extends 
TestBaseWithCatalog {
     checkFilters(predicate, null, icebergFilters, expectedRows);
   }
 
+  private void setSmallSplitSize() {
+    Table table = validationCatalog.loadTable(tableIdent);
+    table.updateProperties().set(TableProperties.SPLIT_SIZE, "2048").commit();
+  }
+
+  private void assertInputPartitions(SparkPlan plan, int 
expectedNumPartitions) {
+    List<SparkPlan> leaves = 
JavaConverters.seqAsJavaListConverter(plan.collectLeaves()).asJava();
+    assertThat(leaves).singleElement().isInstanceOf(BatchScanExec.class);
+    BatchScanExec scan = (BatchScanExec) leaves.get(0);
+    assertThat(scan.inputPartitions().size()).isEqualTo(expectedNumPartitions);
+  }
+
   private void checkFilters(
       String predicate, String sparkFilter, String icebergFilters, 
List<Object[]> expectedRows) {
 

Reply via email to