You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/10/12 07:20:05 UTC

(sedona-db) branch main updated: feat(python/sedonadb-geopandas): add explode (#1402)

This is an automated email from the ASF dual-hosted git repository.

jiayuasu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git


The following commit(s) were added to refs/heads/main by this push:
     new f0ba98696 feat(python/sedonadb-geopandas): add explode (#1402)
f0ba98696 is described below

commit f0ba98696dbb19fc7c2c2128f6f0f1e6cdf712cf
Author: Jia Yu <ji...@wherobots.com>
AuthorDate: Mon Oct 12 00:19:59 2026 -0700

    feat(python/sedonadb-geopandas): add explode (#1402)
---
 python/sedonadb-geopandas/README.md                |   7 +
 .../python/sedonadb_geopandas/_frame.py            | 117 ++++++++++++
 .../python/sedonadb_geopandas/_series.py           |  21 ++
 python/sedonadb-geopandas/tests/test_explode.py    | 211 +++++++++++++++++++++
 4 files changed, 356 insertions(+)

diff --git a/python/sedonadb-geopandas/README.md b/python/sedonadb-geopandas/README.md
index ede9b131a..5c60a53b6 100644
--- a/python/sedonadb-geopandas/README.md
+++ b/python/sedonadb-geopandas/README.md
@@ -38,6 +38,7 @@ web = gdf.to_crs("EPSG:3857")              # reproject (CRS tracked through)
 
 joined = gdf.sjoin(regions, predicate="within")  # spatial join
 zones = joined.dissolve(by="region")              # group and union geometry
+parts = zones.explode()                           # one row per geometry part
 
 result = zones.to_geopandas()               # back to a real GeoDataFrame
 ```
@@ -94,6 +95,12 @@ GeoPandas' `align=False`; there is no index to align on. `touches` inherits
 an engine issue with geometry collections that mix dimensions
 (apache/sedona-db#1383).
 
+`explode()` takes parts one level deep and drops rows whose geometry has no
+parts, as GeoPandas does. Without an index it cannot number the parts
+(`index_parts=True`), and it explodes the active geometry column only:
+GeoPandas explodes the active geometry even when `column=` names another
+geometry column, and explodes non-geometry columns as lists.
+
 `sjoin()` requires both geometry columns to share a CRS: GeoPandas warns on a
 mismatch and joins anyway, which is almost always a mistake, so this raises and
 points at `to_crs()`. `on_attribute` is not supported yet.
diff --git a/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py b/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
index 4f682fb24..410cd1e91 100644
--- a/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
+++ b/python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
@@ -111,6 +111,85 @@ def _is_missing(value):
         return isinstance(value, float) and value != value
 
 
+def _explode_parts(df, column):
+    """`df` with one row per part of its geometry column `column`.
+
+    Parts are taken one level deep, as `shapely.get_parts` does. A single
+    geometry is its own one part, even when empty; a multi-geometry or
+    collection with no parts, or a missing geometry, yields no row. The
+    exploded column moves to the end. All three as in GeoPandas.
+
+    `ST_Dump` lists the parts before they are unnested, so each part is
+    stored once rather than the whole geometry being repeated per part. It
+    flattens nested collections, though, so a geometry collection's members
+    are read one level deep with `ST_GeometryN` instead, which repeats the
+    collection (only) once per member while it is exploded.
+    """
+    ctx = df._ctx
+    column_type = df.schema.field(column).type
+    crs = column_type.crs
+    spherical = "SPHERICAL" in str(getattr(column_type, "edge_type", "")).upper()
+    df = df.filter(df[column].is_not_null())
+    geometry = df[column]
+    is_collection = geometry.geo.geometry_type() == lit("ST_GeometryCollection")
+    # nvl2 needs its condition in the same type as its values, hence the
+    # binary flag (non-null on collections only), as in GeoSeries.envelope.
+    collection_flag = _binary_flag(is_collection)
+    carried = collection_flag.funcs.nvl2(geometry.geo.as_binary(), lit(None))
+    members = (
+        geometry.geo.num_geometries().cast(pa.int64()) * is_collection.cast(pa.int64())
+    ).funcs.coalesce(lit(0))
+    # A collection's parts come from its member positions, so its dumped
+    # leaves are dropped before unnesting: unnested in parallel with the
+    # positions, each leaf would carry the whole collection.
+    leaves = geometry.geo.dump()
+    leaves = leaves.funcs.array_slice(
+        lit(1),
+        leaves.funcs.cardinality().cast(pa.int64()) * (~is_collection).cast(pa.int64()),
+    )
+    others = [name for name in df.schema.names if name != column]
+    dump, position, carry = (
+        _unused_name(df, "__dump"),
+        _unused_name(df, "__member"),
+        _unused_name(df, "__collection"),
+    )
+    staged = df.select(
+        *[df[name] for name in others],
+        leaves.alias(dump),
+        ctx.lit(1).funcs.range(members + lit(1)).alias(position),
+        carried.alias(carry),
+    ).unnest(dump, position)
+
+    def from_wkb(wkb):
+        return wkb.funcs.st_geogfromwkb() if spherical else wkb.funcs.st_geomfromwkb()
+
+    member = from_wkb(staged[carry]).geo.geometry_n(staged[position]).geo.as_binary()
+    wkb = _binary_flag(staged[position].is_not_null()).funcs.nvl2(
+        member, staged[dump]["geom"].cast(pa.binary())
+    )
+    # WKB carries neither the spatial kind nor the CRS: both are restored.
+    part = from_wkb(wkb)
+    if crs is not None:
+        part = part.funcs.st_setcrs(ctx.lit(crs.to_json()))
+    elif spherical:
+        # The geography constructor assigns OGC:CRS84; SRID 0 clears it, as
+        # the column had no CRS.
+        part = part.geo.set_srid(ctx.lit(0))
+    return staged.select(*[staged[name] for name in others], part.alias(column))
+
+
+def _binary_flag(condition):
+    """A binary value that is non-null exactly where `condition` is true."""
+    return condition.funcs.nullif(lit(False)).cast(pa.string()).cast(pa.binary())
+
+
+def _unused_name(df, name):
+    """`name`, prefixed with underscores until no column of `df` has it."""
+    while name in df.schema.names:
+        name = f"_{name}"
+    return name
+
+
 class GeoDataFrame:
     """A lazy SedonaDB frame in the shape of a `geopandas.GeoDataFrame`.
 
@@ -806,6 +885,44 @@ class GeoDataFrame:
         unioned = collected.mutate(geometry_expr.alias(self._geometry_name))
         return GeoDataFrame(unioned, self._geometry_name)
 
+    def explode(self, column=None, ignore_index=False, index_parts=False):
+        """One row per part of each multi-part geometry, as in GeoPandas.
+
+        Parts are taken one level deep, as `shapely.get_parts` does: a
+        multi-geometry or collection yields its members, a single geometry
+        stays one row, and a row whose geometry has no parts (an empty
+        multi-geometry or collection, or a missing geometry) is dropped. The
+        other columns repeat for each part, and the exploded column moves to
+        the end, as in GeoPandas.
+
+        Args:
+            column: The column to explode: only the active geometry column
+                (the default) is supported. GeoPandas explodes the active
+                geometry even when `column` names another geometry column,
+                and explodes a non-geometry column as lists.
+            ignore_index: Accepted for compatibility; there is no index.
+            index_parts: Not supported, since there is no index to number the
+                parts in.
+
+        Returns:
+            A `GeoDataFrame` with the same active geometry column.
+        """
+        if column is None:
+            if self._geometry_name is None:
+                raise AttributeError("This GeoDataFrame has no active geometry column")
+            column = self._geometry_name
+        if column not in self.columns:
+            raise KeyError(column)
+        if column != self._geometry_name:
+            raise NotImplementedError(
+                f"explode() supports the active geometry column only, not {column!r}"
+            )
+        if index_parts:
+            raise NotImplementedError(
+                "explode() cannot number parts with index_parts=True: there is no index"
+            )
+        return GeoDataFrame(_explode_parts(self._df, column), self._geometry_name)
+
     def to_geopandas(self):
         """Execute and return a `geopandas.GeoDataFrame` (or plain DataFrame).
 
diff --git a/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py b/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
index d3f91052b..2db2894bd 100644
--- a/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
+++ b/python/sedonadb-geopandas/python/sedonadb_geopandas/_series.py
@@ -773,6 +773,27 @@ class GeoSeries(Series):
         """A point guaranteed to lie on each geometry (`ST_PointOnSurface`)."""
         return self._geo(self._expr.geo.point_on_surface())
 
+    def explode(self, ignore_index=False, index_parts=False):
+        """One element per part of each multi-part geometry, as in GeoPandas.
+
+        Parts are taken one level deep, and a geometry with no parts (or a
+        missing one) yields none; see `GeoDataFrame.explode`. The result has a
+        different number of rows, so it belongs to a new frame and cannot be
+        combined with columns of this one. `ignore_index` is accepted for
+        compatibility; `index_parts=True` is not supported, since there is no
+        index.
+        """
+        from sedonadb_geopandas._frame import _explode_parts
+
+        if index_parts:
+            raise NotImplementedError(
+                "explode() cannot number parts with index_parts=True: there is no index"
+            )
+        exploded = _explode_parts(
+            self._df.select(self._expr.alias(self._name)), self._name
+        )
+        return GeoSeries(exploded, exploded[self._name], self._name)
+
     # -- binary operations -----------------------------------------------
 
     def _other(self, other):
diff --git a/python/sedonadb-geopandas/tests/test_explode.py b/python/sedonadb-geopandas/tests/test_explode.py
new file mode 100644
index 000000000..9f72581af
--- /dev/null
+++ b/python/sedonadb-geopandas/tests/test_explode.py
@@ -0,0 +1,211 @@
+# 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.
+"""Exploding multi-part geometries, compared against GeoPandas."""
+
+import geopandas as gpd
+import pytest
+
+import sedonadb_geopandas as sgpd
+
+# Single, empty, multi-part, nested, Z, and missing geometries: the cases where
+# one-level parts, the engine's part count, and GeoPandas' row dropping differ.
+WKT = [
+    "POINT (1 1)",
+    "POINT EMPTY",
+    "LINESTRING EMPTY",
+    "POLYGON EMPTY",
+    "MULTIPOINT ((0 0), (1 1))",
+    "MULTIPOINT EMPTY",
+    "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))",
+    "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))",
+    "MULTIPOLYGON EMPTY",
+    "GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))",
+    "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING (0 0, 1 1))",
+    "GEOMETRYCOLLECTION EMPTY",
+    "MULTIPOINT Z ((0 0 1), (1 1 2))",
+    None,
+]
+
+
+def _geometries(series):
+    return list(zip(series.to_wkt(), series.has_z))
+
+
+def test_explode_matches_geopandas():
+    # One row per part, one level deep: a nested collection yields its
+    # members whole. An empty single geometry is its own one part, while a
+    # geometry with no parts, or a missing one, drops its row.
+    expected_source = gpd.GeoDataFrame(
+        {"id": range(len(WKT)), "label": [f"row{i}" for i in range(len(WKT))]},
+        geometry=gpd.GeoSeries.from_wkt(WKT),
+        crs=3857,
+    )
+    ours = sgpd.from_geopandas(expected_source).explode().to_geopandas()
+    expected = expected_source.explode()
+    assert list(ours.columns) == list(expected.columns)
+    assert list(ours["id"]) == list(expected["id"])
+    assert list(ours["label"]) == list(expected["label"])
+    assert _geometries(ours.geometry) == _geometries(expected.geometry)
+    assert ours.crs == expected.crs
+
+
+def test_explode_moves_the_geometry_column_last():
+    source = gpd.GeoDataFrame(
+        {"a": [1], "b": [2]},
+        geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"]),
+    )[["geometry", "a", "b"]].rename_geometry("pt")
+    ours = sgpd.from_geopandas(source).explode()
+    expected = source.explode()
+    assert ours.columns == list(expected.columns) == ["a", "b", "pt"]
+    assert ours._geometry_name == expected.geometry.name == "pt"
+    assert len(ours.to_geopandas()) == 2
+
+
+def test_explode_repeats_other_geometry_columns():
+    source = gpd.GeoDataFrame(
+        {"a": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+    )
+    source["other"] = gpd.GeoSeries.from_wkt(["POINT (5 5)"], crs=4326)
+    ours = sgpd.from_geopandas(source).explode().to_geopandas()
+    expected = source.explode()
+    assert list(ours.columns) == list(expected.columns)
+    assert _geometries(ours["other"]) == _geometries(expected["other"])
+    assert ours["other"].crs == expected["other"].crs
+
+
+def test_explode_keeps_geography():
+    # A multi-geometry and a nested collection take different routes to their
+    # parts; both keep the geography type and CRS.
+    gdf = sgpd.GeoDataFrame(
+        sgpd.default_context().sql(
+            "SELECT id, ST_GeogFromWKT(w) AS g FROM (VALUES "
+            "(1, 'MULTIPOINT ((0 0), (1 1))'), "
+            "(2, 'GEOMETRYCOLLECTION (MULTIPOINT ((5 5), (6 6)), POINT (7 7))')"
+            ") AS t(id, w)"
+        ),
+        geometry="g",
+    )
+    exploded = gdf.explode()
+    assert "geography" in str(exploded._df.schema.field("g").type)
+    assert exploded.crs == gdf.crs
+    result = exploded.to_geopandas().sort_values("id", kind="stable")
+    assert [g.wkt for g in result["g"]] == [
+        "POINT (0 0)",
+        "POINT (1 1)",
+        "MULTIPOINT ((5 5), (6 6))",
+        "POINT (7 7)",
+    ]
+
+
+def test_explode_keeps_geography_without_a_crs():
+    # Parts are rebuilt as geography, whose constructor assigns OGC:CRS84; a
+    # column without a CRS stays without one.
+    gdf = sgpd.GeoDataFrame(
+        sgpd.default_context().sql(
+            "SELECT 1 AS id, ST_SetSRID(ST_GeogFromWKT("
+            "'GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))'), 0) AS g "
+            "UNION ALL SELECT 2, ST_SetSRID(ST_GeogFromWKT('MULTIPOINT ((5 5), (6 6))'), 0)"
+        ),
+        geometry="g",
+    )
+    assert gdf.crs is None
+    exploded = gdf.explode()
+    assert exploded.crs is None
+    assert "geography" in str(exploded._df.schema.field("g").type)
+    assert len(exploded.to_geopandas()) == 4
+
+
+def test_explode_of_an_empty_frame():
+    source = gpd.GeoDataFrame(
+        {"a": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+    )
+    gdf = sgpd.from_geopandas(source)
+    exploded = gdf[gdf["a"] > 5].explode()
+    assert exploded.columns == ["a", "geometry"]
+    assert len(exploded.to_geopandas()) == 0
+
+
+def test_explode_with_columns_named_like_its_working_columns():
+    source = gpd.GeoDataFrame(
+        {"__dump": ["a", "b"], "__member": [1, 2], "__collection": [True, False]},
+        geometry=gpd.GeoSeries.from_wkt(
+            [
+                "MULTIPOINT ((0 0), (1 1))",
+                "GEOMETRYCOLLECTION (MULTIPOINT ((0 0), (1 1)), POINT (2 2))",
+            ]
+        ),
+    )
+    ours = sgpd.from_geopandas(source).explode().to_geopandas()
+    expected = source.explode()
+    assert list(ours.columns) == list(expected.columns)
+    assert list(ours["__dump"]) == list(expected["__dump"])
+    assert _geometries(ours.geometry) == _geometries(expected.geometry)
+
+
+def test_geoseries_explode_matches_geopandas():
+    source = gpd.GeoSeries.from_wkt(WKT, crs=3857, name="geom")
+    gdf = sgpd.from_geopandas(gpd.GeoDataFrame(source.to_frame(), geometry="geom"))
+    ours = gdf.geometry.explode().to_geopandas()
+    expected = source.explode()
+    assert ours.name == expected.name == "geom"
+    assert _geometries(ours) == _geometries(expected)
+    assert ours.crs == expected.crs
+
+
+def test_geoseries_explode_of_a_derived_series():
+    source = gpd.GeoDataFrame(
+        geometry=gpd.GeoSeries.from_wkt(
+            [
+                "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))",
+                "MULTIPOINT ((0 0), (1 1))",
+            ]
+        )
+    )
+    ours = sgpd.from_geopandas(source).geometry.boundary.explode().to_geopandas()
+    expected = source.geometry.boundary.explode()
+    assert _geometries(ours) == _geometries(expected)
+
+
+def test_geoseries_explode_belongs_to_a_new_frame():
+    # The exploded column has more rows than the frame it came from.
+    gdf = sgpd.from_geopandas(
+        gpd.GeoDataFrame(geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"]))
+    )
+    with pytest.raises(ValueError):
+        gdf["parts"] = gdf.geometry.explode()
+
+
+def test_explode_validation():
+    source = gpd.GeoDataFrame(
+        {"v": [1]}, geometry=gpd.GeoSeries.from_wkt(["MULTIPOINT ((0 0), (1 1))"])
+    )
+    source["other"] = gpd.GeoSeries.from_wkt(["POINT (5 5)"])
+    gdf = sgpd.from_geopandas(source)
+    # There is no index, so ignore_index changes nothing.
+    assert len(gdf.explode(ignore_index=True).to_geopandas()) == 2
+    with pytest.raises(NotImplementedError, match="index_parts"):
+        gdf.explode(index_parts=True)
+    with pytest.raises(NotImplementedError, match="index_parts"):
+        gdf.geometry.explode(index_parts=True)
+    with pytest.raises(NotImplementedError, match="active geometry"):
+        gdf.explode(column="v")
+    with pytest.raises(NotImplementedError, match="active geometry"):
+        gdf.explode(column="other")
+    with pytest.raises(KeyError):
+        gdf.explode(column="missing")
+    with pytest.raises(AttributeError, match="no active geometry"):
+        gdf[["v"]].explode()