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 7a74178db9 Parquet: Validate geospatial projection parameters (#17578)
7a74178db9 is described below

commit 7a74178db9487d934313f5e8430d14f7aa3769d9
Author: Manu Zhang <[email protected]>
AuthorDate: Thu Oct 22 06:50:20 2026 +0800

    Parquet: Validate geospatial projection parameters (#17578)
---
 .../apache/iceberg/parquet/MessageTypeToType.java  |   4 +
 .../apache/iceberg/parquet/ParquetSchemaUtil.java  |   3 +
 .../org/apache/iceberg/parquet/PruneColumns.java   |  78 ++++++++
 .../apache/iceberg/parquet/TestPruneColumns.java   | 220 +++++++++++++++++++++
 4 files changed, 305 insertions(+)

diff --git 
a/parquet/src/main/java/org/apache/iceberg/parquet/MessageTypeToType.java 
b/parquet/src/main/java/org/apache/iceberg/parquet/MessageTypeToType.java
index 2b01bf882e..5d791e66f3 100644
--- a/parquet/src/main/java/org/apache/iceberg/parquet/MessageTypeToType.java
+++ b/parquet/src/main/java/org/apache/iceberg/parquet/MessageTypeToType.java
@@ -149,6 +149,10 @@ class MessageTypeToType extends ParquetTypeVisitor<Type> {
 
   @Override
   public Type primitive(PrimitiveType primitive) {
+    return convertPrimitive(primitive);
+  }
+
+  static Type convertPrimitive(PrimitiveType primitive) {
     // first, use the logical type annotation, if present
     LogicalTypeAnnotation logicalType = primitive.getLogicalTypeAnnotation();
     if (logicalType != null) {
diff --git 
a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java 
b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java
index 75e070381e..ccbf68ec9e 100644
--- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java
+++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java
@@ -267,6 +267,9 @@ public class ParquetSchemaUtil {
     int ordinal = 1;
     for (Type type : fileSchema.getFields()) {
       if (selectedIds.contains(ordinal)) {
+        Types.NestedField expectedField = expectedSchema.findField(ordinal);
+        PruneColumns.validateFallbackType(expectedField.type(), type, 
type.getName());
+
         builder.addField(type.withId(ordinal));
       }
       ordinal += 1;
diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/PruneColumns.java 
b/parquet/src/main/java/org/apache/iceberg/parquet/PruneColumns.java
index 1675325c78..0caa376fbb 100644
--- a/parquet/src/main/java/org/apache/iceberg/parquet/PruneColumns.java
+++ b/parquet/src/main/java/org/apache/iceberg/parquet/PruneColumns.java
@@ -24,6 +24,8 @@ import java.util.Set;
 import org.apache.iceberg.relocated.com.google.common.base.Objects;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Type.TypeID;
+import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.Types.ListType;
 import org.apache.iceberg.types.Types.MapType;
 import org.apache.iceberg.types.Types.NestedField;
@@ -170,9 +172,85 @@ class PruneColumns extends TypeWithSchemaVisitor<Type> {
   @Override
   public Type primitive(
       org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType 
primitive) {
+    validatePrimitive(expected, primitive, String.join(".", currentPath()));
     return null;
   }
 
+  static void validatePrimitive(
+      org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType 
primitive, String path) {
+    if (expected != null
+        && (expected.typeId() == TypeID.GEOMETRY || expected.typeId() == 
TypeID.GEOGRAPHY)) {
+      Preconditions.checkArgument(
+          
TypeUtil.isPromotionAllowed(MessageTypeToType.convertPrimitive(primitive), 
expected),
+          "Cannot read Parquet type %s as Iceberg type %s for field %s",
+          primitive,
+          expected,
+          path);
+    }
+  }
+
+  static void validateFallbackType(org.apache.iceberg.types.Type expected, 
Type type, String path) {
+    if (expected == null) {
+      return;
+    }
+
+    if (expected.isPrimitiveType() || type.isPrimitive()) {
+      if (expected.isPrimitiveType() && type.isPrimitive()) {
+        validatePrimitive(expected.asPrimitiveType(), type.asPrimitiveType(), 
path);
+      }
+
+      return;
+    }
+
+    GroupType group = type.asGroupType();
+    if (expected.isStructType()) {
+      validateFallbackStruct(expected.asStructType(), group, path);
+    } else if (expected.isListType()) {
+      validateFallbackList(expected.asListType(), group, path);
+    } else if (expected.isMapType()) {
+      validateFallbackMap(expected.asMapType(), group, path);
+    }
+  }
+
+  private static void validateFallbackStruct(StructType expected, GroupType 
struct, String path) {
+    for (NestedField expectedField : expected.fields()) {
+      // Projected structs omit unselected fields, so projected positions may 
not match the file.
+      // Validate only when an expected name identifies the corresponding file 
field.
+      Type field = ParquetSchemaUtil.fieldType(struct, expectedField.name());
+      if (field != null) {
+        validateFallbackType(expectedField.type(), field, path + "." + 
field.getName());
+      }
+    }
+  }
+
+  private static void validateFallbackList(ListType expected, GroupType list, 
String path) {
+    Type element = ParquetSchemaUtil.determineListElementType(list);
+    String elementPath = path;
+    if (!element.isRepetition(Type.Repetition.REPEATED)) {
+      elementPath += "." + list.getFieldName(0);
+    }
+
+    validateFallbackType(expected.elementType(), element, elementPath + "." + 
element.getName());
+  }
+
+  private static void validateFallbackMap(MapType expected, GroupType map, 
String path) {
+    GroupType repeated = map.getType(0).asGroupType();
+    String repeatedPath = path + "." + repeated.getName();
+    if (repeated.getFieldCount() == 2) {
+      Type key = repeated.getType(0);
+      Type value = repeated.getType(1);
+      validateFallbackType(expected.keyType(), key, repeatedPath + "." + 
key.getName());
+      validateFallbackType(expected.valueType(), value, repeatedPath + "." + 
value.getName());
+
+    } else if (repeated.getFieldCount() == 1) {
+      Type keyOrValue = repeated.getType(0);
+      org.apache.iceberg.types.Type expectedKeyOrValue =
+          keyOrValue.getName().equalsIgnoreCase("key") ? expected.keyType() : 
expected.valueType();
+      validateFallbackType(
+          expectedKeyOrValue, keyOrValue, repeatedPath + "." + 
keyOrValue.getName());
+    }
+  }
+
   private Integer getId(Type type) {
     return type.getId() == null ? null : type.getId().intValue();
   }
diff --git 
a/parquet/src/test/java/org/apache/iceberg/parquet/TestPruneColumns.java 
b/parquet/src/test/java/org/apache/iceberg/parquet/TestPruneColumns.java
index 46b00585d0..8d2b3210ad 100644
--- a/parquet/src/test/java/org/apache/iceberg/parquet/TestPruneColumns.java
+++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestPruneColumns.java
@@ -19,10 +19,14 @@
 package org.apache.iceberg.parquet;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.types.EdgeAlgorithm;
 import org.apache.iceberg.types.Types.DoubleType;
+import org.apache.iceberg.types.Types.GeographyType;
+import org.apache.iceberg.types.Types.GeometryType;
 import org.apache.iceberg.types.Types.IntegerType;
 import org.apache.iceberg.types.Types.ListType;
 import org.apache.iceberg.types.Types.LongType;
@@ -32,6 +36,7 @@ import org.apache.iceberg.types.Types.StringType;
 import org.apache.iceberg.types.Types.StructType;
 import org.apache.iceberg.types.Types.VariantType;
 import org.apache.iceberg.variants.Variant;
+import org.apache.parquet.column.schema.EdgeInterpolationAlgorithm;
 import org.apache.parquet.schema.LogicalTypeAnnotation;
 import org.apache.parquet.schema.MessageType;
 import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName;
@@ -681,6 +686,221 @@ public class TestPruneColumns {
     assertThat(wholeActual).as("Whole-map projection is 
unchanged").isEqualTo(fileSchema);
   }
 
+  @Test
+  public void acceptsMatchingGeospatialParameters() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geometryType(null))
+            .id(1)
+            .named("geom_default")
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+            .id(2)
+            .named("geom_projected")
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geographyType(null, null))
+            .id(3)
+            .named("geog_default")
+            .optional(PrimitiveTypeName.BINARY)
+            .as(
+                LogicalTypeAnnotation.geographyType(
+                    "EPSG:4326", EdgeInterpolationAlgorithm.ANDOYER))
+            .id(4)
+            .named("geog_custom")
+            .named("table");
+
+    Schema projection =
+        new Schema(
+            NestedField.optional(1, "geom_default", GeometryType.crs84()),
+            NestedField.optional(2, "geom_projected", 
GeometryType.of("epsg:3857")),
+            NestedField.optional(3, "geog_default", GeographyType.crs84()),
+            NestedField.optional(
+                4, "geog_custom", GeographyType.of("epsg:4326", 
EdgeAlgorithm.ANDOYER)));
+
+    assertThat(ParquetSchemaUtil.pruneColumns(fileSchema, 
projection)).isEqualTo(fileSchema);
+  }
+
+  @Test
+  public void rejectsGeometryCrsMismatch() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+            .id(1)
+            .named("geom")
+            .named("table");
+    Schema projection = new Schema(NestedField.optional(1, "geom", 
GeometryType.crs84()));
+
+    assertThatThrownBy(() -> ParquetSchemaUtil.pruneColumns(fileSchema, 
projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("field geom")
+        .hasMessageContaining("geometry(OGC:CRS84)");
+  }
+
+  @Test
+  public void rejectsGeometryCrsMismatchWithoutIds() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+            .named("geom")
+            .named("table");
+    Schema projection = new Schema(NestedField.optional(1, "geom", 
GeometryType.crs84()));
+
+    assertThatThrownBy(() -> 
ParquetSchemaUtil.pruneColumnsFallback(fileSchema, projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("field geom")
+        .hasMessageContaining("geometry(OGC:CRS84)");
+  }
+
+  @Test
+  public void rejectsProjectedNestedStructGeometryCrsMismatchWithoutIds() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .addField(
+                Types.buildGroup(Type.Repetition.OPTIONAL)
+                    .optional(PrimitiveTypeName.BINARY)
+                    .as(LogicalTypeAnnotation.geometryType(null))
+                    .named("other_geom")
+                    .optional(PrimitiveTypeName.BINARY)
+                    .as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+                    .named("geom")
+                    .named("location"))
+            .named("table");
+    Schema projection =
+        new Schema(
+            NestedField.optional(
+                1,
+                "location",
+                StructType.of(NestedField.optional(3, "geom", 
GeometryType.crs84()))));
+
+    assertThatThrownBy(() -> 
ParquetSchemaUtil.pruneColumnsFallback(fileSchema, projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("field location.geom")
+        .hasMessageContaining("geometry(OGC:CRS84)");
+  }
+
+  @Test
+  public void acceptsProjectedNestedStructGeometryParametersWithoutIds() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .addField(
+                Types.buildGroup(Type.Repetition.OPTIONAL)
+                    .optional(PrimitiveTypeName.BINARY)
+                    .as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+                    .named("other_geom")
+                    .optional(PrimitiveTypeName.BINARY)
+                    .as(LogicalTypeAnnotation.geometryType(null))
+                    .named("geom")
+                    .named("location"))
+            .named("table");
+    Schema projection =
+        new Schema(
+            NestedField.optional(
+                1,
+                "location",
+                StructType.of(NestedField.optional(3, "geom", 
GeometryType.crs84()))));
+
+    assertThat(ParquetSchemaUtil.pruneColumnsFallback(fileSchema, 
projection)).isNotNull();
+  }
+
+  @Test
+  public void rejectsListElementGeometryCrsMismatchWithoutIds() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .addField(
+                Types.buildGroup(Type.Repetition.OPTIONAL)
+                    .addField(
+                        Types.buildGroup(Type.Repetition.REPEATED)
+                            .optional(PrimitiveTypeName.BINARY)
+                            
.as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+                            .named("element")
+                            .named("list"))
+                    .as(LogicalTypeAnnotation.listType())
+                    .named("locations"))
+            .named("table");
+    Schema projection =
+        new Schema(
+            NestedField.optional(1, "locations", ListType.ofOptional(2, 
GeometryType.crs84())));
+
+    assertThatThrownBy(() -> 
ParquetSchemaUtil.pruneColumnsFallback(fileSchema, projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("field locations.list.element")
+        .hasMessageContaining("geometry(OGC:CRS84)");
+  }
+
+  @Test
+  public void rejectsMapValueGeometryCrsMismatchWithoutIds() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .addField(
+                Types.buildGroup(Type.Repetition.OPTIONAL)
+                    .addField(
+                        Types.buildGroup(Type.Repetition.REPEATED)
+                            .required(PrimitiveTypeName.BINARY)
+                            .as(LogicalTypeAnnotation.stringType())
+                            .named("key")
+                            .optional(PrimitiveTypeName.BINARY)
+                            
.as(LogicalTypeAnnotation.geometryType("EPSG:3857"))
+                            .named("value")
+                            .named("key_value"))
+                    .as(LogicalTypeAnnotation.mapType())
+                    .named("locations"))
+            .named("table");
+    Schema projection =
+        new Schema(
+            NestedField.optional(
+                1, "locations", MapType.ofOptional(2, 3, StringType.get(), 
GeometryType.crs84())));
+
+    assertThatThrownBy(() -> 
ParquetSchemaUtil.pruneColumnsFallback(fileSchema, projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("field locations.key_value.value")
+        .hasMessageContaining("geometry(OGC:CRS84)");
+  }
+
+  @Test
+  public void rejectsGeographyCrsMismatch() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .optional(PrimitiveTypeName.BINARY)
+            .as(
+                LogicalTypeAnnotation.geographyType(
+                    "EPSG:4326", EdgeInterpolationAlgorithm.SPHERICAL))
+            .id(1)
+            .named("geog")
+            .named("table");
+    Schema projection = new Schema(NestedField.optional(1, "geog", 
GeographyType.crs84()));
+
+    assertThatThrownBy(() -> ParquetSchemaUtil.pruneColumns(fileSchema, 
projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("geography(OGC:CRS84, spherical)");
+  }
+
+  @Test
+  public void rejectsGeographyAlgorithmMismatch() {
+    MessageType fileSchema =
+        Types.buildMessage()
+            .optional(PrimitiveTypeName.BINARY)
+            .as(LogicalTypeAnnotation.geographyType("OGC:CRS84", 
EdgeInterpolationAlgorithm.KARNEY))
+            .id(1)
+            .named("geog")
+            .named("table");
+    Schema projection = new Schema(NestedField.optional(1, "geog", 
GeographyType.crs84()));
+
+    assertThatThrownBy(() -> ParquetSchemaUtil.pruneColumns(fileSchema, 
projection))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot read Parquet type")
+        .hasMessageContaining("KARNEY")
+        .hasMessageContaining("geography(OGC:CRS84, spherical)");
+  }
+
   private static Type buildVariantType(int id, String name) {
     return Types.buildGroup(Type.Repetition.OPTIONAL)
         .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION))

Reply via email to