Skip to content

Commit c17260b

Browse files
authored
feat(c/sedona-geos): Implement ST_IsRing (#231)
1 parent 6ba858b commit c17260b

File tree

4 files changed

+242
-5
lines changed

4 files changed

+242
-5
lines changed

c/sedona-geos/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod st_buffer;
2525
mod st_centroid;
2626
mod st_convexhull;
2727
mod st_dwithin;
28+
mod st_isring;
2829
mod st_isvalid;
2930
mod st_length;
3031
mod st_perimeter;

c/sedona-geos/src/register.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@
1616
// under the License.
1717
use sedona_expr::scalar_udf::ScalarKernelRef;
1818

19-
use crate::st_convexhull::st_convex_hull_impl;
20-
use crate::st_isvalid::st_is_valid_impl;
2119
use crate::{
2220
distance::st_distance_impl, st_area::st_area_impl, st_buffer::st_buffer_impl,
23-
st_centroid::st_centroid_impl, st_dwithin::st_dwithin_impl, st_length::st_length_impl,
21+
st_centroid::st_centroid_impl, st_convexhull::st_convex_hull_impl, st_dwithin::st_dwithin_impl,
22+
st_isring::st_is_ring_impl, st_isvalid::st_is_valid_impl, st_length::st_length_impl,
2423
st_perimeter::st_perimeter_impl,
2524
};
2625

@@ -42,14 +41,17 @@ pub fn scalar_kernels() -> Vec<(&'static str, ScalarKernelRef)> {
4241
("st_convexhull", st_convex_hull_impl()),
4342
("st_coveredby", st_covered_by_impl()),
4443
("st_covers", st_covers_impl()),
44+
("st_crosses", st_crosses_impl()),
4545
("st_difference", st_difference_impl()),
4646
("st_disjoint", st_disjoint_impl()),
4747
("st_distance", st_distance_impl()),
4848
("st_dwithin", st_dwithin_impl()),
4949
("st_equals", st_equals_impl()),
50-
("st_length", st_length_impl()),
5150
("st_intersection", st_intersection_impl()),
5251
("st_intersects", st_intersects_impl()),
52+
("st_isring", st_is_ring_impl()),
53+
("st_length", st_length_impl()),
54+
("st_overlaps", st_overlaps_impl()),
5355
("st_perimeter", st_perimeter_impl()),
5456
("st_symdifference", st_sym_difference_impl()),
5557
("st_touches", st_touches_impl()),

c/sedona-geos/src/st_isring.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
use std::sync::Arc;
19+
20+
use arrow_array::builder::BooleanBuilder;
21+
use arrow_schema::DataType;
22+
use datafusion_common::{error::Result, DataFusionError};
23+
use datafusion_expr::ColumnarValue;
24+
use geos::{Geom, GeometryTypes};
25+
use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel};
26+
use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher};
27+
28+
use crate::executor::GeosExecutor;
29+
30+
/// ST_IsRing() implementation using the geos crate
31+
pub fn st_is_ring_impl() -> ScalarKernelRef {
32+
Arc::new(STIsRing {})
33+
}
34+
35+
#[derive(Debug)]
36+
struct STIsRing {}
37+
38+
impl SedonaScalarKernel for STIsRing {
39+
fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
40+
let matcher = ArgMatcher::new(
41+
vec![ArgMatcher::is_geometry()],
42+
SedonaType::Arrow(DataType::Boolean),
43+
);
44+
45+
matcher.match_args(args)
46+
}
47+
48+
fn invoke_batch(
49+
&self,
50+
arg_types: &[SedonaType],
51+
args: &[ColumnarValue],
52+
) -> Result<ColumnarValue> {
53+
let executor = GeosExecutor::new(arg_types, args);
54+
let mut builder = BooleanBuilder::with_capacity(executor.num_iterations());
55+
56+
executor.execute_wkb_void(|maybe_wkb| {
57+
match maybe_wkb {
58+
Some(wkb) => {
59+
builder.append_value(invoke_scalar(&wkb)?);
60+
}
61+
_ => builder.append_null(),
62+
}
63+
Ok(())
64+
})?;
65+
66+
executor.finish(Arc::new(builder.finish()))
67+
}
68+
}
69+
70+
fn invoke_scalar(geos_geom: &geos::Geometry) -> Result<bool> {
71+
// Check if geometry is empty - (PostGIS compatibility)
72+
let is_empty = geos_geom.is_empty().map_err(|e| {
73+
DataFusionError::Execution(format!("Failed to check if geometry is a ring: {e}"))
74+
})?;
75+
76+
if is_empty {
77+
return Ok(false);
78+
}
79+
80+
// Check if geometry is a LineString - (PostGIS compatibility)
81+
if geos_geom.geometry_type() != GeometryTypes::LineString {
82+
return Err(DataFusionError::Execution(
83+
"ST_IsRing() should only be called on a linear feature".to_string(),
84+
));
85+
}
86+
87+
geos_geom.is_ring().map_err(|e| {
88+
DataFusionError::Execution(format!("Failed to check if geometry is a ring: {e}"))
89+
})
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use arrow_array::{create_array as arrow_array, ArrayRef};
95+
use rstest::rstest;
96+
use sedona_expr::scalar_udf::SedonaScalarUDF;
97+
use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY};
98+
use sedona_testing::compare::assert_array_equal;
99+
use sedona_testing::testers::ScalarUdfTester;
100+
101+
use super::*;
102+
103+
#[rstest]
104+
fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType) {
105+
let udf = SedonaScalarUDF::from_kernel("st_isring", st_is_ring_impl());
106+
let tester = ScalarUdfTester::new(udf.into(), vec![sedona_type]);
107+
tester.assert_return_type(DataType::Boolean);
108+
109+
// Valid ring (closed + simple) - square
110+
let result = tester
111+
.invoke_scalar("LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0)")
112+
.unwrap();
113+
tester.assert_scalar_result_equals(result, true);
114+
115+
// Valid ring (closed + simple) - triangle
116+
let result = tester
117+
.invoke_scalar("LINESTRING(0 0, 1 0, 1 1, 0 0)")
118+
.unwrap();
119+
tester.assert_scalar_result_equals(result, true);
120+
121+
// Non-LineString types should throw errors (PostGIS compatibility)
122+
123+
// Point (not a linestring) - should error
124+
let result = tester.invoke_scalar("POINT(21 52)");
125+
assert!(result.is_err());
126+
assert!(result
127+
.unwrap_err()
128+
.to_string()
129+
.contains("should only be called on a linear feature"));
130+
131+
// Polygon (not a linestring) - should error
132+
let result = tester.invoke_scalar("POLYGON((0 0, 0 5, 5 5, 5 0, 0 0))");
133+
assert!(result.is_err());
134+
assert!(result
135+
.unwrap_err()
136+
.to_string()
137+
.contains("should only be called on a linear feature"));
138+
139+
// MultiLineString (collection) - should error
140+
let result = tester.invoke_scalar("MULTILINESTRING((0 0, 0 1, 1 1, 1 0, 0 0))");
141+
assert!(result.is_err());
142+
assert!(result
143+
.unwrap_err()
144+
.to_string()
145+
.contains("should only be called on a linear feature"));
146+
147+
// GeometryCollection - should error
148+
let result =
149+
tester.invoke_scalar("GEOMETRYCOLLECTION(LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0))");
150+
assert!(result.is_err());
151+
assert!(result
152+
.unwrap_err()
153+
.to_string()
154+
.contains("should only be called on a linear feature"));
155+
156+
let input_wkt = vec![
157+
Some("LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0)"), // Valid ring => true
158+
Some("LINESTRING(0 0, 0 1, 1 0, 1 1, 0 0)"), // Self-intersecting => false
159+
Some("LINESTRING(0 0, 2 2)"), // Not closed => false
160+
Some("LINESTRING EMPTY"), // Empty => false
161+
Some("POINT EMPTY"), // Empty => false
162+
None, // NULL => null
163+
];
164+
165+
let expected: ArrayRef = arrow_array!(
166+
Boolean,
167+
[
168+
Some(true),
169+
Some(false),
170+
Some(false),
171+
Some(false),
172+
Some(false),
173+
None
174+
]
175+
);
176+
177+
assert_array_equal(&tester.invoke_wkb_array(input_wkt).unwrap(), &expected);
178+
}
179+
}

python/sedonadb/tests/functions/test_functions.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# under the License.
1717
import pytest
1818
import shapely
19-
from sedonadb.testing import geom_or_null, PostGIS, SedonaDB, val_or_null
19+
from sedonadb.testing import PostGIS, SedonaDB, geom_or_null, val_or_null
2020

2121

2222
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
@@ -661,6 +661,61 @@ def test_st_isclosed(eng, geom, expected):
661661
eng.assert_query_result(f"SELECT ST_IsClosed({geom_or_null(geom)})", expected)
662662

663663

664+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
665+
@pytest.mark.parametrize(
666+
("geom", "expected"),
667+
[
668+
(None, None),
669+
# Valid rings (closed + simple)
670+
("LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0)", True),
671+
("LINESTRING(0 0, 1 0, 1 1, 0 0)", True),
672+
("LINESTRING(0 0, 2 2, 1 2, 0 0)", True),
673+
# Closed but self-intersecting - bowtie shape (not simple)
674+
("LINESTRING(0 0, 0 1, 1 0, 1 1, 0 0)", False),
675+
# Not closed
676+
("LINESTRING(0 0, 1 1)", False),
677+
("LINESTRING(2 0, 2 2, 3 3)", False),
678+
("LINESTRING(0 0, 2 2)", False),
679+
# Empty geometries
680+
("LINESTRING EMPTY", False),
681+
("POINT EMPTY", False),
682+
("POLYGON EMPTY", False),
683+
("MULTIPOLYGON EMPTY", False),
684+
("GEOMETRYCOLLECTION EMPTY", False),
685+
],
686+
)
687+
def test_st_isring(eng, geom, expected):
688+
"""Test ST_IsRing with LineString geometries.
689+
690+
ST_IsRing returns true if the geometry is a closed and simple LineString.
691+
"""
692+
eng = eng.create_or_skip()
693+
eng.assert_query_result(f"SELECT ST_IsRing({geom_or_null(geom)})", expected)
694+
695+
696+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
697+
@pytest.mark.parametrize(
698+
("geom"),
699+
[
700+
"POINT(0 0)",
701+
"MULTIPOINT((0 0), (1 1))",
702+
"POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
703+
"MULTILINESTRING((0 0, 0 1, 1 1, 1 0, 0 0))",
704+
"GEOMETRYCOLLECTION(LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0))",
705+
],
706+
)
707+
def test_st_isring_non_linestring_error(eng, geom):
708+
"""Test that ST_IsRing throws errors for non-LineString non-empty geometries.
709+
710+
Both SedonaDB and PostGIS throw errors when ST_IsRing is called on
711+
non-LineString geometry types (PostGIS compatibility).
712+
"""
713+
eng = eng.create_or_skip()
714+
715+
with pytest.raises(Exception, match="linear|linestring"):
716+
eng.assert_query_result(f"SELECT ST_IsRing(ST_GeomFromText('{geom}'))", None)
717+
718+
664719
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
665720
@pytest.mark.parametrize(
666721
("geom", "expected"),

0 commit comments

Comments
 (0)