diff --git a/common/src/main/java/org/apache/sedona/common/Constructors.java b/common/src/main/java/org/apache/sedona/common/Constructors.java index 42f7241ebb0..19e1550ed56 100644 --- a/common/src/main/java/org/apache/sedona/common/Constructors.java +++ b/common/src/main/java/org/apache/sedona/common/Constructors.java @@ -74,7 +74,7 @@ public static Geometry geomFromWKB(byte[] wkb) throws ParseException { } public static Geometry geomFromWKB(byte[] wkb, int SRID) throws ParseException { - Geometry geom = new WKBReader().read(wkb); + Geometry geom = WKBReader.forDeclaredDimensions().read(wkb); if (geom.getFactory().getSRID() != geom.getSRID() || (SRID >= 0 && geom.getSRID() != SRID)) { // Make sure that the geometry and the geometry factory have the correct SRID if (SRID < 0) { diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index e3057e5ae12..030ec563bde 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -38,6 +38,7 @@ import org.apache.sedona.common.sphere.Spheroid; import org.apache.sedona.common.subDivide.GeometrySubDivider; import org.apache.sedona.common.utils.*; +import org.datasyslab.jts.geom.util.GeometryCopier; import org.locationtech.jts.algorithm.Angle; import org.locationtech.jts.algorithm.MinimumAreaRectangle; import org.locationtech.jts.algorithm.MinimumBoundingCircle; @@ -1103,12 +1104,7 @@ public static Geometry setSRID(Geometry geometry, int srid) { geometry.getPrecisionModel(), srid, geometry.getFactory().getCoordinateSequenceFactory()); - Geometry newGeom = factory.createGeometry(geometry); - // Workaround for JTS bug: GeometryEditor.editPolygon returns the original - // empty polygon without copying it to the new factory, so the SRID is not - // updated for POLYGON EMPTY (and similar empty geometry types). - newGeom.setSRID(srid); - return newGeom; + return GeometryCopier.copy(geometry, factory); } public static int getSRID(Geometry geometry) { diff --git a/common/src/main/java/org/apache/sedona/common/geometrySerde/ByteBufferGeometryBuffer.java b/common/src/main/java/org/apache/sedona/common/geometrySerde/ByteBufferGeometryBuffer.java index e603c67a08f..fd0618f5154 100644 --- a/common/src/main/java/org/apache/sedona/common/geometrySerde/ByteBufferGeometryBuffer.java +++ b/common/src/main/java/org/apache/sedona/common/geometrySerde/ByteBufferGeometryBuffer.java @@ -20,12 +20,12 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; +import org.datasyslab.jts.geom.impl.DeclaredCoordinateSequence; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.CoordinateXYM; import org.locationtech.jts.geom.CoordinateXYZM; -import org.locationtech.jts.geom.impl.CoordinateArraySequence; class ByteBufferGeometryBuffer implements GeometryBuffer { private CoordinateType coordinateType = CoordinateType.XY; @@ -126,20 +126,20 @@ public CoordinateSequence getCoordinate(int offset) { switch (coordinateType) { case XY: coordinates[0] = new CoordinateXY(x, y); - return new CoordinateArraySequence(coordinates, 2, 0); + return new DeclaredCoordinateSequence(coordinates, 2, 0); case XYZ: z = byteBuffer.getDouble(offset + 16); coordinates[0] = new Coordinate(x, y, z); - return new CoordinateArraySequence(coordinates, 3, 0); + return new DeclaredCoordinateSequence(coordinates, 3, 0); case XYM: m = byteBuffer.getDouble(offset + 16); coordinates[0] = new CoordinateXYM(x, y, m); - return new CoordinateArraySequence(coordinates, 3, 1); + return new DeclaredCoordinateSequence(coordinates, 3, 1); case XYZM: z = byteBuffer.getDouble(offset + 16); m = byteBuffer.getDouble(offset + 24); coordinates[0] = new CoordinateXYZM(x, y, z, m); - return new CoordinateArraySequence(coordinates, 4, 1); + return new DeclaredCoordinateSequence(coordinates, 4, 1); default: throw new IllegalStateException("coordinateType was not configured properly"); } @@ -240,7 +240,7 @@ public CoordinateSequence getCoordinates(int offset, int numCoordinates) { default: throw new IllegalStateException("coordinateType was not configured properly"); } - return new CoordinateArraySequence(coordinates, dimension, measures); + return new DeclaredCoordinateSequence(coordinates, dimension, measures); } @Override diff --git a/common/src/main/java/org/apache/sedona/common/geometrySerde/GeometrySerializer.java b/common/src/main/java/org/apache/sedona/common/geometrySerde/GeometrySerializer.java index 1f9a82e8cba..b9edb3467b9 100644 --- a/common/src/main/java/org/apache/sedona/common/geometrySerde/GeometrySerializer.java +++ b/common/src/main/java/org/apache/sedona/common/geometrySerde/GeometrySerializer.java @@ -18,6 +18,8 @@ */ package org.apache.sedona.common.geometrySerde; +import org.datasyslab.jts.geom.impl.DeclaredCoordinateSequence; +import org.datasyslab.jts.geom.impl.DeclaredCoordinateSequenceFactory; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Geometry; @@ -476,12 +478,14 @@ private static void collectCoordinateDimensions( // Measures are explicit CoordinateSequence metadata. A Z dimension is not always explicit: // JTS's default sequence factory represents ordinary XY coordinates as dimension 3 with NaN Z. - // XYZM is unambiguous, while XYZ is recoverable only when at least one Z value is finite. An - // ambiguous sequence does not constrain a multipart geometry whose other members establish the - // shared layout. + // Trusted binary layouts and XYZM are unambiguous. Unmarked XYZ is recoverable only when at + // least one Z value is non-NaN. An ambiguous sequence does not constrain a multipart geometry + // whose other members establish the shared layout. CoordinateType coordinateType = null; if (measures > 0) { coordinateType = spatialDimensions > 2 ? CoordinateType.XYZM : CoordinateType.XYM; + } else if (coordinates instanceof DeclaredCoordinateSequence && spatialDimensions == 3) { + coordinateType = CoordinateType.XYZ; } else if (spatialDimensions == 2) { coordinateType = CoordinateType.XY; } else { @@ -500,7 +504,7 @@ private static int alignedOffset(int offset) { } private static GeometryFactory createGeometryFactory(int srid) { - return new GeometryFactory(PRECISION_MODEL, srid); + return new GeometryFactory(PRECISION_MODEL, srid, DeclaredCoordinateSequenceFactory.instance()); } private static Polygon createEmptyPolygon( diff --git a/common/src/main/java/org/apache/sedona/common/geometrySerde/UnsafeGeometryBuffer.java b/common/src/main/java/org/apache/sedona/common/geometrySerde/UnsafeGeometryBuffer.java index a908a932f3d..2bae400f71a 100644 --- a/common/src/main/java/org/apache/sedona/common/geometrySerde/UnsafeGeometryBuffer.java +++ b/common/src/main/java/org/apache/sedona/common/geometrySerde/UnsafeGeometryBuffer.java @@ -19,12 +19,12 @@ package org.apache.sedona.common.geometrySerde; import java.lang.reflect.Field; +import org.datasyslab.jts.geom.impl.DeclaredCoordinateSequence; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.CoordinateXYM; import org.locationtech.jts.geom.CoordinateXYZM; -import org.locationtech.jts.geom.impl.CoordinateArraySequence; import sun.misc.Unsafe; class UnsafeGeometryBuffer implements GeometryBuffer { @@ -173,20 +173,20 @@ public CoordinateSequence getCoordinate(int offset) { switch (coordinateType) { case XY: coordinates[0] = new CoordinateXY(x, y); - return new CoordinateArraySequence(coordinates, 2, 0); + return new DeclaredCoordinateSequence(coordinates, 2, 0); case XYZ: z = UNSAFE.getDouble(bytes, coordOffset + 16); coordinates[0] = new Coordinate(x, y, z); - return new CoordinateArraySequence(coordinates, 3, 0); + return new DeclaredCoordinateSequence(coordinates, 3, 0); case XYM: m = UNSAFE.getDouble(bytes, coordOffset + 16); coordinates[0] = new CoordinateXYM(x, y, m); - return new CoordinateArraySequence(coordinates, 3, 1); + return new DeclaredCoordinateSequence(coordinates, 3, 1); case XYZM: z = UNSAFE.getDouble(bytes, coordOffset + 16); m = UNSAFE.getDouble(bytes, coordOffset + 24); coordinates[0] = new CoordinateXYZM(x, y, z, m); - return new CoordinateArraySequence(coordinates, 4, 1); + return new DeclaredCoordinateSequence(coordinates, 4, 1); default: throw new IllegalStateException("coordinateType was not configured properly"); } @@ -293,7 +293,7 @@ public CoordinateSequence getCoordinates(int offset, int numCoordinates) { default: throw new IllegalStateException("coordinateType was not configured properly"); } - return new CoordinateArraySequence(coordinates, dimension, measures); + return new DeclaredCoordinateSequence(coordinates, dimension, measures); } @Override diff --git a/common/src/main/java/org/apache/sedona/common/utils/FormatUtils.java b/common/src/main/java/org/apache/sedona/common/utils/FormatUtils.java index 42e4e4bc82f..9994b8945a4 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/FormatUtils.java +++ b/common/src/main/java/org/apache/sedona/common/utils/FormatUtils.java @@ -207,10 +207,7 @@ public Geometry readWkt(String line) throws ParseException { public Geometry readWkb(String line) throws ParseException { final String[] columns = line.split(splitter.getDelimiter()); final byte[] aux = WKBReader.hexToBytes(columns[this.startOffset]); - // For some unknown reasons, the wkb reader cannot be used in transient variable like the wkt - // reader. - WKBReader wkbReader = new WKBReader(); - Geometry geometry = wkbReader.read(aux); + Geometry geometry = WKBReader.forDeclaredDimensions().read(aux); if (geometry.getSRID() != geometry.getFactory().getSRID()) { // Make sure that the geometry factory has the correct SRID when the parsed WKB // contains a non-zero SRID (EWKB) diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java index 0bd0c833090..3df0d1aacc1 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -37,6 +37,8 @@ import org.junit.Test; import org.locationtech.jts.geom.*; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.impl.CoordinateArraySequenceFactory; +import org.locationtech.jts.geom.impl.PackedCoordinateSequenceFactory; import org.locationtech.jts.geom.prep.PreparedGeometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; import org.locationtech.jts.io.ParseException; @@ -4676,6 +4678,166 @@ public void setSRIDEmptyGeometries() throws ParseException { } } + @Test + public void setSRIDPreservesEmptyPolygonHoles() throws ParseException { + Polygon source = + (Polygon) Constructors.geomFromWKT("POLYGON ((0 0, 10 0, 10 10, 0 0), EMPTY)", 100); + + Polygon result = (Polygon) Functions.setSRID(source, 4326); + + assertEquals(1, result.getNumInteriorRing()); + assertTrue(result.getInteriorRingN(0).isEmpty()); + assertEquals(source.getExteriorRing(), result.getExteriorRing()); + assertNotSame(source.getInteriorRingN(0), result.getInteriorRingN(0)); + assertGeometryTreeUsesFactory(result, result.getFactory(), 4326); + assertEquals(100, source.getSRID()); + assertEquals(1, source.getNumInteriorRing()); + } + + @Test + public void setSRIDCopiesEmptyPolygonWithoutMutatingInput() throws ParseException { + Polygon source = (Polygon) Constructors.geomFromWKT("POLYGON EMPTY", 100); + source.setUserData("source metadata"); + source.getExteriorRing().setUserData("shell metadata"); + + Polygon result = (Polygon) Functions.setSRID(source, 4326); + + assertEquals(100, source.getSRID()); + assertNotSame(source, result); + assertNotSame(source.getExteriorRing(), result.getExteriorRing()); + assertTrue(result.isEmpty()); + assertGeometryTreeUsesFactory(result, result.getFactory(), 4326); + assertNull(result.getUserData()); + assertNull(result.getExteriorRing().getUserData()); + assertEquals("source metadata", source.getUserData()); + assertEquals("shell metadata", source.getExteriorRing().getUserData()); + } + + @Test + public void setSRIDPreservesNestedEmptyComponentsAndCopiesStructure() { + GeometryFactory sourceFactory = + new GeometryFactory(new PrecisionModel(), 100, CoordinateArraySequenceFactory.instance()); + Point emptyPoint = + sourceFactory.createPoint(sourceFactory.getCoordinateSequenceFactory().create(0, 3, 0)); + Polygon emptyPolygon = + sourceFactory.createPolygon( + sourceFactory.createLinearRing( + sourceFactory.getCoordinateSequenceFactory().create(0, 4, 1))); + Point populatedPoint = sourceFactory.createPoint(new Coordinate(1, 2)); + GeometryCollection nested = + sourceFactory.createGeometryCollection(new Geometry[] {emptyPolygon, populatedPoint}); + GeometryCollection source = + sourceFactory.createGeometryCollection(new Geometry[] {emptyPoint, nested}); + source.setSRID(100); + emptyPoint.setSRID(101); + nested.setSRID(102); + emptyPolygon.setSRID(103); + populatedPoint.setSRID(104); + source.setUserData("root metadata"); + emptyPoint.setUserData("child metadata"); + nested.setUserData("nested metadata"); + + GeometryCollection result = (GeometryCollection) Functions.setSRID(source, 4326); + + assertEquals(2, result.getNumGeometries()); + assertEquals(2, result.getGeometryN(1).getNumGeometries()); + assertGeometryTreeUsesFactory(result, result.getFactory(), 4326); + assertNull(result.getUserData()); + assertNull(result.getGeometryN(0).getUserData()); + assertNull(result.getGeometryN(1).getUserData()); + assertEquals(3, ((Point) result.getGeometryN(0)).getCoordinateSequence().getDimension()); + assertEquals( + 4, + ((Polygon) result.getGeometryN(1).getGeometryN(0)) + .getExteriorRing() + .getCoordinateSequence() + .getDimension()); + assertEquals( + 1, + ((Polygon) result.getGeometryN(1).getGeometryN(0)) + .getExteriorRing() + .getCoordinateSequence() + .getMeasures()); + assertNotSame(source, result); + assertNotSame(source.getGeometryN(0), result.getGeometryN(0)); + Point resultPoint = (Point) result.getGeometryN(1).getGeometryN(1); + resultPoint.getCoordinateSequence().setOrdinate(0, 0, 9); + assertEquals(1, populatedPoint.getX(), 0); + assertEquals(100, source.getSRID()); + assertEquals(101, emptyPoint.getSRID()); + assertEquals(102, nested.getSRID()); + assertEquals(103, emptyPolygon.getSRID()); + assertEquals(104, populatedPoint.getSRID()); + assertEquals("root metadata", source.getUserData()); + assertEquals("child metadata", emptyPoint.getUserData()); + assertEquals("nested metadata", nested.getUserData()); + } + + @Test + public void setSRIDPreservesPackedCoordinateSequenceFactoryAndEmptyLayouts() { + GeometryFactory sourceFactory = + new GeometryFactory( + new PrecisionModel(), 7, PackedCoordinateSequenceFactory.DOUBLE_FACTORY); + Point emptyXym = + sourceFactory.createPoint(sourceFactory.getCoordinateSequenceFactory().create(0, 3, 1)); + LineString emptyXyz = + sourceFactory.createLineString( + sourceFactory.getCoordinateSequenceFactory().create(0, 3, 0)); + LinearRing emptyXyzmShell = + sourceFactory.createLinearRing( + sourceFactory.getCoordinateSequenceFactory().create(0, 4, 1)); + GeometryCollection source = + sourceFactory.createGeometryCollection( + new Geometry[] { + sourceFactory.createMultiPoint(new Point[] {emptyXym}), + sourceFactory.createMultiLineString(new LineString[] {emptyXyz}), + sourceFactory.createMultiPolygon( + new Polygon[] {sourceFactory.createPolygon(emptyXyzmShell)}) + }); + + GeometryCollection result = (GeometryCollection) Functions.setSRID(source, 3857); + + assertSame( + PackedCoordinateSequenceFactory.DOUBLE_FACTORY, + result.getFactory().getCoordinateSequenceFactory()); + assertEquals(1, result.getGeometryN(0).getNumGeometries()); + assertEquals(1, result.getGeometryN(1).getNumGeometries()); + assertEquals(1, result.getGeometryN(2).getNumGeometries()); + CoordinateSequence pointSequence = + ((Point) result.getGeometryN(0).getGeometryN(0)).getCoordinateSequence(); + CoordinateSequence lineSequence = + ((LineString) result.getGeometryN(1).getGeometryN(0)).getCoordinateSequence(); + CoordinateSequence shellSequence = + ((Polygon) result.getGeometryN(2).getGeometryN(0)) + .getExteriorRing() + .getCoordinateSequence(); + assertEquals(3, pointSequence.getDimension()); + assertEquals(1, pointSequence.getMeasures()); + assertEquals(3, lineSequence.getDimension()); + assertEquals(0, lineSequence.getMeasures()); + assertEquals(4, shellSequence.getDimension()); + assertEquals(1, shellSequence.getMeasures()); + assertGeometryTreeUsesFactory(result, result.getFactory(), 3857); + assertEquals(7, source.getFactory().getSRID()); + } + + private static void assertGeometryTreeUsesFactory( + Geometry geometry, GeometryFactory factory, int srid) { + assertSame(factory, geometry.getFactory()); + assertEquals(srid, geometry.getSRID()); + assertEquals(srid, geometry.getFactory().getSRID()); + for (int i = 0; i < geometry.getNumGeometries(); i++) { + Geometry child = geometry.getGeometryN(i); + if (child != geometry) assertGeometryTreeUsesFactory(child, factory, srid); + } + if (geometry instanceof Polygon) { + Polygon polygon = (Polygon) geometry; + assertGeometryTreeUsesFactory(polygon.getExteriorRing(), factory, srid); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) + assertGeometryTreeUsesFactory(polygon.getInteriorRingN(i), factory, srid); + } + } + @Test public void closestPoint() { Point point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1)); diff --git a/common/src/test/java/org/apache/sedona/common/geometrySerde/GeometryDimensionSerdeTest.java b/common/src/test/java/org/apache/sedona/common/geometrySerde/GeometryDimensionSerdeTest.java index c442ac5eb97..a48f3e6e5a6 100644 --- a/common/src/test/java/org/apache/sedona/common/geometrySerde/GeometryDimensionSerdeTest.java +++ b/common/src/test/java/org/apache/sedona/common/geometrySerde/GeometryDimensionSerdeTest.java @@ -19,9 +19,17 @@ package org.apache.sedona.common.geometrySerde; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.sedona.common.Constructors; +import org.apache.sedona.common.Functions; +import org.datasyslab.jts.geom.util.GeometryCopier; +import org.datasyslab.jts.io.WKBReader; import org.junit.Test; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; @@ -40,10 +48,293 @@ import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.impl.CoordinateArraySequence; import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKBWriter; import org.locationtech.jts.io.WKTReader; public class GeometryDimensionSerdeTest { private static final GeometryFactory FACTORY = new GeometryFactory(); + private static final WkbLayout[] WKB_LAYOUTS = { + new WkbLayout(CoordinateType.XY, 2, 0, 0, 0), + new WkbLayout(CoordinateType.XYZ, 3, 0, 1000, 0x80000000), + new WkbLayout(CoordinateType.XYM, 3, 1, 2000, 0x40000000), + new WkbLayout(CoordinateType.XYZM, 4, 1, 3000, 0xc0000000) + }; + + private static final class WkbLayout { + final CoordinateType coordinateType; + final int dimension; + final int measures; + final int isoOffset; + final int ewkbFlags; + + WkbLayout( + CoordinateType coordinateType, int dimension, int measures, int isoOffset, int ewkbFlags) { + this.coordinateType = coordinateType; + this.dimension = dimension; + this.measures = measures; + this.isoOffset = isoOffset; + this.ewkbFlags = ewkbFlags; + } + } + + @Test + public void nestedCollectionsAndEmptyMultipartMembersSurviveFactoryCopies() + throws ParseException { + Geometry empty = + Constructors.geomFromWKB(new WKBWriter(3).write(new WKTReader().read("POINT Z EMPTY"))); + GeometryFactory factory = empty.getFactory(); + Geometry[] parts = + new Geometry[] { + factory.createMultiPoint( + new Point[] {(Point) empty, factory.createPoint(new Coordinate(1, 2, 3))}), + factory.createMultiLineString( + new LineString[] { + (LineString) + Constructors.geomFromWKB( + new WKBWriter(3).write(new WKTReader().read("LINESTRING Z EMPTY"))), + factory.createLineString( + new Coordinate[] {new Coordinate(1, 2, 3), new Coordinate(4, 5, 6)}) + }), + factory.createMultiPolygon( + new Polygon[] { + (Polygon) + Constructors.geomFromWKB( + new WKBWriter(3).write(new WKTReader().read("POLYGON Z EMPTY"))) + }) + }; + GeometryCollection nested = + factory.createGeometryCollection( + new Geometry[] { + factory.createGeometryCollection(parts), FACTORY.createPoint(new CoordinateXY(7, 8)) + }); + nested.setUserData("metadata"); + Geometry changed = Functions.setSRID(nested, 4326); + assertEquals(0, nested.getSRID()); + assertEquals(4326, changed.getSRID()); + assertEquals(4326, changed.getFactory().getSRID()); + assertNull(changed.getUserData()); + Geometry output = roundTrip(changed); + assertEquals(2, output.getNumGeometries()); + Geometry children = output.getGeometryN(0); + for (int i = 0; i < parts.length; i++) { + Geometry part = children.getGeometryN(i); + assertEquals(parts[i].getNumGeometries(), part.getNumGeometries()); + assertTrue(part.getGeometryN(0).isEmpty()); + assertEquals(CoordinateType.XYZ, coordinateType(GeometrySerializer.serialize(part))); + } + assertEquals( + CoordinateType.XY, coordinateType(GeometrySerializer.serialize(output.getGeometryN(1)))); + } + + @Test + public void hexWkbReaderPreservesEmptyZAndInputData() throws ParseException { + byte[] bytes = new WKBWriter(3, true).write(new WKTReader().read("POLYGON Z EMPTY")); + org.apache.sedona.common.utils.FormatUtils reader = + new org.apache.sedona.common.utils.FormatUtils( + org.apache.sedona.common.enums.FileDataSplitter.WKB, true); + Geometry geometry = reader.readWkb(WKBWriter.toHex(bytes) + "\tmetadata"); + assertEquals("metadata", geometry.getUserData()); + assertEquals(CoordinateType.XYZ, coordinateType(GeometrySerializer.serialize(geometry))); + } + + @Test + public void isoAndEwkbLayoutsSurviveCopiesAndWireBuffers() throws ParseException { + for (boolean iso : new boolean[] {false, true}) { + for (WkbLayout layout : WKB_LAYOUTS) { + int dimension = layout.dimension; + int measures = layout.measures; + CoordinateType expected = layout.coordinateType; + for (int primitive = 1; primitive <= 3; primitive++) { + for (boolean empty : new boolean[] {false, true}) { + // Populated lines/polygons are covered elsewhere; this also exercises all-NaN Z/M. + if (!empty && primitive != 1) continue; + ByteBuffer wkb = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN); + wkb.put((byte) 1); + int type = + iso ? primitive + layout.isoOffset : primitive | layout.ewkbFlags | 0x20000000; + wkb.putInt(type); + if (!iso) wkb.putInt(4326); + if (primitive == 1) { + wkb.putDouble(empty ? Double.NaN : 1).putDouble(empty ? Double.NaN : 2); + for (int ordinate = 2; ordinate < dimension; ordinate++) wkb.putDouble(Double.NaN); + } else { + wkb.putInt(0); + } + Geometry geometry = + Constructors.geomFromWKB(java.util.Arrays.copyOf(wkb.array(), wkb.position())); + assertEquals(iso ? 0 : 4326, geometry.getSRID()); + geometry.setUserData("retained"); + assertEquals("retained", geometry.copy().getUserData()); + for (String buffer : new String[] {"bytebuffer", "unsafe"}) { + Geometry decoded = + GeometrySerializer.deserialize( + GeometryBufferFactory.wrap(buffer, GeometrySerializer.serialize(geometry))); + for (Geometry copy : + new Geometry[] { + geometry.copy(), + geometry.reverse(), + GeometryCopier.copy(geometry, geometry.getFactory()), + decoded, + decoded.copy(), + decoded.reverse(), + Functions.setSRID(decoded, 3857) + }) { + assertNotSame(geometry, copy); + byte[] bytes = GeometrySerializer.serialize(copy); + assertEquals(expected, coordinateType(bytes)); + Geometry output = GeometrySerializer.deserialize(bytes); + CoordinateSequence sequence = + output instanceof Point + ? ((Point) output).getCoordinateSequence() + : output instanceof LineString + ? ((LineString) output).getCoordinateSequence() + : ((Polygon) output).getExteriorRing().getCoordinateSequence(); + assertSequenceLayout(sequence, dimension, measures); + } + } + } + } + } + } + } + + @Test + public void declaredFactoryDoesNotPromoteOrdinaryCoordinates() throws ParseException { + Geometry source = + Constructors.geomFromWKB(new WKBWriter(3).write(new WKTReader().read("POINT Z EMPTY"))); + Geometry ordinary = source.getFactory().createPoint(new Coordinate(1, 2)); + assertEquals(CoordinateType.XY, coordinateType(GeometrySerializer.serialize(ordinary))); + assertEquals( + CoordinateType.XY, + coordinateType( + GeometrySerializer.serialize( + source.getFactory().createGeometry(FACTORY.createPoint(new Coordinate(1, 2)))))); + assertThrows(ParseException.class, () -> Constructors.geomFromWKB(new byte[] {1, 1})); + } + + @Test + public void derivedCoordinatesFromBinaryGeometryFactoriesRemainXy() throws ParseException { + Geometry polygon = new WKTReader().read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"); + for (Geometry input : + new Geometry[] { + roundTrip(polygon), Constructors.geomFromWKB(new WKBWriter().write(polygon)) + }) { + GeometryFactory factory = input.getFactory(); + Geometry points = factory.createMultiPointFromCoords(new Coordinate[] {new Coordinate(3, 4)}); + assertEquals(CoordinateType.XY, coordinateType(GeometrySerializer.serialize(points))); + assertEquals(3, points.getCoordinate().x, 0); + assertEquals(4, points.getCoordinate().y, 0); + CoordinateSequence coordinates = factory.getCoordinateSequenceFactory().create(2, 3); + coordinates.setOrdinate(0, 0, 1); + coordinates.setOrdinate(0, 1, 2); + coordinates.setOrdinate(1, 0, 3); + coordinates.setOrdinate(1, 1, 4); + assertEquals( + CoordinateType.XY, + coordinateType(GeometrySerializer.serialize(factory.createLineString(coordinates)))); + Geometry generated = Functions.generatePoints(input, 3, 100); + assertEquals(3, generated.getNumGeometries()); + assertTrue(input.covers(generated)); + assertEquals(CoordinateType.XY, coordinateType(GeometrySerializer.serialize(generated))); + } + } + + @Test + public void wkbResultsUseOrdinaryAllocationForEveryGeometryType() throws ParseException { + for (String wkt : + new String[] { + "POINT EMPTY", + "LINESTRING EMPTY", + "POLYGON EMPTY", + "POLYGON ((0 0, 4 0, 0 4, 0 0), (1 1, 2 1, 1 2, 1 1))", + "MULTIPOINT (EMPTY, (1 2))", + "MULTILINESTRING (EMPTY, (0 0, 1 1))", + "MULTIPOLYGON (EMPTY, ((0 0, 4 0, 0 4, 0 0)))", + "GEOMETRYCOLLECTION (POINT EMPTY, GEOMETRYCOLLECTION (POLYGON EMPTY))" + }) { + Geometry geometry = + Constructors.geomFromWKB(new WKBWriter().write(new WKTReader().read(wkt))); + geometry.apply( + (org.locationtech.jts.geom.GeometryComponentFilter) + component -> { + Geometry points = + component + .getFactory() + .createMultiPointFromCoords(new Coordinate[] {new Coordinate(3, 4)}); + assertEquals( + wkt, CoordinateType.XY, coordinateType(GeometrySerializer.serialize(points))); + }); + } + } + + @Test + public void wkbReaderPreservesMemberSridsAndDefaultSrid() throws ParseException { + ByteBuffer bytes = ByteBuffer.allocate(9 + 2 * 25).order(ByteOrder.LITTLE_ENDIAN); + bytes.put((byte) 1).putInt(7).putInt(2); + bytes.put((byte) 1).putInt(0x20000001).putInt(4326).putDouble(1).putDouble(2); + bytes.put((byte) 1).putInt(0x20000001).putInt(3857).putDouble(3).putDouble(4); + Geometry collection = WKBReader.forDeclaredDimensions(27700).read(bytes.array()); + assertEquals(27700, collection.getSRID()); + assertEquals(4326, collection.getGeometryN(0).getSRID()); + assertEquals(3857, collection.getGeometryN(1).getSRID()); + assertEquals(1, collection.getGeometryN(0).getCoordinate().x, 0); + assertEquals(4, collection.getGeometryN(1).getCoordinate().y, 0); + } + + @Test + public void factoryCopiesAndSetSridUseTheSameUserDataPolicy() throws ParseException { + Geometry point = new WKTReader().read("POINT (1 2)"); + for (Geometry input : new Geometry[] {point, roundTrip(point)}) { + input.setUserData("child metadata"); + Geometry collection = input.getFactory().createGeometryCollection(new Geometry[] {input}); + collection.setUserData("parent metadata"); + for (Geometry copy : + new Geometry[] { + collection.getFactory().createGeometry(collection), Functions.setSRID(collection, 4326) + }) { + assertNull(copy.getUserData()); + assertNull(copy.getGeometryN(0).getUserData()); + } + assertEquals("parent metadata", collection.getUserData()); + assertEquals("child metadata", input.getUserData()); + } + } + + @Test + public void declaredWkbZSurvivesEmptyAndNaNCoordinates() throws ParseException { + for (String wkt : + new String[] { + "POINT Z EMPTY", + "LINESTRING Z EMPTY", + "POLYGON Z EMPTY", + "POINT Z (1 2 NaN)", + "LINESTRING Z (1 2 NaN, 3 4 NaN)" + }) { + byte[] wkb = new WKBWriter(3).write(new WKTReader().read(wkt.replace("NaN", "9"))); + // WKBWriter infers populated Z from values; supply explicit NaN Z ordinates in the bytes. + if (wkt.contains("NaN")) { + ByteBuffer ordinates = ByteBuffer.wrap(wkb).order(ByteOrder.BIG_ENDIAN); + int start = wkt.startsWith("POINT") ? 5 : 9; + for (int offset = start + 16; offset < wkb.length; offset += 24) { + ordinates.putDouble(offset, Double.NaN); + } + } + Geometry input = Constructors.geomFromWKB(wkb); + assertEquals(wkt, CoordinateType.XYZ, coordinateType(GeometrySerializer.serialize(input))); + for (String buffer : new String[] {"bytebuffer", "unsafe"}) { + Geometry decoded = + GeometrySerializer.deserialize( + GeometryBufferFactory.wrap(buffer, GeometrySerializer.serialize(input))); + for (Geometry transformed : + new Geometry[] { + decoded, decoded.copy(), decoded.reverse(), Functions.setSRID(decoded, 4326) + }) { + assertEquals( + wkt, CoordinateType.XYZ, coordinateType(GeometrySerializer.serialize(transformed))); + } + } + } + } @Test public void leadingNaNDoesNotDropLaterOrdinates() { @@ -162,6 +453,45 @@ public void ordinaryJtsXyRemainsXy() throws ParseException { assertSequenceLayout(output.getCoordinateSequence(), 2, 0); } + @Test + public void mixedDeclaredLayoutsRequireGeometryCollections() throws ParseException { + for (String[] wkts : + new String[][] { + {"POINT (1 2)", "POINT Z EMPTY"}, + {"LINESTRING (0 0, 1 1)", "LINESTRING Z EMPTY"}, + {"POLYGON ((0 0, 4 0, 0 4, 0 0))", "POLYGON Z EMPTY"} + }) { + Geometry xy = roundTrip(new WKTReader().read(wkts[0])); + Geometry z = + roundTrip( + Constructors.geomFromWKB(new WKBWriter(3).write(new WKTReader().read(wkts[1])))); + assertMixedLayoutsRequireCollection(xy, z); + } + ByteBuffer point = ByteBuffer.allocate(29).order(ByteOrder.LITTLE_ENDIAN); + point.put((byte) 1).putInt(1001).putDouble(1).putDouble(2).putDouble(Double.NaN); + assertMixedLayoutsRequireCollection( + roundTrip(new WKTReader().read("POINT (3 4)")), + roundTrip(Constructors.geomFromWKB(point.array()))); + } + + private static void assertMixedLayoutsRequireCollection(Geometry xy, Geometry z) { + for (Geometry[] members : new Geometry[][] {{xy, z}, {z, xy}}) { + Geometry multipart = Functions.createMultiGeometry(members); + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> GeometrySerializer.serialize(multipart)); + assertTrue(error.getMessage().contains("heterogeneous dimensional layouts")); + Geometry collection = roundTrip(FACTORY.createGeometryCollection(members)); + assertEquals(2, collection.getNumGeometries()); + for (int i = 0; i < members.length; i++) { + assertEquals(members[i].isEmpty(), collection.getGeometryN(i).isEmpty()); + assertEquals( + members[i] == xy ? CoordinateType.XY : CoordinateType.XYZ, + coordinateType(GeometrySerializer.serialize(collection.getGeometryN(i)))); + } + } + } + @Test public void rejectsRecoverablyHeterogeneousMultipartLayouts() { LineString xy = diff --git a/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md b/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md index 76415b03d6a..e6bc078c887 100644 --- a/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md +++ b/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md @@ -41,6 +41,12 @@ All non-null `Geography` values in a group must have the same SRID; a group cont SRIDs is rejected. In contrast, scalar [`ST_Collect`](../Geometry-Editors/ST_Collect.md) uses the first non-null Geography input's SRID for the output. +For `Geometry` inputs, a multipart result must have a consistent coordinate layout (XY, XYZ, +XYM, or XYZM). Starting in 2.0.0, dimensions declared in WKB are retained even for empty geometries +or NaN Z values. Combining WKB-declared XY and XYZ components, such as an XY point and +`POINT Z EMPTY`, raises a heterogeneous-layout error during serialization. To preserve each member's layout in a +`GeometryCollection`, apply `ST_ForceCollection` to each input before collecting it. + SQL Example ```sql diff --git a/docs/api/sql/Geometry-Editors/ST_Collect.md b/docs/api/sql/Geometry-Editors/ST_Collect.md index 31f4b76271a..b863b5fb227 100644 --- a/docs/api/sql/Geometry-Editors/ST_Collect.md +++ b/docs/api/sql/Geometry-Editors/ST_Collect.md @@ -44,6 +44,12 @@ the first non-null input supplies the output SRID; later inputs are not required SRID. In contrast, [`ST_Collect_Agg`](../Aggregate-Functions/ST_Collect_Agg.md) rejects a group containing mixed Geography SRIDs. +For `Geometry` inputs, a multipart result must have a consistent coordinate layout (XY, XYZ, +XYM, or XYZM). Starting in 2.0.0, dimensions declared in WKB are retained even for empty geometries +or NaN Z values. Combining WKB-declared XY and XYZ components, such as an XY point and +`POINT Z EMPTY`, raises a heterogeneous-layout error during serialization. To preserve each member's layout in a +`GeometryCollection`, apply `ST_ForceCollection` to each input before collecting it. + SQL Example ```sql diff --git a/pom.xml b/pom.xml index 55d1c702acf..230a4f37e37 100644 --- a/pom.xml +++ b/pom.xml @@ -73,7 +73,7 @@ 3.2.4 2.13.4 1.20.0 - 1.21.0-datasyslab-1 + 1.21.0-datasyslab-2 0.16.1 0.8 diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py index 50197c3135d..b0985980829 100644 --- a/python/tests/geopandas/test_geoseries.py +++ b/python/tests/geopandas/test_geoseries.py @@ -748,6 +748,41 @@ def test_constructor_null_and_empty_controls(self, values): check_index_type=False, ) + @pytest.mark.parametrize( + "wkt", + [ + "POINT Z EMPTY", + "LINESTRING Z EMPTY", + "POLYGON Z EMPTY", + "POINT Z (1 2 NaN)", + "POINT Z (1 2 3)", + "POINT (1 2)", + ], + ) + def test_constructor_leading_null_preserves_declared_dimensions(self, wkt): + import shapely + + if not hasattr(shapely, "geos_version") or shapely.geos_version < (3, 12, 0): + pytest.skip("Declared NaN Z requires GEOS 3.12 or newer") + _ = self.spark + geometry = shapely.from_wkt(wkt) + local = gpd.GeoSeries( + [None, geometry, None, Point(4, 5)], + index=pd.Index([9, 2, 9, 1], name="row"), + name="shape", + ) + result = GeoSeries(local).to_geopandas() + pd.testing.assert_index_equal(result.index, local.index) + assert result.name == local.name + assert result.iloc[0] is None and result.iloc[2] is None + assert shapely.get_coordinate_dimension( + result.iloc[1] + ) == shapely.get_coordinate_dimension(geometry) + assert result.iloc[1].is_empty == geometry.is_empty + assert result.iloc[3].equals(Point(4, 5)) + if not geometry.is_empty: + assert result.iloc[1].x == geometry.x and result.iloc[1].y == geometry.y + def test_constructor_leading_null_preserves_embedded_srid(self): from shapely import wkb from sedona.spark.sql.types import GeometryType diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/GeometryUdtTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/GeometryUdtTestScala.scala index 2aae4ea39fb..ed566e26126 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/GeometryUdtTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/GeometryUdtTestScala.scala @@ -41,6 +41,32 @@ class GeometryUdtTestScala extends TestBaseScala with BeforeAndAfter { } describe("GeometryUDT Test") { + it("Should preserve declared Z through WKB and the geometry UDT") { + val wkts = + Seq("POINT Z EMPTY", "LINESTRING Z EMPTY", "POLYGON Z EMPTY", "POINT Z (1 2 NaN)") + wkts.foreach { wkt => + val wkb = new org.locationtech.jts.io.WKBWriter(3) + .write(new WKTReader().read(wkt.replace("NaN", "9"))) + if (wkt.contains("NaN")) { + java.nio.ByteBuffer.wrap(wkb).putDouble(21, Double.NaN) + } + val hex = org.locationtech.jts.io.WKBWriter.toHex(wkb) + val geometry = sparkSession + .sql(s"SELECT ST_SetSRID(ST_GeomFromWKB(unhex('$hex')), 4326)") + .collect()(0) + .getAs[Geometry](0) + val sequence = geometry match { + case point: org.locationtech.jts.geom.Point => point.getCoordinateSequence + case line: org.locationtech.jts.geom.LineString => line.getCoordinateSequence + case polygon: org.locationtech.jts.geom.Polygon => + polygon.getExteriorRing.getCoordinateSequence + } + assert(sequence.getDimension == 3, wkt) + assert(sequence.getMeasures == 0, wkt) + assert(geometry.getSRID == 4326) + } + } + it("Should write dataframe with geometry in Parquet format") { tempFolder.create() diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala index 1bf7154b4a9..a3d950748d2 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala @@ -29,7 +29,7 @@ import org.apache.spark.sql.{DataFrame, Row} import org.geotools.referencing.CRS import org.junit.Assert.{assertEquals, assertFalse, assertTrue} import org.locationtech.jts.algorithm.MinimumBoundingCircle -import org.locationtech.jts.geom.{Coordinate, Geometry, GeometryFactory, Polygon} +import org.locationtech.jts.geom.{Coordinate, Geometry, GeometryFactory, Point, Polygon} import org.locationtech.jts.io.WKTWriter import org.locationtech.jts.linearref.LengthIndexedLine import org.locationtech.jts.operation.distance3d.Distance3DOp @@ -1036,6 +1036,27 @@ class functionTestScala assert(df.first().get(0).asInstanceOf[Polygon].getSRID == 3021) } + it("ST_SetSRID preserves empty polygon holes") { + val polygonWithEmptyHole = + "01030000000200000005000000000000000000000000000000000000000000000000002440000000000000000000000000000024400000000000002440000000000000000000000000000024400000000000000000000000000000000000000000" + val result = sparkSession + .sql(s""" + |WITH source AS ( + | SELECT ST_GeomFromWKB(unhex('$polygonWithEmptyHole')) AS polygon + |) + |SELECT + | ST_NumInteriorRings(polygon), + | ST_NumInteriorRings(ST_SetSRID(polygon, 4326)), + | ST_SRID(ST_SetSRID(polygon, 4326)) + |FROM source + |""".stripMargin) + .first() + + assertEquals(1, result.getInt(0)) + assertEquals(1, result.getInt(1)) + assertEquals(4326, result.getInt(2)) + } + it("Passed ST_AsHEXEWKB") { val baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POINT(1 2)') as point") var actual = baseDf.selectExpr("ST_AsHEXEWKB(point)").first().get(0) @@ -3457,6 +3478,46 @@ class functionTestScala assertEquals(expected, actual) } + it("Should keep ST_GeneratePoints output XY for WKB and materialized polygon inputs") { + val polygonWkb = + "010300000001000000050000000000000000000000000000000000000000000000000024400000000000000000000000000000244000000000000024400000000000000000000000000000244000000000000000000000000000000000" + + def generatedPointDimensions(points: Geometry): Seq[Int] = { + assertEquals(8, points.getNumGeometries) + (0 until points.getNumGeometries).map { index => + val point = points.getGeometryN(index).asInstanceOf[Point] + assertTrue(point.getX >= 0 && point.getX <= 10) + assertTrue(point.getY >= 0 && point.getY <= 10) + point.getCoordinateSequence.getDimension + } + } + + val direct = sparkSession + .sql(s"SELECT ST_GeneratePoints(ST_GeomFromWKB(unhex('$polygonWkb')), 8, 42) AS points") + .first() + .getAs[Geometry]("points") + + val polygons = sparkSession + .sql(s"SELECT ST_GeomFromWKB(unhex('$polygonWkb')) AS polygon") + .repartition(2) + .cache() + try { + polygons.collect() + polygons.createOrReplaceTempView("materialized_xy_polygon") + val materialized = sparkSession + .sql("SELECT ST_GeneratePoints(polygon, 8, 42) AS points FROM materialized_xy_polygon") + .first() + .getAs[Geometry]("points") + assertEquals( + (Seq.fill(8)(2), Seq.fill(8)(2)), + (generatedPointDimensions(direct), generatedPointDimensions(materialized))) + assertTrue(direct.equalsExact(materialized)) + } finally { + sparkSession.catalog.dropTempView("materialized_xy_polygon") + polygons.unpersist() + } + } + it("should pass ST_NRings") { val geomTestCases = Map( ("'POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))'") -> 1, diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functions/collect/TestStCollect.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functions/collect/TestStCollect.scala index 4d7c8a1c9cf..3d63f58912d 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/functions/collect/TestStCollect.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functions/collect/TestStCollect.scala @@ -28,6 +28,61 @@ class TestStCollect extends TestBaseScala with GeometrySample with GivenWhenThen import sparkSession.implicits._ describe("st collect workflow") { + it("should require a collection for mixed XY and declared empty or NaN Z members") { + val cases = Seq( + ("POINT (1 2)", "POINT Z EMPTY"), + ("LINESTRING (0 0, 1 1)", "LINESTRING Z EMPTY"), + ("POLYGON ((0 0, 4 0, 0 4, 0 0))", "POLYGON Z EMPTY"), + ("POINT (1 2)", "POINT Z (3 4 NaN)")) + cases.foreach { case (xyWkt, zWkt) => + val zBytes = new org.locationtech.jts.io.WKBWriter(3) + .write(wktReader.read(zWkt.replace("NaN", "9"))) + if (zWkt.contains("NaN")) java.nio.ByteBuffer.wrap(zBytes).putDouble(21, Double.NaN) + val zHex = org.locationtech.jts.io.WKBWriter.toHex(zBytes) + val inputs = Seq((xyWkt, zHex)) + .toDF("xyWkt", "zHex") + .selectExpr("ST_GeomFromWKT(xyWkt) AS xy", "ST_GeomFromWKB(unhex(zHex)) AS z") + .repartition(1) + .cache() + try { + assert(inputs.count() == 1) + val rows = inputs.selectExpr("xy AS geom").union(inputs.selectExpr("z AS geom")) + val queries = + Seq(inputs.selectExpr("ST_Collect(xy, z)"), rows.selectExpr("ST_Collect_Agg(geom)")) + queries.foreach { query => + val error = intercept[Exception] { query.collect() } + val causes = Iterator.iterate(error: Throwable)(_.getCause).takeWhile(_ != null).toSeq + assert( + causes.exists(cause => + Option(cause.getMessage) + .exists(_.contains("heterogeneous dimensional layouts"))), + error.toString) + } + val collections = Seq( + inputs.selectExpr("ST_Collect(ST_ForceCollection(xy), ST_ForceCollection(z))"), + rows.selectExpr("ST_Collect_Agg(ST_ForceCollection(geom))")) + collections.foreach { query => + val geometry = query.collect()(0).getAs[org.locationtech.jts.geom.Geometry](0) + assert(geometry.getGeometryType == "GeometryCollection") + val dimensions = (0 until geometry.getNumGeometries).map { i => + val member = geometry.getGeometryN(i).getGeometryN(0) + member match { + case point: org.locationtech.jts.geom.Point => + point.getCoordinateSequence.getDimension + case line: org.locationtech.jts.geom.LineString => + line.getCoordinateSequence.getDimension + case polygon: org.locationtech.jts.geom.Polygon => + polygon.getExteriorRing.getCoordinateSequence.getDimension + } + } + assert(dimensions.sorted == Seq(2, 3)) + } + } finally { + inputs.unpersist() + } + } + } + it("should return null when passed geometry is also null") { Given("data frame with empty geometries") val emptyGeometryDataFrame = Seq((1, null), (2, null), (3, null)).toDF("id", "geom")