-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Spark 4.1: Map geo Spark types #16851
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b9e112b
129153d
582c759
55af6ce
5b01562
6632e21
f4ae297
f5e24be
78941d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
|
|
||
| import static org.apache.iceberg.types.Types.NestedField.optional; | ||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.nio.ByteBuffer; | ||
|
|
@@ -29,12 +30,19 @@ | |
| import org.apache.iceberg.MetadataColumns; | ||
| import org.apache.iceberg.Schema; | ||
| import org.apache.iceberg.expressions.Literal; | ||
| import org.apache.iceberg.types.EdgeAlgorithm; | ||
| import org.apache.iceberg.types.Type; | ||
| import org.apache.iceberg.types.Types; | ||
| import org.apache.spark.sql.catalyst.expressions.AttributeReference; | ||
| import org.apache.spark.sql.catalyst.expressions.MetadataAttribute; | ||
| import org.apache.spark.sql.catalyst.types.DataTypeUtils; | ||
| import org.apache.spark.sql.catalyst.util.ResolveDefaultColumnsUtils$; | ||
| import org.apache.spark.sql.types.DataType; | ||
| import org.apache.spark.sql.types.DataTypes; | ||
| import org.apache.spark.sql.types.GeographyType; | ||
| import org.apache.spark.sql.types.GeographyType$; | ||
| import org.apache.spark.sql.types.GeometryType; | ||
| import org.apache.spark.sql.types.GeometryType$; | ||
| import org.apache.spark.sql.types.Metadata; | ||
| import org.apache.spark.sql.types.StructField; | ||
| import org.apache.spark.sql.types.StructType; | ||
|
|
@@ -93,6 +101,121 @@ public void testSchemaConversionWithMetaDataColumnSchema() { | |
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testGeospatialTypeConversion() { | ||
| // a default-CRS geometry round-trips through the null <-> OGC:CRS84 normalization | ||
| Types.GeometryType defaultGeometry = Types.GeometryType.crs84(); | ||
| DataType sparkDefaultGeometry = SparkSchemaUtil.convert(defaultGeometry); | ||
| assertThat(sparkDefaultGeometry).isInstanceOf(GeometryType.class); | ||
| assertThat(((GeometryType) sparkDefaultGeometry).crs()) | ||
| .isEqualTo(Types.GeometryType.DEFAULT_CRS); | ||
| assertThat(SparkSchemaUtil.convert(sparkDefaultGeometry)).isEqualTo(defaultGeometry); | ||
|
|
||
| // a default-CRS geography round-trips through the null <-> OGC:CRS84 normalization | ||
| Types.GeographyType defaultGeography = Types.GeographyType.crs84(); | ||
| DataType sparkDefaultGeography = SparkSchemaUtil.convert(defaultGeography); | ||
| assertThat(sparkDefaultGeography).isInstanceOf(GeographyType.class); | ||
| assertThat(((GeographyType) sparkDefaultGeography).crs()) | ||
| .isEqualTo(Types.GeographyType.DEFAULT_CRS); | ||
| assertThat(SparkSchemaUtil.convert(sparkDefaultGeography)).isEqualTo(defaultGeography); | ||
|
|
||
| Types.GeometryType geometry = Types.GeometryType.of("EPSG:3857"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this only exercises explicit CRS values. Adding a default-CRS geometry round-trip (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added above |
||
| DataType sparkGeometry = SparkSchemaUtil.convert(geometry); | ||
| assertThat(sparkGeometry).isInstanceOf(GeometryType.class); | ||
| assertThat(((GeometryType) sparkGeometry).crs()).isEqualTo("EPSG:3857"); | ||
| assertThat(SparkSchemaUtil.convert(sparkGeometry)).isEqualTo(geometry); | ||
|
|
||
| Types.GeographyType geography = Types.GeographyType.of("OGC:CRS84"); | ||
| DataType sparkGeography = SparkSchemaUtil.convert(geography); | ||
| assertThat(sparkGeography).isInstanceOf(GeographyType.class); | ||
| assertThat(((GeographyType) sparkGeography).crs()).isEqualTo("OGC:CRS84"); | ||
| assertThat(SparkSchemaUtil.convert(sparkGeography)).isEqualTo(geography); | ||
|
huan233usc marked this conversation as resolved.
huan233usc marked this conversation as resolved.
|
||
|
|
||
| assertThat(SparkSchemaUtil.convert(GeometryType$.MODULE$.apply("EPSG:3857"))) | ||
| .isEqualTo(geometry); | ||
| assertThat(SparkSchemaUtil.convert(GeographyType$.MODULE$.apply("OGC:CRS84"))) | ||
| .isEqualTo(geography); | ||
|
|
||
| Types.GeographyType vincentyGeography = | ||
| Types.GeographyType.of("OGC:CRS84", EdgeAlgorithm.VINCENTY); | ||
| assertThatThrownBy(() -> SparkSchemaUtil.convert(vincentyGeography)) | ||
| .isInstanceOf(UnsupportedOperationException.class) | ||
| .hasMessage("Spark does not support geography edge algorithm: vincenty"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGeospatialCrsUnsupportedBySparkIsRejected() { | ||
| // Iceberg permits any non-empty CRS, but Spark only recognizes a fixed set; a CRS Spark cannot | ||
| // resolve is rejected by Spark's SparkIllegalArgumentException (an IllegalArgumentException). | ||
| Types.GeometryType geometry = Types.GeometryType.of("EPSG:4269"); | ||
| assertThatThrownBy(() -> SparkSchemaUtil.convert(geometry)) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .hasMessageContaining("EPSG:4269"); | ||
|
|
||
| Types.GeographyType geography = Types.GeographyType.of("EPSG:4269"); | ||
| assertThatThrownBy(() -> SparkSchemaUtil.convert(geography)) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .hasMessageContaining("EPSG:4269"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGeospatialMixedSridIsRejected() { | ||
| // Spark models a mixed-SRID column with a sentinel CRS ("SRID:ANY"); Iceberg requires a | ||
| // concrete column-level CRS, so a mixed-SRID Spark type must be rejected, not persisted. | ||
| DataType mixedGeometry = GeometryType$.MODULE$.apply("ANY"); | ||
| assertThatThrownBy(() -> SparkSchemaUtil.convert(mixedGeometry)) | ||
| .isInstanceOf(UnsupportedOperationException.class) | ||
| .hasMessage("Cannot convert Spark geometry with mixed SRID to Iceberg"); | ||
|
|
||
| DataType mixedGeography = GeographyType$.MODULE$.apply("ANY"); | ||
| assertThatThrownBy(() -> SparkSchemaUtil.convert(mixedGeography)) | ||
| .isInstanceOf(UnsupportedOperationException.class) | ||
| .hasMessage("Cannot convert Spark geography with mixed SRID to Iceberg"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testPruneGeospatialTypes() { | ||
| Schema schema = | ||
| new Schema( | ||
| optional(1, "geom", Types.GeometryType.of("EPSG:3857")), | ||
| optional(2, "geog", Types.GeographyType.of("OGC:CRS84")), | ||
| optional(3, "id", Types.LongType.get())); | ||
|
|
||
| StructType requestedType = SparkSchemaUtil.convert(schema); | ||
| Schema pruned = SparkSchemaUtil.prune(schema, requestedType); | ||
|
|
||
| assertThat(pruned.asStruct()).isEqualTo(schema.asStruct()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this covers only the happy path. A negative case (requesting an incompatible Spark type for a geo column, triggering the |
||
| } | ||
|
|
||
| @Test | ||
| public void testPruneGeospatialTypeWithIncompatibleRequestedType() { | ||
| Schema schema = new Schema(optional(1, "geom", Types.GeometryType.of("EPSG:3857"))); | ||
|
|
||
| // requesting a non-geo Spark type for a geometry column must be rejected | ||
| StructType incompatibleType = new StructType().add("geom", DataTypes.BinaryType, true); | ||
|
|
||
| assertThatThrownBy(() -> SparkSchemaUtil.prune(schema, incompatibleType)) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .hasMessageContaining("Cannot project") | ||
| .hasMessageContaining("incompatible type"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testPruneGeospatialTypeWithIncompatibleCrs() { | ||
| // Spark normally copies the type from the table schema, so this defends against a requested geo | ||
| // type whose CRS disagrees with the table's rather than a case reachable through normal reads. | ||
| // Both CRS values must be ones Spark recognizes (else construction fails first); OGC:CRS84 and | ||
| // EPSG:3857 are both valid and differ, exercising the prune-time CRS check. | ||
| Schema schema = new Schema(optional(1, "geom", Types.GeometryType.of("EPSG:3857"))); | ||
|
|
||
| StructType mismatchedCrs = | ||
| new StructType().add("geom", GeometryType$.MODULE$.apply("OGC:CRS84"), true); | ||
|
|
||
| assertThatThrownBy(() -> SparkSchemaUtil.prune(schema, mismatchedCrs)) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .hasMessageContaining("Cannot project geometry with incompatible CRS"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testSchemaConversionWithOnlyWriteDefault() { | ||
| Schema schema = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unlike
DECIMAL, geo types get no additional CRS/SRID compatibility check inprimitive(). Sinceprimitive()returns the table's own type there's no corruption, but a requested geo column with a mismatched CRS passes pruning silently. Is deferring SRID validation intentional (consistent with UUID->String), or worth a follow-up?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I am leaving CRS/SRID validation out of this split for now: pruning only validates the requested Spark type class and returns the table Iceberg type, so it cannot change the table CRS. The CRS/SRID compatibility check belongs with the read/write path where Spark values are materialized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Revisiting this — I think it's worth adding CRS/algorithm checks here now, mirroring
DECIMAL. Unlikely in normal reads (Spark copies types from the table schema), but cheap defensive validation.Example for
primitive():And a test, e.g. table
geometry(EPSG:3857)with requestedGeometryType(EPSG:4326)→Cannot project geometry with incompatible CRS.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, added the CRS/algorithm checks plus a test. The algorithm compare is cross-type (Iceberg vs Spark enum), so I reused
convertAlgorithmto translate first instead of==. Thanks!