From f8b439f2d1c360b61e5bf2d4d0fbf5e075caa2f0 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Tue, 8 Sep 2026 12:05:45 -0700 Subject: [PATCH 1/4] [GH-3329] Preserve empty LineString dimensions during deserialization --- python/src/geom_buf.c | 5 +++- python/src/geomserde.c | 12 +++------ .../geopandas/test_match_geopandas_series.py | 9 +------ python/tests/utils/test_geomserde_speedup.py | 27 ++++++++++++++----- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/python/src/geom_buf.c b/python/src/geom_buf.c index f113393f7c6..7c2ac123821 100644 --- a/python/src/geom_buf.c +++ b/python/src/geom_buf.c @@ -147,7 +147,10 @@ static SedonaErrorCode copy_coord_seq_to_buffer( static SedonaErrorCode copy_buffer_to_coord_seq( GEOSContextHandle_t handle, double *buf, int num_coords, int has_z, int has_m, GEOSCoordSequence **p_coord_seq) { - if (dyn_GEOSCoordSeq_copyFromBuffer_r != NULL) { + /* Older GEOS versions infer XYZ for an empty buffer. Use the explicit + * dimension constructor below for empty XY/XYZ sequences. M layouts need + * copyFromBuffer, which preserves their dimensions on GEOS >= 3.12. */ + if (dyn_GEOSCoordSeq_copyFromBuffer_r != NULL && (num_coords > 0 || has_m)) { /* fast path for libgeos >= 3.10.0 */ GEOSCoordSequence *coord_seq = dyn_GEOSCoordSeq_copyFromBuffer_r( handle, buf, num_coords, has_z, has_m); diff --git a/python/src/geomserde.c b/python/src/geomserde.c index c1f74277383..01f57ba9bfa 100644 --- a/python/src/geomserde.c +++ b/python/src/geomserde.c @@ -122,15 +122,9 @@ static SedonaErrorCode sedona_serialize_linestring( static SedonaErrorCode sedona_deserialize_linestring( GEOSContextHandle_t handle, int srid, GeomBuffer *geom_buf, CoordinateSequenceInfo *cs_info, GEOSGeometry **p_geom) { - if (cs_info->num_coords == 0) { - GEOSGeometry *geom = dyn_GEOSGeom_createEmptyLineString_r(handle); - if (geom == NULL) { - return SEDONA_GEOS_ERROR; - } - *p_geom = geom; - return SEDONA_SUCCESS; - } - + /* Preserve the stored dimensions for empty LineStrings too. The default + * GEOS empty constructor can add or drop dimensions depending on the version. + */ GEOSCoordSequence *coord_seq = NULL; SedonaErrorCode err = geom_buf_read_coords(geom_buf, handle, cs_info, &coord_seq); diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py index 750d400145d..3e0a406a235 100644 --- a/python/tests/geopandas/test_match_geopandas_series.py +++ b/python/tests/geopandas/test_match_geopandas_series.py @@ -137,6 +137,7 @@ def setup_method(self): self.geomcollection = [ GeometryCollection(), + GeometryCollection([Point(), LineString(), Polygon()]), GeometryCollection( [ MultiPoint([(0, 0), (1, 1)]), @@ -569,14 +570,6 @@ def test_to_arrow(self): import pyarrow as pa for geom in self.geoms: - # LINEARRING EMPTY and LineString EMPTY - # result in 01EA03000000000000 instead of 010200000000000000. - # Sedona returns the right result, so this bug is likely in pyarrow or geoarrow - # Below we set the modify the failing case as a workaround to pass the test - # Occurs in python 3.9, but fixed by python 3.10. - if geom[0] in [LineString(), LinearRing()]: - geom[0] = LineString([(0, 0), (1, 1)]) - sgpd_result = pa.array(GeoSeries(geom).to_arrow()) gpd_result = pa.array(gpd.GeoSeries(geom).to_arrow()) assert sgpd_result == gpd_result diff --git a/python/tests/utils/test_geomserde_speedup.py b/python/tests/utils/test_geomserde_speedup.py index f3c9903cb0e..4351e9292a0 100644 --- a/python/tests/utils/test_geomserde_speedup.py +++ b/python/tests/utils/test_geomserde_speedup.py @@ -59,6 +59,23 @@ def test_nan_first_z_serialization_keeps_dimension(self): assert coordinate_type == geometry_serde_general.CoordinateType.XYZ + @pytest.mark.parametrize( + "wkt", + [ + "LINESTRING EMPTY", + "LINESTRING Z EMPTY", + "GEOMETRYCOLLECTION (LINESTRING EMPTY)", + "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING EMPTY, POLYGON EMPTY)", + "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (LINESTRING EMPTY))", + ], + ) + def test_empty_linestring_roundtrip_keeps_dimension(self, wkt): + geometry = wkt_loads(wkt) + actual = self.serde_roundtrip(geometry) + + # Spatial equality does not distinguish the dimensions of empty geometries. + assert actual.wkb == geometry.wkb + def test_multi_point(self): multi_points = [ wkt_loads("MULTIPOINT EMPTY"), @@ -168,6 +185,8 @@ def test_srid_roundtrip(self): "POINT ZM (1 2 3 4)", "LINESTRING M (0 0 1, 2 3 4)", "LINESTRING ZM (0 0 1 2, 3 4 5 6)", + "LINESTRING M EMPTY", + "LINESTRING ZM EMPTY", "POLYGON M ((0 0 1, 2 0 2, 0 2 3, 0 0 1))", "GEOMETRYCOLLECTION ZM (POINT ZM (1 2 3 4), " "LINESTRING ZM (0 0 1 2, 3 4 5 6))", @@ -232,13 +251,7 @@ def _test_serde_roundtrip(geoms): for geom in geoms: geom_actual = TestGeomSerdeSpeedup.serde_roundtrip(geom) assert geom_actual.equals_exact(geom, 1e-6) - # GEOSGeom_createEmptyLineString in libgeos creates LineString with - # Z dimension, This bug has been fixed by - # https://github.com/libgeos/geos/pull/745 - geom_actual_wkt = geom_actual.wkt.replace( - "LINESTRING Z EMPTY", "LINESTRING EMPTY" - ) - assert geom.wkt == geom_actual_wkt + assert geom.wkt == geom_actual.wkt @staticmethod def serde_roundtrip(geom: BaseGeometry) -> BaseGeometry: From fc89f3bc0597af769eda49f75603abc3f2de8ee1 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Wed, 9 Sep 2026 00:05:14 -0700 Subject: [PATCH 2/4] [GH-3329] Preserve existing geometry collection test inputs --- python/tests/geopandas/test_match_geopandas_series.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py index 3e0a406a235..1afccb68fd7 100644 --- a/python/tests/geopandas/test_match_geopandas_series.py +++ b/python/tests/geopandas/test_match_geopandas_series.py @@ -137,7 +137,6 @@ def setup_method(self): self.geomcollection = [ GeometryCollection(), - GeometryCollection([Point(), LineString(), Polygon()]), GeometryCollection( [ MultiPoint([(0, 0), (1, 1)]), @@ -154,6 +153,7 @@ def setup_method(self): ), # Collection predicates must retain collection semantics with one member. GeometryCollection([LineString([(0, 0), (1, 1), (0, 0)])]), + GeometryCollection([Point(), LineString(), Polygon()]), ] self.geoms = [ @@ -169,7 +169,8 @@ def setup_method(self): self.pairs = [ (self.points, self.multipolygons), - (self.geomcollection, self.polygons), + # Keep equal-length inputs so the align=False cases still run. + (self.geomcollection[: len(self.polygons)], self.polygons), (self.linestrings, self.multipoints), (self.linearrings, self.multilinestrings), ] From 330329631d8bfecab7c396d8f9fa68630bd768c1 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Wed, 9 Sep 2026 00:32:35 -0700 Subject: [PATCH 3/4] [GH-3329] Preserve empty dimensions in both geometry decoders --- .../spark/utils/geometry_serde_general.py | 36 +++-- python/src/geomserde.c | 20 ++- python/tests/utils/test_geometry_serde.py | 21 +++ .../tests/utils/test_geometry_serde_empty.py | 144 ++++++++++++++++++ python/tests/utils/test_geomserde_speedup.py | 38 ++++- 5 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 python/tests/utils/test_geometry_serde_empty.py diff --git a/python/sedona/spark/utils/geometry_serde_general.py b/python/sedona/spark/utils/geometry_serde_general.py index beed847cc64..29dacfbbbc5 100644 --- a/python/sedona/spark/utils/geometry_serde_general.py +++ b/python/sedona/spark/utils/geometry_serde_general.py @@ -85,6 +85,15 @@ def type_of(geom) -> int: else: raise ValueError(f"Invalid coordinate dimension: {geom._ndim}") + @staticmethod + def type_of_empty(geom) -> int: + # Shapely 1.x reports _ndim == 2 even for explicit XYZ empty geometries. + # Their WKB still records Z, so read its type flag instead. + wkb = wkb_dumps(geom) + byte_order = "I" + geometry_type = struct.unpack_from(byte_order, wkb, 1)[0] + return CoordinateType.XYZ if geometry_type & 0x80000000 else CoordinateType.XY + @staticmethod def bytes_per_coord(coord_type: int) -> int: return CoordinateType.BYTES_PER_COORDINATE[coord_type - 1] @@ -163,6 +172,12 @@ def read_coordinate(self) -> CoordType: self.coords_offset += self.bytes_per_coord return coord + def read_empty(self, geometry_type: str) -> BaseGeometry: + # Constructors such as Point() create empty GeometryCollections in + # Shapely 1.x. WKT preserves both the primitive type and its dimension. + dimension = " Z" if self.coord_type == CoordinateType.XYZ else "" + return wkt_loads(f"{geometry_type}{dimension} EMPTY") + def read_int(self) -> int: value = struct.unpack_from("i", self.buffer, self.ints_offset)[0] if value > len(self.buffer): @@ -327,15 +342,14 @@ def serialize_point(geom: Point) -> bytes: coords = coords[0] return struct.pack(pack_format, preamble_byte, 0, 0, 0, 1, *coords) else: - return struct.pack("BBBBi", 18, 0, 0, 0, 0) + return generate_header_bytes( + GeometryTypeID.POINT, CoordinateType.type_of_empty(geom), 0 + ) def deserialize_point(geom_buffer: GeometryBuffer) -> Point: if geom_buffer.num_coords == 0: - # Here we don't call Point() directly since it would create an empty GeometryCollection - # in shapely 1.x. You'll find similar code for creating empty geometries in other - # deserialization functions. - return wkt_loads("POINT EMPTY") + return geom_buffer.read_empty("POINT") coord = geom_buffer.read_coordinate() return Point(coord) @@ -385,12 +399,14 @@ def serialize_linestring(geom: LineString) -> bytes: ) return header + array.array("d", [x for c in coords for x in c]).tobytes() else: - return generate_header_bytes(GeometryTypeID.LINESTRING, 1, 0) + return generate_header_bytes( + GeometryTypeID.LINESTRING, CoordinateType.type_of_empty(geom), 0 + ) def deserialize_linestring(geom_buffer: GeometryBuffer) -> LineString: if geom_buffer.num_coords == 0: - return wkt_loads("LINESTRING EMPTY") + return geom_buffer.read_empty("LINESTRING") coords = geom_buffer.read_coordinates(geom_buffer.num_coords) return LineString(coords) @@ -437,7 +453,9 @@ def serialize_polygon(geom: Polygon) -> bytes: num_rings = struct.unpack_from(int_format, wkb_string, 5)[0] if num_rings == 0: - return generate_header_bytes(GeometryTypeID.POLYGON, CoordinateType.XY, 0) + return generate_header_bytes( + GeometryTypeID.POLYGON, CoordinateType.type_of_empty(geom), 0 + ) coord_bytes = b"" ring_lengths = [] @@ -464,7 +482,7 @@ def serialize_polygon(geom: Polygon) -> bytes: def deserialize_polygon(geom_buffer: GeometryBuffer) -> Polygon: if geom_buffer.num_coords == 0: - return wkt_loads("POLYGON EMPTY") + return geom_buffer.read_empty("POLYGON") return geom_buffer.read_polygon() diff --git a/python/src/geomserde.c b/python/src/geomserde.c index 01f57ba9bfa..35a2fed77c2 100644 --- a/python/src/geomserde.c +++ b/python/src/geomserde.c @@ -65,9 +65,7 @@ static SedonaErrorCode sedona_deserialize_point(GEOSContextHandle_t handle, CoordinateSequenceInfo *cs_info, GEOSGeometry **p_geom) { GEOSGeometry *geom = NULL; - if (cs_info->num_coords == 0) { - geom = dyn_GEOSGeom_createEmptyPoint_r(handle); - } else if (cs_info->dims == 2) { + if (cs_info->num_coords > 0 && cs_info->dims == 2) { /* fast path for 2D points */ double x = *geom_buf->buf_coord++; double y = *geom_buf->buf_coord++; @@ -179,8 +177,22 @@ static SedonaErrorCode sedona_deserialize_polygon( GEOSContextHandle_t handle, int srid, GeomBuffer *geom_buf, CoordinateSequenceInfo *cs_info, GEOSGeometry **p_geom) { if (cs_info->num_coords == 0) { - GEOSGeometry *geom = dyn_GEOSGeom_createEmptyPolygon_r(handle); + /* An explicit empty shell preserves the stored Z/M layout. The default + * empty polygon constructor always creates an XY polygon. */ + GEOSCoordSequence *coord_seq = NULL; + SedonaErrorCode err = + geom_buf_read_coords(geom_buf, handle, cs_info, &coord_seq); + if (err != SEDONA_SUCCESS) { + return err; + } + GEOSGeometry *shell = dyn_GEOSGeom_createLinearRing_r(handle, coord_seq); + if (shell == NULL) { + dyn_GEOSCoordSeq_destroy_r(handle, coord_seq); + return SEDONA_GEOS_ERROR; + } + GEOSGeometry *geom = dyn_GEOSGeom_createPolygon_r(handle, shell, NULL, 0); if (geom == NULL) { + dyn_GEOSGeom_destroy_r(handle, shell); return SEDONA_GEOS_ERROR; } *p_geom = geom; diff --git a/python/tests/utils/test_geometry_serde.py b/python/tests/utils/test_geometry_serde.py index 1eeb895b4c9..58cf3c87dce 100644 --- a/python/tests/utils/test_geometry_serde.py +++ b/python/tests/utils/test_geometry_serde.py @@ -34,6 +34,27 @@ class TestGeometrySerde(TestBase): + @pytest.mark.parametrize( + "wkt", + [ + "POINT EMPTY", + "POINT Z EMPTY", + "LINESTRING EMPTY", + "LINESTRING Z EMPTY", + "POLYGON EMPTY", + "POLYGON Z EMPTY", + "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING EMPTY, POLYGON EMPTY)", + "GEOMETRYCOLLECTION Z (POINT Z EMPTY, LINESTRING Z EMPTY, POLYGON Z EMPTY)", + ], + ) + def test_spark_empty_geometry_dimensions(self, wkt): + geometry = wkt_loads(wkt) + actual = self.spark.createDataFrame( + [(geometry,)], StructType().add("geom", GeometryType()) + ).first()[0] + + assert actual.wkb == geometry.wkb + @pytest.mark.parametrize( "geom", [ diff --git a/python/tests/utils/test_geometry_serde_empty.py b/python/tests/utils/test_geometry_serde_empty.py new file mode 100644 index 00000000000..55c4fab944e --- /dev/null +++ b/python/tests/utils/test_geometry_serde_empty.py @@ -0,0 +1,144 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import struct + +import pytest +import shapely +from shapely.geometry import LineString, Point, Polygon +from shapely.wkt import loads as wkt_loads + +from sedona.spark.utils import geometry_serde, geometry_serde_general + +EMPTY_PRIMITIVES = [("POINT", 1), ("LINESTRING", 2), ("POLYGON", 3)] +EMPTY_LAYOUTS = [("", 1), (" Z", 2)] +COLLECTIONS = [ + "GEOMETRYCOLLECTION (POINT (1 2), POINT Z EMPTY, LINESTRING Z EMPTY, " + "POLYGON Z EMPTY)", + "GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT EMPTY, LINESTRING Z EMPTY, " + "POLYGON EMPTY)", + "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (POINT (1 2), POINT Z EMPTY, " + "LINESTRING Z EMPTY, POLYGON Z EMPTY))", + pytest.param( + "GEOMETRYCOLLECTION (POINT Z EMPTY, LINESTRING Z EMPTY, POLYGON Z EMPTY)", + marks=pytest.mark.skipif( + shapely.__version__ < "2", + reason="Shapely 1.x hides members of all-empty collections from geoms", + ), + ), +] + + +@pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES) +@pytest.mark.parametrize("dimension,coord_type", EMPTY_LAYOUTS) +def test_general_empty_serializer_keeps_dimension( + geometry_type, type_id, dimension, coord_type +): + geometry = wkt_loads(f"{geometry_type}{dimension} EMPTY") + + buffer = geometry_serde_general.serialize(geometry) + + # Check the stored layout directly: a matching decoder could hide an XY header. + assert buffer == struct.pack( + "BBBBi", (type_id << 4) | (coord_type << 1), 0, 0, 0, 0 + ) + + +@pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES) +@pytest.mark.parametrize("dimension,coord_type", EMPTY_LAYOUTS) +def test_general_empty_deserializer_keeps_stored_dimension( + geometry_type, type_id, dimension, coord_type +): + # Build the eight-byte internal header independently of the Python serializer, + # as it can also come from the JVM or the C extension. + buffer = struct.pack("BBBBi", (type_id << 4) | (coord_type << 1), 0, 0, 0, 0) + + actual, offset = geometry_serde_general.deserialize(buffer) + + assert offset == len(buffer) + assert actual.wkb == wkt_loads(f"{geometry_type}{dimension} EMPTY").wkb + + +@pytest.mark.parametrize( + "constructor,type_id", [(Point, 1), (LineString, 2), (Polygon, 3)] +) +def test_general_empty_constructor_remains_xy(constructor, type_id): + buffer = geometry_serde_general.serialize(constructor()) + actual, offset = geometry_serde_general.deserialize(buffer) + + assert buffer == struct.pack("BBBBi", (type_id << 4) | 2, 0, 0, 0, 0) + assert offset == len(buffer) + assert actual.is_empty + + +@pytest.mark.parametrize("wkt", COLLECTIONS) +def test_general_empty_collection_members_keep_dimension(wkt): + geometry = wkt_loads(wkt) + + buffer = geometry_serde_general.serialize(geometry) + actual, offset = geometry_serde_general.deserialize(buffer) + + assert offset == len(buffer) + assert actual.wkb == geometry.wkb + + +@pytest.mark.skipif( + not geometry_serde.speedup_enabled, reason="C extension is unavailable" +) +@pytest.mark.parametrize("c_serializes", [False, True]) +@pytest.mark.parametrize( + "wkt", + [ + f"{geometry_type}{dimension} EMPTY" + for geometry_type, _ in EMPTY_PRIMITIVES + for dimension, _ in EMPTY_LAYOUTS + ] + + COLLECTIONS, +) +def test_empty_dimensions_cross_decoder_roundtrip(wkt, c_serializes): + geometry = wkt_loads(wkt) + serializer = geometry_serde if c_serializes else geometry_serde_general + deserializer = geometry_serde_general if c_serializes else geometry_serde + + buffer = serializer.serialize(geometry) + actual, offset = deserializer.deserialize(buffer) + + assert offset == len(buffer) + assert actual.wkb == geometry.wkb + + +@pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES) +@pytest.mark.parametrize("coord_type", [3, 4]) +def test_general_empty_deserializer_still_rejects_m(geometry_type, type_id, coord_type): + buffer = struct.pack("BBBBi", (type_id << 4) | (coord_type << 1), 0, 0, 0, 0) + + with pytest.raises(ValueError, match="requires geomserde_speedup"): + geometry_serde_general.deserialize(buffer) + + +@pytest.mark.skipif( + shapely.__version__ < "2.1" + or getattr(shapely, "geos_version", (0, 0, 0)) < (3, 12, 0), + reason="M coordinates require Shapely 2.1 and GEOS 3.12 or newer", +) +@pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES) +@pytest.mark.parametrize("dimension", ["M", "ZM"]) +def test_general_empty_serializer_still_rejects_m(geometry_type, type_id, dimension): + geometry = wkt_loads(f"{geometry_type} {dimension} EMPTY") + + with pytest.raises(ValueError, match="requires geomserde_speedup"): + geometry_serde_general.serialize(geometry) diff --git a/python/tests/utils/test_geomserde_speedup.py b/python/tests/utils/test_geomserde_speedup.py index 4351e9292a0..394e4f41a17 100644 --- a/python/tests/utils/test_geomserde_speedup.py +++ b/python/tests/utils/test_geomserde_speedup.py @@ -62,20 +62,50 @@ def test_nan_first_z_serialization_keeps_dimension(self): @pytest.mark.parametrize( "wkt", [ + "POINT EMPTY", + "POINT Z EMPTY", "LINESTRING EMPTY", "LINESTRING Z EMPTY", + "POLYGON EMPTY", + "POLYGON Z EMPTY", "GEOMETRYCOLLECTION (LINESTRING EMPTY)", "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING EMPTY, POLYGON EMPTY)", "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (LINESTRING EMPTY))", + "GEOMETRYCOLLECTION Z (POINT Z EMPTY, LINESTRING Z EMPTY, POLYGON Z EMPTY)", + "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING Z EMPTY, POLYGON Z EMPTY)", + "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION Z (POINT Z EMPTY, POLYGON Z EMPTY))", ], ) - def test_empty_linestring_roundtrip_keeps_dimension(self, wkt): + def test_empty_geometry_roundtrip_keeps_dimension(self, wkt): geometry = wkt_loads(wkt) actual = self.serde_roundtrip(geometry) # Spatial equality does not distinguish the dimensions of empty geometries. assert actual.wkb == geometry.wkb + @pytest.mark.parametrize("geom_type", ["POINT", "LINESTRING", "POLYGON"]) + @pytest.mark.parametrize( + "coord_type", [1, 2, 3, 4], ids=["XY", "XYZ", "XYM", "XYZM"] + ) + def test_decode_empty_geometry_keeps_stored_layout(self, geom_type, coord_type): + if coord_type in (3, 4) and ( + parse_version(shapely.__version__) < parse_version("2.1") + or getattr(shapely, "geos_version", (0, 0, 0)) < (3, 12, 0) + ): + pytest.skip("M coordinates require Shapely 2.1 and GEOS 3.12 or newer") + + # Build the stored header directly so the serializer cannot hide a decoder bug. + buffer = geometry_serde_general.generate_header_bytes( + getattr(geometry_serde_general.GeometryTypeID, geom_type), coord_type, 0 + ) + actual, offset = geometry_serde.deserialize(buffer) + dimension = ["", "Z", "M", "ZM"][coord_type - 1] + expected = wkt_loads(f"{geom_type} {dimension} EMPTY") + + assert offset == len(buffer) + assert actual.wkb == expected.wkb + assert geometry_serde.serialize(actual) == buffer + def test_multi_point(self): multi_points = [ wkt_loads("MULTIPOINT EMPTY"), @@ -183,11 +213,16 @@ def test_srid_roundtrip(self): [ "POINT M (1 2 3)", "POINT ZM (1 2 3 4)", + "POINT M EMPTY", + "POINT ZM EMPTY", "LINESTRING M (0 0 1, 2 3 4)", "LINESTRING ZM (0 0 1 2, 3 4 5 6)", "LINESTRING M EMPTY", "LINESTRING ZM EMPTY", "POLYGON M ((0 0 1, 2 0 2, 0 2 3, 0 0 1))", + "POLYGON M EMPTY", + "POLYGON ZM EMPTY", + "GEOMETRYCOLLECTION ZM (POINT ZM EMPTY, LINESTRING ZM EMPTY, POLYGON ZM EMPTY)", "GEOMETRYCOLLECTION ZM (POINT ZM (1 2 3 4), " "LINESTRING ZM (0 0 1 2, 3 4 5 6))", ], @@ -199,6 +234,7 @@ def test_m_roundtrip(self, wkt): assert shapely.to_wkt(actual) == shapely.to_wkt(geometry) assert actual.has_z == geometry.has_z assert actual.has_m == geometry.has_m + assert actual.wkb == geometry.wkb @pytest.mark.skipif( parse_version(shapely.__version__) < parse_version("2.1") From 7ff7448b3123659de8e9114741bf8c788578266d Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Wed, 9 Sep 2026 11:10:14 -0700 Subject: [PATCH 4/4] [GH-3329] Keep empty Point fallback compatible with GEOS 3.8 --- .../spark/utils/geometry_serde_general.py | 9 +++ .../tests/utils/test_geometry_serde_empty.py | 73 ++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/python/sedona/spark/utils/geometry_serde_general.py b/python/sedona/spark/utils/geometry_serde_general.py index 29dacfbbbc5..eaa02a29254 100644 --- a/python/sedona/spark/utils/geometry_serde_general.py +++ b/python/sedona/spark/utils/geometry_serde_general.py @@ -36,6 +36,11 @@ from shapely.wkb import dumps as wkb_dumps from shapely.wkt import loads as wkt_loads +try: + from shapely import geos_version +except ImportError: + from shapely.geos import geos_version + CoordType = Union[ Tuple[float, float], Tuple[float, float, float], Tuple[float, float, float, float] ] @@ -87,6 +92,10 @@ def type_of(geom) -> int: @staticmethod def type_of_empty(geom) -> int: + # GEOS < 3.9 cannot write empty Points as WKB. Keep the fallback's + # existing XY encoding on those versions without calling the writer. + if isinstance(geom, Point) and geos_version < (3, 9, 0): + return CoordinateType.XY # Shapely 1.x reports _ndim == 2 even for explicit XYZ empty geometries. # Their WKB still records Z, so read its type flag instead. wkb = wkb_dumps(geom) diff --git a/python/tests/utils/test_geometry_serde_empty.py b/python/tests/utils/test_geometry_serde_empty.py index 55c4fab944e..fab01b836c6 100644 --- a/python/tests/utils/test_geometry_serde_empty.py +++ b/python/tests/utils/test_geometry_serde_empty.py @@ -22,6 +22,11 @@ from shapely.geometry import LineString, Point, Polygon from shapely.wkt import loads as wkt_loads +try: + from shapely import geos_version +except ImportError: + from shapely.geos import geos_version + from sedona.spark.utils import geometry_serde, geometry_serde_general EMPTY_PRIMITIVES = [("POINT", 1), ("LINESTRING", 2), ("POLYGON", 3)] @@ -43,12 +48,72 @@ ] +def _require_empty_layout(geometry, coord_type): + if geometry.geom_type == "Point" and geos_version < (3, 9): + if coord_type == 2: + pytest.skip("Legacy empty Points use XY without WKB support") + return + + # Some older WKT readers change the requested empty layout before serde runs. + # Check the source geometry so the fixed header assertions remain meaningful. + source_wkb = geometry.wkb + byte_order = "I" + source_type = struct.unpack_from(byte_order, source_wkb, 1)[0] + source_coord_type = 2 if source_type & 0x80000000 else 1 + if source_coord_type != coord_type: + pytest.skip("GEOS WKT reader cannot construct the requested empty layout") + + +def _assert_geometry_equal(actual, expected): + assert actual.geom_type == expected.geom_type + if geos_version < (3, 9): + if expected.geom_type == "GeometryCollection": + # GEOS < 3.9 also cannot write collections containing empty Points. + assert len(actual.geoms) == len(expected.geoms) + for actual_member, expected_member in zip(actual.geoms, expected.geoms): + _assert_geometry_equal(actual_member, expected_member) + return + if expected.geom_type == "Point" and expected.is_empty: + assert actual.is_empty + assert geometry_serde_general.serialize(actual) == struct.pack( + "BBBBi", 0x12, 0, 0, 0, 0 + ) + return + + expected_wkb = expected.wkb + assert expected_wkb # Comparing two failed WKB writes would hide a regression. + assert actual.wkb == expected_wkb + + +@pytest.mark.parametrize("wkt", [None, "POINT EMPTY", "POINT Z EMPTY"]) +def test_general_empty_point_without_wkb_support(monkeypatch, wkt): + geometry = Point() if wkt is None else wkt_loads(wkt) + + def unsupported_wkb(_geometry): + raise AssertionError("GEOS < 3.9 cannot write empty Points as WKB") + + monkeypatch.setattr( + geometry_serde_general, "geos_version", (3, 8, 0), raising=False + ) + monkeypatch.setattr(geometry_serde_general, "wkb_dumps", unsupported_wkb) + + buffer = geometry_serde_general.serialize(geometry) + actual, offset = geometry_serde_general.deserialize(buffer) + + assert buffer == struct.pack("BBBBi", 0x12, 0, 0, 0, 0) + assert offset == len(buffer) + assert actual.geom_type == "Point" + assert actual.is_empty + assert geometry_serde_general.serialize(actual) == buffer + + @pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES) @pytest.mark.parametrize("dimension,coord_type", EMPTY_LAYOUTS) def test_general_empty_serializer_keeps_dimension( geometry_type, type_id, dimension, coord_type ): geometry = wkt_loads(f"{geometry_type}{dimension} EMPTY") + _require_empty_layout(geometry, coord_type) buffer = geometry_serde_general.serialize(geometry) @@ -66,11 +131,13 @@ def test_general_empty_deserializer_keeps_stored_dimension( # Build the eight-byte internal header independently of the Python serializer, # as it can also come from the JVM or the C extension. buffer = struct.pack("BBBBi", (type_id << 4) | (coord_type << 1), 0, 0, 0, 0) + expected = wkt_loads(f"{geometry_type}{dimension} EMPTY") + _require_empty_layout(expected, coord_type) actual, offset = geometry_serde_general.deserialize(buffer) assert offset == len(buffer) - assert actual.wkb == wkt_loads(f"{geometry_type}{dimension} EMPTY").wkb + _assert_geometry_equal(actual, expected) @pytest.mark.parametrize( @@ -93,7 +160,7 @@ def test_general_empty_collection_members_keep_dimension(wkt): actual, offset = geometry_serde_general.deserialize(buffer) assert offset == len(buffer) - assert actual.wkb == geometry.wkb + _assert_geometry_equal(actual, geometry) @pytest.mark.skipif( @@ -118,7 +185,7 @@ def test_empty_dimensions_cross_decoder_roundtrip(wkt, c_serializes): actual, offset = deserializer.deserialize(buffer) assert offset == len(buffer) - assert actual.wkb == geometry.wkb + _assert_geometry_equal(actual, geometry) @pytest.mark.parametrize("geometry_type,type_id", EMPTY_PRIMITIVES)