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

paleolimbot 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 b68c3410 feat(c/sedona-geos): Implement ST_Boundary() (#298)
b68c3410 is described below

commit b68c3410abc5b4fd4b2cd5a2946467e00814aa6b
Author: Abeeujah <[email protected]>
AuthorDate: Fri Nov 14 02:53:31 2025 +0100

    feat(c/sedona-geos): Implement ST_Boundary() (#298)
---
 c/sedona-geos/benches/geos-functions.rs           |   3 +
 c/sedona-geos/src/lib.rs                          |   1 +
 c/sedona-geos/src/register.rs                     |   2 +
 c/sedona-geos/src/st_boundary.rs                  | 344 ++++++++++++++++++++++
 c/sedona-geos/src/st_simplify.rs                  |   1 +
 python/sedonadb/tests/functions/test_functions.py |  90 ++++++
 6 files changed, 441 insertions(+)

diff --git a/c/sedona-geos/benches/geos-functions.rs 
b/c/sedona-geos/benches/geos-functions.rs
index 56f3bb59..3653ec12 100644
--- a/c/sedona-geos/benches/geos-functions.rs
+++ b/c/sedona-geos/benches/geos-functions.rs
@@ -28,6 +28,9 @@ fn criterion_benchmark(c: &mut Criterion) {
     benchmark::scalar(c, &f, "geos", "st_area", Polygon(10));
     benchmark::scalar(c, &f, "geos", "st_area", Polygon(500));
 
+    benchmark::scalar(c, &f, "geos", "st_boundary", Polygon(10));
+    benchmark::scalar(c, &f, "geos", "st_boundary", Polygon(500));
+
     benchmark::scalar(
         c,
         &f,
diff --git a/c/sedona-geos/src/lib.rs b/c/sedona-geos/src/lib.rs
index 6bad2b81..94667123 100644
--- a/c/sedona-geos/src/lib.rs
+++ b/c/sedona-geos/src/lib.rs
@@ -21,6 +21,7 @@ mod geos;
 mod overlay;
 pub mod register;
 mod st_area;
+mod st_boundary;
 mod st_buffer;
 mod st_centroid;
 mod st_convexhull;
diff --git a/c/sedona-geos/src/register.rs b/c/sedona-geos/src/register.rs
index e4263c5a..e077c992 100644
--- a/c/sedona-geos/src/register.rs
+++ b/c/sedona-geos/src/register.rs
@@ -20,6 +20,7 @@ use sedona_expr::scalar_udf::ScalarKernelRef;
 use crate::{
     distance::st_distance_impl,
     st_area::st_area_impl,
+    st_boundary::st_boundary_impl,
     st_buffer::{st_buffer_impl, st_buffer_style_impl},
     st_centroid::st_centroid_impl,
     st_convexhull::st_convex_hull_impl,
@@ -50,6 +51,7 @@ use crate::overlay::{
 pub fn scalar_kernels() -> Vec<(&'static str, ScalarKernelRef)> {
     vec![
         ("st_area", st_area_impl()),
+        ("st_boundary", st_boundary_impl()),
         ("st_buffer", st_buffer_impl()),
         ("st_buffer", st_buffer_style_impl()),
         ("st_centroid", st_centroid_impl()),
diff --git a/c/sedona-geos/src/st_boundary.rs b/c/sedona-geos/src/st_boundary.rs
new file mode 100644
index 00000000..f89344d6
--- /dev/null
+++ b/c/sedona-geos/src/st_boundary.rs
@@ -0,0 +1,344 @@
+// 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.
+
+use std::sync::Arc;
+
+use arrow_array::builder::BinaryBuilder;
+use datafusion_common::{error::Result, DataFusionError};
+use datafusion_expr::ColumnarValue;
+use geos::{Geom, Geometry, GeometryTypes};
+use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel};
+use sedona_geometry::wkb_factory::WKB_MIN_PROBABLE_BYTES;
+use sedona_schema::{
+    datatypes::{SedonaType, WKB_GEOMETRY},
+    matchers::ArgMatcher,
+};
+
+use crate::executor::GeosExecutor;
+
+/// ST_Boundary() implementation using the geos crate
+pub fn st_boundary_impl() -> ScalarKernelRef {
+    Arc::new(STBoundary {})
+}
+
+#[derive(Debug)]
+struct STBoundary {}
+
+impl SedonaScalarKernel for STBoundary {
+    fn return_type(&self, args: &[SedonaType]) -> 
datafusion_common::Result<Option<SedonaType>> {
+        let matcher = ArgMatcher::new(vec![ArgMatcher::is_geometry()], 
WKB_GEOMETRY);
+
+        matcher.match_args(args)
+    }
+
+    fn invoke_batch(
+        &self,
+        arg_types: &[SedonaType],
+        args: &[ColumnarValue],
+    ) -> datafusion_common::Result<ColumnarValue> {
+        let executor = GeosExecutor::new(arg_types, args);
+        let mut builder = BinaryBuilder::with_capacity(
+            executor.num_iterations(),
+            WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+        );
+
+        executor.execute_wkb_void(|maybe_wkb| {
+            match maybe_wkb {
+                Some(wkb) => {
+                    invoke_scalar(&wkb, &mut builder)?;
+                    builder.append_value([]);
+                }
+                _ => builder.append_null(),
+            }
+            Ok(())
+        })?;
+
+        executor.finish(Arc::new(builder.finish()))
+    }
+}
+
+fn invoke_scalar(geos_geom: &geos::Geometry, writer: &mut BinaryBuilder) -> 
Result<()> {
+    let result_geom = geos_boundary(geos_geom)?;
+
+    let wkb = result_geom
+        .to_wkb()
+        .map_err(|e| DataFusionError::Execution(format!("Failed to convert to 
wkb: {e}")))?;
+
+    writer.append_value(wkb.as_ref());
+    Ok(())
+}
+
+/// For simple geometries, it calls `geometry.boundary()`.
+/// For a `GeometryCollection`, it recursively computes the boundary for each
+/// component and then re-aggregates the resulting geometry types (Point, 
LineString, etc.)
+/// into their respective Multi-geometry types (MultiPoint, MultiLineString, 
etc.)
+/// before forming the final `GeometryCollection`. This aggregation step is 
crucial
+/// for adhering to OGC specifications for `GeometryCollection` boundaries.
+fn geos_boundary(geometry: &impl Geom) -> Result<Geometry> {
+    if geometry.geometry_type() == GeometryTypes::GeometryCollection {
+        let num_geometries = geometry.get_num_geometries().map_err(|e| {
+            DataFusionError::Execution(format!("Failed to get number of 
geometries: {e}"))
+        })?;
+
+        let mut empty_collections: Vec<Geometry> = Vec::new();
+        let mut points: Vec<Geometry> = Vec::new();
+        let mut lines: Vec<Geometry> = Vec::new();
+        let mut polygons: Vec<Geometry> = Vec::new();
+
+        for i in 0..num_geometries {
+            let child_geom = geometry.get_geometry_n(i).map_err(|e| {
+                DataFusionError::Execution(format!("Failed to get {}th child 
geometry: {e}", i + 1))
+            })?;
+
+            // Recursively calculate the boundary of the child geometry
+            let child_boundary = geos_boundary(&child_geom)?;
+
+            // Collect components based on whether they are an empty 
GeometryCollection
+            if is_empty_geometry_collection(&child_boundary)? {
+                empty_collections.push(child_boundary);
+            } else {
+                // Collect and group non-empty boundary components (Points, 
LineStrings, etc.)
+                collect_boundary_components(
+                    &child_boundary,
+                    &mut points,
+                    &mut lines,
+                    &mut polygons,
+                )?;
+            }
+        }
+
+        let mut result_components: Vec<Geometry> = Vec::new();
+
+        // Aggregate the result
+        result_components.extend(empty_collections);
+
+        if points.len() == 1 {
+            result_components.push(points.into_iter().next().unwrap());
+        } else if !points.is_empty() {
+            let multi_point = Geometry::create_multipoint(points).map_err(|e| {
+                DataFusionError::Execution(format!("Failed to create 
multipoint: {e}"))
+            })?;
+            result_components.push(multi_point);
+        }
+
+        if lines.len() == 1 {
+            result_components.push(lines.into_iter().next().unwrap());
+        } else if !lines.is_empty() {
+            let multi_line = 
Geometry::create_multiline_string(lines).map_err(|e| {
+                DataFusionError::Execution(format!("Failed to create 
multilinestring: {e}"))
+            })?;
+            result_components.push(multi_line);
+        }
+
+        if polygons.len() == 1 {
+            result_components.push(polygons.into_iter().next().unwrap());
+        } else if !polygons.is_empty() {
+            let multi_polygon = 
Geometry::create_multipolygon(polygons).map_err(|e| {
+                DataFusionError::Execution(format!("Failed to create 
multipolygon: {e}"))
+            })?;
+            result_components.push(multi_polygon);
+        }
+
+        if result_components.len() == 1 {
+            Ok(Geom::clone(result_components.first().unwrap()))
+        } else {
+            
Geometry::create_geometry_collection(result_components).map_err(|e| {
+                DataFusionError::Execution(format!("Failed to create geometry 
collection: {e}"))
+            })
+        }
+    } else {
+        // For simple geometries, use the standard geos boundary function
+        geometry
+            .boundary()
+            .map_err(|e| DataFusionError::Execution(format!("Failed to 
calculate boundary: {e}")))
+    }
+}
+
+/// Checks if a geometry is an empty `GeometryCollection`.
+fn is_empty_geometry_collection(geom: &Geometry) -> Result<bool> {
+    if geom.geometry_type() == GeometryTypes::GeometryCollection {
+        let num = geom.get_num_geometries().map_err(|e| {
+            DataFusionError::Execution(format!("Failed to get number of 
geometries: {e}"))
+        })?;
+        Ok(num == 0)
+    } else {
+        Ok(false)
+    }
+}
+
+/// Recursively collects and groups individual boundary components (Point, 
LineString, etc.)
+/// from a given boundary geometry (which could be a `GeometryCollection` 
itself)
+/// into mutable vectors based on their type.
+fn collect_boundary_components(
+    boundary: &Geometry,
+    points: &mut Vec<Geometry>,
+    lines: &mut Vec<Geometry>,
+    polygons: &mut Vec<Geometry>,
+) -> Result<()> {
+    match boundary.geometry_type() {
+        // Recurse into sub-geometries if it's a collection
+        GeometryTypes::GeometryCollection => {
+            let num_geoms = boundary.get_num_geometries().map_err(|e| {
+                DataFusionError::Execution(format!("Failed to get number of 
geometries: {e}"))
+            })?;
+
+            for i in 0..num_geoms {
+                let component = boundary.get_geometry_n(i).map_err(|e| {
+                    DataFusionError::Execution(format!("Failed to get {}th 
geometry: {e}", i + 1))
+                })?;
+                let owned_component = Geom::clone(&component);
+                collect_boundary_components(&owned_component, points, lines, 
polygons)?;
+            }
+        }
+        // Collect simple, single-part components
+        GeometryTypes::Point => {
+            points.push(Geom::clone(boundary));
+        }
+        GeometryTypes::LineString => {
+            lines.push(Geom::clone(boundary));
+        }
+        GeometryTypes::Polygon => {
+            polygons.push(Geom::clone(boundary));
+        }
+        // Decompose Multi-geometries and collect their parts
+        GeometryTypes::MultiPoint => {
+            let num_points = boundary.get_num_geometries().map_err(|e| {
+                DataFusionError::Execution(format!("Failed to get number of 
points: {e}"))
+            })?;
+            for i in 0..num_points {
+                let point = boundary.get_geometry_n(i).map_err(|e| {
+                    DataFusionError::Execution(format!("Failed to get {}th 
point: {e}", i + 1))
+                })?;
+                points.push(Geom::clone(&point));
+            }
+        }
+        GeometryTypes::MultiLineString => {
+            let num_lines = boundary.get_num_geometries().map_err(|e| {
+                DataFusionError::Execution(format!("Failed to get number of 
linestrings: {e}"))
+            })?;
+            for i in 0..num_lines {
+                let line = boundary.get_geometry_n(i).map_err(|e| {
+                    DataFusionError::Execution(format!("Failed to get {}th 
linestring: {e}", i + 1))
+                })?;
+                lines.push(Geom::clone(&line));
+            }
+        }
+        GeometryTypes::MultiPolygon => {
+            let num_polygons = boundary.get_num_geometries().map_err(|e| {
+                DataFusionError::Execution(format!("Failed to get number of 
polygons: {e}"))
+            })?;
+            for i in 0..num_polygons {
+                let polygon = boundary.get_geometry_n(i).map_err(|e| {
+                    DataFusionError::Execution(format!("Failed to get {}th 
polygon: {e}", i + 1))
+                })?;
+                polygons.push(Geom::clone(&polygon));
+            }
+        }
+        // Ignore other types (e.g., empty geometries)
+        _ => {}
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use rstest::rstest;
+    use sedona_expr::scalar_udf::SedonaScalarUDF;
+    use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY};
+    use sedona_testing::testers::ScalarUdfTester;
+
+    use super::*;
+
+    #[rstest]
+    fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType) 
{
+        let udf = SedonaScalarUDF::from_kernel("st_boundary", 
st_boundary_impl());
+        let tester = ScalarUdfTester::new(udf.into(), vec![sedona_type]);
+        tester.assert_return_type(WKB_GEOMETRY);
+
+        let result = tester
+            .invoke_scalar(
+                "GEOMETRYCOLLECTION(LINESTRING(1 1,2 
2),GEOMETRYCOLLECTION(POLYGON((3 3,4 4,5 5,3 
3)),GEOMETRYCOLLECTION(LINESTRING(6 6,7 7),POLYGON((8 8,9 9,10 10,8 8)))))",
+            )
+            .unwrap();
+        tester.assert_scalar_result_equals(result, 
"GEOMETRYCOLLECTION(MULTIPOINT((1 1),(2 2),(6 6),(7 7)),MULTILINESTRING((3 3,4 
4,5 5,3 3),(8 8,9 9,10 10,8 8)))");
+
+        let result = tester
+            .invoke_scalar("LINESTRING(100 150,50 60, 70 80, 160 170)")
+            .unwrap();
+        tester.assert_scalar_result_equals(result, "MULTIPOINT((100 150),(160 
170))");
+
+        let result = tester
+            .invoke_scalar(
+                "POLYGON (( 10 130, 50 190, 110 190, 140 150, 150 80, 100 10, 
20 40, 10 130 ), ( 70 40, 100 50, 120 80, 80 110, 50 90, 70 40 ))"
+            )
+            .unwrap();
+        tester.assert_scalar_result_equals(
+            result,
+            "MULTILINESTRING((10 130,50 190,110 190,140 150,150 80,100 10,20 
40,10 130), (70 40,100 50,120 80,80 110,50 90,70 40))"
+        );
+
+        let result = tester
+            .invoke_scalar("MULTILINESTRING ((10 10, 20 20), (30 30, 40 40, 30 
30))")
+            .unwrap();
+        tester.assert_scalar_result_equals(result, "MULTIPOINT (10 10, 20 
20)");
+
+        let result = tester.invoke_scalar("GEOMETRYCOLLECTION(MULTIPOINT(-2 3, 
-2 2), LINESTRING(5 5, 10 10), POLYGON((-7 4.2, -7.1 5, -7.1 4.3, -7 
4.2)))").unwrap();
+        tester.assert_scalar_result_equals(
+            result,
+            "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION EMPTY, MULTIPOINT(5 5, 10 
10), LINESTRING(-7 4.2, -7.1 5, -7.1 4.3, -7 4.2))"
+        );
+
+        let result = tester.invoke_scalar("POINT (10 20)").unwrap();
+        tester.assert_scalar_result_equals(result, "GEOMETRYCOLLECTION EMPTY");
+
+        let result = tester
+            .invoke_scalar("MULTIPOINT (5 5, 10 10, 15 15)")
+            .unwrap();
+        tester.assert_scalar_result_equals(result, "GEOMETRYCOLLECTION EMPTY");
+
+        let result = tester
+            .invoke_scalar("LINESTRING (0 0, 1 1, 0 1, 0 0)")
+            .unwrap();
+        tester.assert_scalar_result_equals(result, "MULTIPOINT EMPTY");
+
+        let result = tester
+            .invoke_scalar("POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))")
+            .unwrap();
+        tester.assert_scalar_result_equals(result, "LINESTRING(0 0,0 10,10 
10,10 0,0 0)");
+
+        let result = tester
+            .invoke_scalar(
+                "MULTIPOLYGON (((0 0, 0 1, 1 1, 1 0, 0 0)), ((10 10, 10 11, 11 
11, 11 10, 10 10)))",
+            )
+            .unwrap();
+        tester.assert_scalar_result_equals(
+            result,
+            "MULTILINESTRING((0 0,0 1,1 1,1 0,0 0),(10 10,10 11,11 11,11 10,10 
10))",
+        );
+
+        let result = tester
+            .invoke_scalar("GEOMETRYCOLLECTION(POLYGON ((0 0, 0 1, 1 1, 1 0, 0 
0)), GEOMETRYCOLLECTION(LINESTRING(10 10, 10 20)))")
+            .unwrap();
+        tester.assert_scalar_result_equals(
+            result,
+            "GEOMETRYCOLLECTION(MULTIPOINT((10 10),(10 20)), LINESTRING(0 0,0 
1,1 1,1 0,0 0))",
+        );
+
+        let result = tester.invoke_scalar("GEOMETRYCOLLECTION EMPTY").unwrap();
+        tester.assert_scalar_result_equals(result, "GEOMETRYCOLLECTION EMPTY");
+    }
+}
diff --git a/c/sedona-geos/src/st_simplify.rs b/c/sedona-geos/src/st_simplify.rs
index 22b0b624..c3082cc5 100644
--- a/c/sedona-geos/src/st_simplify.rs
+++ b/c/sedona-geos/src/st_simplify.rs
@@ -31,6 +31,7 @@ use sedona_schema::{
 
 use crate::executor::GeosExecutor;
 
+/// ST_Simplify() implementation using the geos crate
 pub fn st_simplify_impl() -> ScalarKernelRef {
     Arc::new(STSimplify {})
 }
diff --git a/python/sedonadb/tests/functions/test_functions.py 
b/python/sedonadb/tests/functions/test_functions.py
index 0250c179..bb1494c2 100644
--- a/python/sedonadb/tests/functions/test_functions.py
+++ b/python/sedonadb/tests/functions/test_functions.py
@@ -142,6 +142,96 @@ def test_st_azimuth(eng, geom1, geom2, expected):
     )
 
 
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+    ("geom", "expected_boundary"),
+    [
+        (None, None),
+        ("LINESTRING(1 1, 0 0, -1 1)", "MULTIPOINT (1 1, -1 1)"),
+        ("POLYGON((1 1,0 0, -1 1, 1 1))", "LINESTRING (1 1, 0 0, -1 1, 1 1)"),
+        (
+            "LINESTRING(100 150,50 60, 70 80, 160 170)",
+            "MULTIPOINT (100 150, 160 170)",
+        ),
+        (
+            "POLYGON (( 10 130, 50 190, 110 190, 140 150, 150 80, 100 10, 20 
40, 10 130 ), ( 70 40, 100 50, 120 80, 80 110, 50 90, 70 40 ))",
+            "MULTILINESTRING ((10 130, 50 190, 110 190, 140 150, 150 80, 100 
10, 20 40, 10 130), (70 40, 100 50, 120 80, 80 110, 50 90, 70 40))",
+        ),
+        (
+            "MULTILINESTRING ((1 1, 2 2), (3 3, 4 4))",
+            "MULTIPOINT (1 1, 2 2, 3 3, 4 4)",
+        ),
+        (
+            "MULTILINESTRING ((10 10, 20 20), (30 30, 40 40, 30 30))",
+            "MULTIPOINT (10 10, 20 20)",
+        ),
+        ("POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))", "LINESTRING (0 0, 0 1, 1 1, 1 
0, 0 0)"),
+        (
+            "MULTIPOLYGON (((0 0, 0 1, 1 1, 1 0, 0 0)), ((10 10, 10 11, 11 11, 
11 10, 10 10)))",
+            "MULTILINESTRING ((0 0, 0 1, 1 1, 1 0, 0 0), (10 10, 10 11, 11 11, 
11 10, 10 10))",
+        ),
+        (
+            "MULTIPOLYGON (((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 2 8, 8 8, 8 
2, 2 2)))",
+            "MULTILINESTRING ((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 2 8, 8 8, 8 
2, 2 2))",  # Note: Order of points in inner ring may vary by implementation, 
but boundary is correct.
+        ),
+        (
+            "GEOMETRYCOLLECTION(POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)), 
GEOMETRYCOLLECTION(LINESTRING(10 10, 10 20)))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (10 10, 10 20), LINESTRING (0 0, 0 
1, 1 1, 1 0, 0 0))",
+        ),
+        (
+            "GEOMETRYCOLLECTION(LINESTRING(1 1,2 
2),GEOMETRYCOLLECTION(POLYGON((3 3,4 4,5 5,3 
3)),GEOMETRYCOLLECTION(LINESTRING(6 6,7 7),POLYGON((8 8,9 9,10 10,8 8)))))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (1 1, 2 2, 6 6, 7 7), 
MULTILINESTRING ((3 3, 4 4, 5 5, 3 3), (8 8, 9 9, 10 10, 8 8)))",
+        ),
+        (
+            "GEOMETRYCOLLECTION(LINESTRING(10 10,20 
20),GEOMETRYCOLLECTION(POLYGON((30 30,40 40,50 50,30 
30)),GEOMETRYCOLLECTION(LINESTRING(60 60,70 70),LINESTRING(80 80,90 90))))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (10 10, 20 20, 60 60, 70 70, 80 
80, 90 90), LINESTRING (30 30, 40 40, 50 50, 30 30))",
+        ),
+        (
+            "GEOMETRYCOLLECTION(POLYGON((1 1,2 2,3 3,1 
1)),GEOMETRYCOLLECTION(LINESTRING(4 4,5 5),GEOMETRYCOLLECTION(POLYGON((6 6,7 
7,8 8,6 6)),LINESTRING(9 9,10 10))))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (4 4, 5 5, 9 9, 10 10), 
MULTILINESTRING ((1 1, 2 2, 3 3, 1 1), (6 6, 7 7, 8 8, 6 6)))",
+        ),
+        (
+            "GEOMETRYCOLLECTION(LINESTRING(1 1,1 10,10 10,10 1,1 
1),GEOMETRYCOLLECTION(LINESTRING(2 2,2 9,9 9,9 2,2 
2),GEOMETRYCOLLECTION(POLYGON((3 3,3 8,8 8,8 3,3 3)),POLYGON((4 4,4 7,7 7,7 4,4 
4)))))",
+            "MULTILINESTRING ((3 3, 3 8, 8 8, 8 3, 3 3), (4 4, 4 7, 7 7, 7 4, 
4 4))",
+        ),
+        (
+            "GEOMETRYCOLLECTION(POLYGON((0 0,10 0,10 10,0 10,0 
0)),GEOMETRYCOLLECTION(LINESTRING(1 1,9 9),GEOMETRYCOLLECTION(LINESTRING(1 9,9 
1),POLYGON((2 2,8 2,8 8,2 8,2 2)))))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (1 1, 9 9, 1 9, 9 1), 
MULTILINESTRING ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 8 2, 8 8, 2 8, 2 2)))",
+        ),
+        (
+            
"GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(LINESTRING(1 2,3 
4),POLYGON((5 6,7 8,9 10,5 6))),POLYGON((11 12,13 14,15 16,11 
12))),LINESTRING(17 18,19 20))",
+            "GEOMETRYCOLLECTION (MULTIPOINT (1 2, 3 4, 17 18, 19 20), 
MULTILINESTRING ((5 6, 7 8, 9 10, 5 6), (11 12, 13 14, 15 16, 11 12)))",
+        ),
+    ],
+)
+def test_st_boundary(eng, geom, expected_boundary):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Boundary({geom_or_null(geom)})", expected_boundary
+    )
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+    ("geom", "is_empty"),
+    [
+        ("POINT (5 10)", True),
+        ("POINT (0 0)", True),
+        ("POINT (-1 -5)", True),
+        ("MULTIPOINT (100 200)", True),
+        ("MULTIPOINT (5 10, 15 20)", True),
+        ("MULTIPOINT (1 1, 2 2, 3 3, 1 1)", True),
+        ("LINESTRING(10 10, 20 20, 30 10, 10 10)", True),
+        ("MULTILINESTRING ((0 0, 0 1, 1 0, 0 0), (10 10, 10 20, 20 10, 10 
10))", True),
+    ],
+)
+def test_st_boundary_empty(eng, geom, is_empty):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_IsEmpty(ST_Boundary({geom_or_null(geom)}))", is_empty
+    )
+
+
 @pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
 @pytest.mark.parametrize(
     ("geom", "dist", "expected_area"),

Reply via email to