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

RussellSpitzer 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 dfd7ef3b71 Spark 3.5, 4.0, 4.1, 4.2: Use scan schema for CBO column 
statistics (#18655)
dfd7ef3b71 is described below

commit dfd7ef3b711cd1977b93f4e6f86cb4148712acd3
Author: Rajarshi Sarkar <[email protected]>
AuthorDate: Fri Oct 30 22:39:00 2026 +0530

    Spark 3.5, 4.0, 4.1, 4.2: Use scan schema for CBO column statistics (#18655)
---
 .../org/apache/iceberg/spark/source/SparkScan.java |  7 +-
 .../apache/iceberg/spark/source/TestSparkScan.java | 40 +++++++++--
 .../org/apache/iceberg/spark/source/SparkScan.java |  7 +-
 .../apache/iceberg/spark/source/TestSparkScan.java | 80 ++++++++++++++++++++++
 .../org/apache/iceberg/spark/source/SparkScan.java |  7 +-
 .../apache/iceberg/spark/source/TestSparkScan.java | 80 ++++++++++++++++++++++
 .../org/apache/iceberg/spark/source/SparkScan.java |  7 +-
 .../apache/iceberg/spark/source/TestSparkScan.java | 80 ++++++++++++++++++++++
 8 files changed, 297 insertions(+), 11 deletions(-)

diff --git 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
index 820461ff87..55ab122997 100644
--- 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
+++ 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
@@ -219,9 +219,12 @@ abstract class SparkScan implements Scan, 
SupportsReportStatistics {
                     Collectors.groupingBy(
                         metadata -> metadata.fields().get(0), 
Collectors.toList()));
 
-        Schema snapshotSchema = SnapshotUtil.schemaFor(table, 
snapshot.snapshotId());
+        Schema scanSchema = expectedSchema();
         for (Map.Entry<Integer, List<BlobMetadata>> entry : 
groupedByField.entrySet()) {
-          String colName = snapshotSchema.findColumnName(entry.getKey());
+          String colName = scanSchema.findColumnName(entry.getKey());
+          if (colName == null) {
+            continue;
+          }
           NamedReference ref = FieldReference.column(colName);
           Long ndv = null;
 
diff --git 
a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
 
b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
index 251ad8f23a..5790f87ff6 100644
--- 
a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
+++ 
b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
@@ -470,7 +470,7 @@ public class TestSparkScan extends TestBaseWithCatalog {
   }
 
   @TestTemplate
-  public void testStatisticsMappedWithSnapshotSchema() throws 
NoSuchTableException {
+  public void testStatisticsMappedWithScanSchema() throws NoSuchTableException 
{
     sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName);
     sql("INSERT INTO %s VALUES (1, 'a')", tableName);
 
@@ -490,8 +490,7 @@ public class TestSparkScan extends TestBaseWithCatalog {
                     1,
                     ImmutableList.of(1),
                     ImmutableMap.of("ndv", "4")),
-                // Blob for field ID 2 ('data') which is in the snapshot 
schema but later dropped
-                // from table
+                // Blob for field ID 2 ('data')
                 new GenericBlobMetadata(
                     APACHE_DATASKETCHES_THETA_V1,
                     snapshotId,
@@ -504,8 +503,12 @@ public class TestSparkScan extends TestBaseWithCatalog {
 
     table.updateStatistics().setStatistics(statisticsFile).commit();
 
-    // Drop column 'data' (field ID 2) to simulate real schema evolution
-    table.updateSchema().deleteColumn("data").commit();
+    // Rename 'data' to 'data_renamed' and add new column 'data' (field ID 3) 
without new snapshot
+    table.updateSchema().renameColumn("data", "data_renamed").commit();
+    table
+        .updateSchema()
+        .addColumn("data", org.apache.iceberg.types.Types.StringType.get())
+        .commit();
 
     Table updatedTable = validationCatalog.loadTable(tableIdent);
     SparkScanBuilder scanBuilder =
@@ -515,9 +518,34 @@ public class TestSparkScan extends TestBaseWithCatalog {
     Map<String, String> reportColStatsEnabled =
         ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true");
 
+    Map<String, Long> expectedNDVs = Maps.newHashMap();
+    expectedNDVs.put("id", 4L);
+    expectedNDVs.put("data_renamed", 2L);
+
     withSQLConf(
         reportColStatsEnabled,
-        () -> 
assertThatCode(scan::estimateStatistics).doesNotThrowAnyException());
+        () -> {
+          assertThatCode(scan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(scan, 1L, expectedNDVs);
+        });
+
+    // Drop column 'data_renamed' (field ID 2) to simulate dropped column 
handling
+    table.updateSchema().deleteColumn("data_renamed").commit();
+
+    Table droppedColTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder droppedColScanBuilder =
+        new SparkScanBuilder(spark, droppedColTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan droppedColScan = (SparkScan) droppedColScanBuilder.build();
+
+    Map<String, Long> expectedDroppedNDVs = Maps.newHashMap();
+    expectedDroppedNDVs.put("id", 4L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          
assertThatCode(droppedColScan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(droppedColScan, 1L, expectedDroppedNDVs);
+        });
   }
 
   @TestTemplate
diff --git 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
index 6cf12f6e76..55ab122997 100644
--- 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
+++ 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
@@ -214,12 +214,17 @@ abstract class SparkScan implements Scan, 
SupportsReportStatistics {
 
         Map<Integer, List<BlobMetadata>> groupedByField =
             metadataList.stream()
+                .filter(metadata -> !metadata.fields().isEmpty())
                 .collect(
                     Collectors.groupingBy(
                         metadata -> metadata.fields().get(0), 
Collectors.toList()));
 
+        Schema scanSchema = expectedSchema();
         for (Map.Entry<Integer, List<BlobMetadata>> entry : 
groupedByField.entrySet()) {
-          String colName = table.schema().findColumnName(entry.getKey());
+          String colName = scanSchema.findColumnName(entry.getKey());
+          if (colName == null) {
+            continue;
+          }
           NamedReference ref = FieldReference.column(colName);
           Long ndv = null;
 
diff --git 
a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
 
b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
index 2eb61407b0..5790f87ff6 100644
--- 
a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
+++ 
b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
@@ -28,6 +28,7 @@ import static 
org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStr
 import static org.apache.spark.sql.functions.date_add;
 import static org.apache.spark.sql.functions.expr;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 
 import java.util.List;
 import java.util.Map;
@@ -468,6 +469,85 @@ public class TestSparkScan extends TestBaseWithCatalog {
     withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 
4L, expectedTwoNDVs));
   }
 
+  @TestTemplate
+  public void testStatisticsMappedWithScanSchema() throws NoSuchTableException 
{
+    sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    long snapshotId = table.currentSnapshot().snapshotId();
+
+    GenericStatisticsFile statisticsFile =
+        new GenericStatisticsFile(
+            snapshotId,
+            "/test/statistics/file.puffin",
+            100,
+            42,
+            ImmutableList.of(
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(1),
+                    ImmutableMap.of("ndv", "4")),
+                // Blob for field ID 2 ('data')
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(2),
+                    ImmutableMap.of("ndv", "2")),
+                // Blob with empty fields (e.g. table-level or non-field blob)
+                new GenericBlobMetadata(
+                    DUMMY_BLOB_TYPE, snapshotId, 1, ImmutableList.of(), 
ImmutableMap.of())));
+
+    table.updateStatistics().setStatistics(statisticsFile).commit();
+
+    // Rename 'data' to 'data_renamed' and add new column 'data' (field ID 3) 
without new snapshot
+    table.updateSchema().renameColumn("data", "data_renamed").commit();
+    table
+        .updateSchema()
+        .addColumn("data", org.apache.iceberg.types.Types.StringType.get())
+        .commit();
+
+    Table updatedTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder scanBuilder =
+        new SparkScanBuilder(spark, updatedTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan scan = (SparkScan) scanBuilder.build();
+
+    Map<String, String> reportColStatsEnabled =
+        ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true");
+
+    Map<String, Long> expectedNDVs = Maps.newHashMap();
+    expectedNDVs.put("id", 4L);
+    expectedNDVs.put("data_renamed", 2L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          assertThatCode(scan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(scan, 1L, expectedNDVs);
+        });
+
+    // Drop column 'data_renamed' (field ID 2) to simulate dropped column 
handling
+    table.updateSchema().deleteColumn("data_renamed").commit();
+
+    Table droppedColTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder droppedColScanBuilder =
+        new SparkScanBuilder(spark, droppedColTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan droppedColScan = (SparkScan) droppedColScanBuilder.build();
+
+    Map<String, Long> expectedDroppedNDVs = Maps.newHashMap();
+    expectedDroppedNDVs.put("id", 4L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          
assertThatCode(droppedColScan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(droppedColScan, 1L, expectedDroppedNDVs);
+        });
+  }
+
   @TestTemplate
   public void testUnpartitionedYears() throws Exception {
     createUnpartitionedTable(spark, tableName);
diff --git 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
index ee61523d80..22340408b0 100644
--- 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
+++ 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
@@ -234,12 +234,17 @@ abstract class SparkScan implements Scan, 
SupportsReportStatistics {
 
         Map<Integer, List<BlobMetadata>> groupedByField =
             metadataList.stream()
+                .filter(metadata -> !metadata.fields().isEmpty())
                 .collect(
                     Collectors.groupingBy(
                         metadata -> metadata.fields().get(0), 
Collectors.toList()));
 
+        Schema scanSchema = schema();
         for (Map.Entry<Integer, List<BlobMetadata>> entry : 
groupedByField.entrySet()) {
-          String colName = table.schema().findColumnName(entry.getKey());
+          String colName = scanSchema.findColumnName(entry.getKey());
+          if (colName == null) {
+            continue;
+          }
           NamedReference ref = FieldReference.column(colName);
           Long ndv = null;
 
diff --git 
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
 
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
index b226990e69..e600c29a94 100644
--- 
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
+++ 
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
@@ -28,6 +28,7 @@ import static 
org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStr
 import static org.apache.spark.sql.functions.date_add;
 import static org.apache.spark.sql.functions.expr;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 
 import java.util.List;
 import java.util.Map;
@@ -470,6 +471,85 @@ public class TestSparkScan extends TestBaseWithCatalog {
     withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 
4L, expectedTwoNDVs));
   }
 
+  @TestTemplate
+  public void testStatisticsMappedWithScanSchema() throws NoSuchTableException 
{
+    sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    long snapshotId = table.currentSnapshot().snapshotId();
+
+    GenericStatisticsFile statisticsFile =
+        new GenericStatisticsFile(
+            snapshotId,
+            "/test/statistics/file.puffin",
+            100,
+            42,
+            ImmutableList.of(
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(1),
+                    ImmutableMap.of("ndv", "4")),
+                // Blob for field ID 2 ('data')
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(2),
+                    ImmutableMap.of("ndv", "2")),
+                // Blob with empty fields (e.g. table-level or non-field blob)
+                new GenericBlobMetadata(
+                    DUMMY_BLOB_TYPE, snapshotId, 1, ImmutableList.of(), 
ImmutableMap.of())));
+
+    table.updateStatistics().setStatistics(statisticsFile).commit();
+
+    // Rename 'data' to 'data_renamed' and add new column 'data' (field ID 3) 
without new snapshot
+    table.updateSchema().renameColumn("data", "data_renamed").commit();
+    table
+        .updateSchema()
+        .addColumn("data", org.apache.iceberg.types.Types.StringType.get())
+        .commit();
+
+    Table updatedTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder scanBuilder =
+        new SparkScanBuilder(spark, updatedTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan scan = (SparkScan) scanBuilder.build();
+
+    Map<String, String> reportColStatsEnabled =
+        ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true");
+
+    Map<String, Long> expectedNDVs = Maps.newHashMap();
+    expectedNDVs.put("id", 4L);
+    expectedNDVs.put("data_renamed", 2L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          assertThatCode(scan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(scan, 1L, expectedNDVs);
+        });
+
+    // Drop column 'data_renamed' (field ID 2) to simulate dropped column 
handling
+    table.updateSchema().deleteColumn("data_renamed").commit();
+
+    Table droppedColTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder droppedColScanBuilder =
+        new SparkScanBuilder(spark, droppedColTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan droppedColScan = (SparkScan) droppedColScanBuilder.build();
+
+    Map<String, Long> expectedDroppedNDVs = Maps.newHashMap();
+    expectedDroppedNDVs.put("id", 4L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          
assertThatCode(droppedColScan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(droppedColScan, 1L, expectedDroppedNDVs);
+        });
+  }
+
   @TestTemplate
   public void testUnpartitionedYears() throws Exception {
     createUnpartitionedTable(spark, tableName);
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
index ee61523d80..22340408b0 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
@@ -234,12 +234,17 @@ abstract class SparkScan implements Scan, 
SupportsReportStatistics {
 
         Map<Integer, List<BlobMetadata>> groupedByField =
             metadataList.stream()
+                .filter(metadata -> !metadata.fields().isEmpty())
                 .collect(
                     Collectors.groupingBy(
                         metadata -> metadata.fields().get(0), 
Collectors.toList()));
 
+        Schema scanSchema = schema();
         for (Map.Entry<Integer, List<BlobMetadata>> entry : 
groupedByField.entrySet()) {
-          String colName = table.schema().findColumnName(entry.getKey());
+          String colName = scanSchema.findColumnName(entry.getKey());
+          if (colName == null) {
+            continue;
+          }
           NamedReference ref = FieldReference.column(colName);
           Long ndv = null;
 
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
index b226990e69..e600c29a94 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java
@@ -28,6 +28,7 @@ import static 
org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStr
 import static org.apache.spark.sql.functions.date_add;
 import static org.apache.spark.sql.functions.expr;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 
 import java.util.List;
 import java.util.Map;
@@ -470,6 +471,85 @@ public class TestSparkScan extends TestBaseWithCatalog {
     withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 
4L, expectedTwoNDVs));
   }
 
+  @TestTemplate
+  public void testStatisticsMappedWithScanSchema() throws NoSuchTableException 
{
+    sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    long snapshotId = table.currentSnapshot().snapshotId();
+
+    GenericStatisticsFile statisticsFile =
+        new GenericStatisticsFile(
+            snapshotId,
+            "/test/statistics/file.puffin",
+            100,
+            42,
+            ImmutableList.of(
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(1),
+                    ImmutableMap.of("ndv", "4")),
+                // Blob for field ID 2 ('data')
+                new GenericBlobMetadata(
+                    APACHE_DATASKETCHES_THETA_V1,
+                    snapshotId,
+                    1,
+                    ImmutableList.of(2),
+                    ImmutableMap.of("ndv", "2")),
+                // Blob with empty fields (e.g. table-level or non-field blob)
+                new GenericBlobMetadata(
+                    DUMMY_BLOB_TYPE, snapshotId, 1, ImmutableList.of(), 
ImmutableMap.of())));
+
+    table.updateStatistics().setStatistics(statisticsFile).commit();
+
+    // Rename 'data' to 'data_renamed' and add new column 'data' (field ID 3) 
without new snapshot
+    table.updateSchema().renameColumn("data", "data_renamed").commit();
+    table
+        .updateSchema()
+        .addColumn("data", org.apache.iceberg.types.Types.StringType.get())
+        .commit();
+
+    Table updatedTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder scanBuilder =
+        new SparkScanBuilder(spark, updatedTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan scan = (SparkScan) scanBuilder.build();
+
+    Map<String, String> reportColStatsEnabled =
+        ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true");
+
+    Map<String, Long> expectedNDVs = Maps.newHashMap();
+    expectedNDVs.put("id", 4L);
+    expectedNDVs.put("data_renamed", 2L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          assertThatCode(scan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(scan, 1L, expectedNDVs);
+        });
+
+    // Drop column 'data_renamed' (field ID 2) to simulate dropped column 
handling
+    table.updateSchema().deleteColumn("data_renamed").commit();
+
+    Table droppedColTable = validationCatalog.loadTable(tableIdent);
+    SparkScanBuilder droppedColScanBuilder =
+        new SparkScanBuilder(spark, droppedColTable, 
CaseInsensitiveStringMap.empty());
+    SparkScan droppedColScan = (SparkScan) droppedColScanBuilder.build();
+
+    Map<String, Long> expectedDroppedNDVs = Maps.newHashMap();
+    expectedDroppedNDVs.put("id", 4L);
+
+    withSQLConf(
+        reportColStatsEnabled,
+        () -> {
+          
assertThatCode(droppedColScan::estimateStatistics).doesNotThrowAnyException();
+          checkColStatisticsReported(droppedColScan, 1L, expectedDroppedNDVs);
+        });
+  }
+
   @TestTemplate
   public void testUnpartitionedYears() throws Exception {
     createUnpartitionedTable(spark, tableName);

Reply via email to