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")