Skip to content

Commit 958b447

Browse files
authored
feat(c/geoarrow-geos): Implement ST_IsSimple (#239)
1 parent eb47819 commit 958b447

File tree

5 files changed

+271
-1
lines changed

5 files changed

+271
-1
lines changed

c/sedona-geos/benches/geos-functions.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ fn criterion_benchmark(c: &mut Criterion) {
213213
ArrayScalar(Polygon(10), Polygon(500)),
214214
);
215215

216+
benchmark::scalar(c, &f, "geos", "st_issimple", Polygon(10));
217+
benchmark::scalar(c, &f, "geos", "st_issimple", Polygon(500));
218+
216219
benchmark::scalar(c, &f, "geos", "st_isvalid", Polygon(10));
217220
benchmark::scalar(c, &f, "geos", "st_isvalid", Polygon(500));
218221

c/sedona-geos/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod st_centroid;
2626
mod st_convexhull;
2727
mod st_dwithin;
2828
mod st_isring;
29+
mod st_issimple;
2930
mod st_isvalid;
3031
mod st_isvalidreason;
3132
mod st_length;

c/sedona-geos/src/register.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use sedona_expr::scalar_udf::ScalarKernelRef;
1919
use crate::{
2020
distance::st_distance_impl, st_area::st_area_impl, st_buffer::st_buffer_impl,
2121
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,
22+
st_isring::st_is_ring_impl, st_issimple::st_is_simple_impl, st_isvalid::st_is_valid_impl,
2323
st_isvalidreason::st_is_valid_reason_impl, st_length::st_length_impl,
2424
st_perimeter::st_perimeter_impl, st_unaryunion::st_unary_union_impl,
2525
};
@@ -51,6 +51,7 @@ pub fn scalar_kernels() -> Vec<(&'static str, ScalarKernelRef)> {
5151
("st_intersection", st_intersection_impl()),
5252
("st_intersects", st_intersects_impl()),
5353
("st_isring", st_is_ring_impl()),
54+
("st_issimple", st_is_simple_impl()),
5455
("st_isvalid", st_is_valid_impl()),
5556
("st_isvalidreason", st_is_valid_reason_impl()),
5657
("st_length", st_length_impl()),

c/sedona-geos/src/st_issimple.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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::{DataFusionError, Result};
23+
use datafusion_expr::ColumnarValue;
24+
use geos::Geom;
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_IsSimple() implementation using the geos crate
31+
pub fn st_is_simple_impl() -> ScalarKernelRef {
32+
Arc::new(STIsSimple {})
33+
}
34+
35+
#[derive(Debug)]
36+
struct STIsSimple {}
37+
38+
impl SedonaScalarKernel for STIsSimple {
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+
executor.execute_wkb_void(|maybe_wkb| {
56+
match maybe_wkb {
57+
Some(wkb) => {
58+
builder.append_value(invoke_scalar(&wkb)?);
59+
}
60+
_ => builder.append_null(),
61+
}
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+
geos_geom.is_simple().map_err(|e| {
72+
DataFusionError::Execution(format!("Failed to check if geometry is simple: {e}"))
73+
})
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
79+
use arrow_array::{ArrayRef, BooleanArray};
80+
use datafusion_common::ScalarValue;
81+
use rstest::rstest;
82+
use sedona_expr::scalar_udf::SedonaScalarUDF;
83+
use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY};
84+
use sedona_testing::testers::ScalarUdfTester;
85+
86+
use super::*;
87+
88+
#[rstest]
89+
fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType) {
90+
let udf = SedonaScalarUDF::from_kernel("st_issimple", st_is_simple_impl());
91+
let tester = ScalarUdfTester::new(udf.into(), vec![sedona_type]);
92+
tester.assert_return_type(DataType::Boolean);
93+
94+
// Simple Polygon
95+
let result = tester
96+
.invoke_scalar("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
97+
.unwrap();
98+
tester.assert_scalar_result_equals(result, true);
99+
100+
// Complex Polygon (self-intersecting)
101+
let result = tester
102+
.invoke_scalar("POLYGON ((0 0, 1 1, 0 1, 1 0, 0 0))")
103+
.unwrap();
104+
tester.assert_scalar_result_equals(result, false);
105+
106+
let result = tester.invoke_scalar(ScalarValue::Null).unwrap();
107+
assert!(result.is_null());
108+
109+
let input_wkt = vec![
110+
None, // Null
111+
Some("POINT (1 1)"), // Points are always simple (T)
112+
Some("MULTIPOINT (1 1, 2 2, 3 3)"), // Points are always simple (T)
113+
Some("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"), // Simple Polygon (T)
114+
Some("POLYGON ((0 0, 1 1, 0 1, 1 0, 0 0))"), // Complex Polygon (F)
115+
Some("POLYGON((1 2, 3 4, 5 6, 1 2))"), // POSTGIS Reference (F)
116+
Some("LINESTRING (0 0, 1 1)"), // Simple LineString (T)
117+
Some("LINESTRING (0 0, 1 1, 0 1, 1 0)"), // Complex LineString (F)
118+
Some("LINESTRING(1 1,2 2,2 3.5,1 3,1 2,2 1)"), // POSTGIS Reference (F)
119+
Some("MULTILINESTRING ((0 0, 1 1), (1 1, 2 2))"), // Simple MultiLineString (T)
120+
Some("MULTILINESTRING ((0 0, 2 2), (0 2, 2 0))"), // Complex MultiLineString (F)
121+
Some("POINT (10 10)"), // Point (T)
122+
Some("GEOMETRYCOLLECTION EMPTY"), // Empty (T)
123+
Some("Polygon((0 0, 2 0, 1 1, 2 2, 0 2, 1 1, 0 0))"), // Complex Polygon (F)
124+
Some("MULTIPOLYGON (((0 0, 0 1, 1 1, 1 0, 0 0)), ((2 2, 2 3, 3 3, 3 2, 2 2)))"), // Holes are fine (T)
125+
Some("POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 0 2, 2 2, 1 1))"), // Holes are fine (T)
126+
];
127+
128+
let expected: ArrayRef = Arc::new(BooleanArray::from(vec![
129+
None,
130+
Some(true),
131+
Some(true),
132+
Some(true),
133+
Some(false),
134+
Some(false),
135+
Some(true),
136+
Some(false),
137+
Some(false),
138+
Some(true),
139+
Some(false),
140+
Some(true),
141+
Some(true),
142+
Some(false),
143+
Some(true),
144+
Some(true),
145+
]));
146+
assert_eq!(&tester.invoke_wkb_array(input_wkt).unwrap(), &expected);
147+
}
148+
}

python/sedonadb/tests/functions/test_functions.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,123 @@ def test_st_centroid(eng, geom, expected):
211211
eng.assert_query_result(f"SELECT ST_Centroid({geom_or_null(geom)})", expected)
212212

213213

214+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
215+
@pytest.mark.parametrize(
216+
("geom", "expected"),
217+
[
218+
(None, None),
219+
# POINTS - Always simple (single point has no self-intersections)
220+
("POINT (1 1)", True),
221+
("POINT EMPTY", True), # Empty geometry is simple
222+
# MULTIPOINTS
223+
("MULTIPOINT (1 1, 2 2, 3 3)", True), # Distinct points
224+
("MULTIPOINT (1 1, 2 2, 1 1)", False), # Duplicate points make it non-simple
225+
("MULTIPOINT EMPTY", True), # Empty multipoint
226+
("MULTIPOINT (1 1, 2 2, 3 3)", True),
227+
# LINESTRINGS
228+
("LINESTRING (0 0, 1 1)", True), # Simple straight line
229+
("LINESTRING (0 0, 1 1, 2 2)", True), # Simple line, collinear points
230+
("LINESTRING (0 0, 1 1, 0 1, 1 0)", False), # Self-intersecting (bowtie shape)
231+
("LINESTRING(1 1,2 2,2 3.5,1 3,1 2,2 1)", False), # Complex self-intersection
232+
(
233+
"LINESTRING (0 0, 1 1, 0 0)",
234+
False,
235+
), # Closed loop with repeated start/end but intersects at interior
236+
("LINESTRING (0 0, 1 1, 1 0, 0 0)", True), # Simple closed ring (triangle)
237+
("LINESTRING EMPTY", True), # Empty linestring
238+
# POLYGONS
239+
("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))", True), # Simple rectangle
240+
(
241+
"POLYGON ((0 0, 1 1, 0 1, 1 0, 0 0))",
242+
False,
243+
), # Bowtie polygon - self-intersecting
244+
(
245+
"POLYGON((1 2, 3 4, 5 6, 1 2))",
246+
False,
247+
), # Degenerate polygon - zero-area Triangle
248+
(
249+
"Polygon((0 0, 2 0, 1 1, 2 2, 0 2, 1 1, 0 0))",
250+
False,
251+
), # Star shape with self-intersection
252+
(
253+
"POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 2 1, 2 2, 1 2, 1 1))",
254+
True,
255+
), # Polygon with hole, valid
256+
(
257+
"POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 0 2, 2 2, 1 1))",
258+
True,
259+
), # Valid OGC Polygon (is also considered 'Simple' by OGC standard)
260+
# MULTILINESTRINGS
261+
(
262+
"MULTILINESTRING ((0 0, 1 1), (1 1, 2 2))",
263+
True,
264+
), # Touching at endpoints only
265+
("MULTILINESTRING ((0 0, 2 2), (0 2, 2 0))", False), # Lines cross in middle
266+
("MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))", True), # Disjoint lines
267+
(
268+
"MULTILINESTRING ((0 0, 1 1, 2 2), (2 2, 3 3))",
269+
True,
270+
), # Connected at endpoint
271+
(
272+
"MULTILINESTRING ((0 0, 2 0, 2 2, 0 2, 0 0), (1 1, 3 1, 3 3, 1 3, 1 1))",
273+
False,
274+
), # Not simple: The two rings overlap and intersect (2 1), violating the MULTILINESTRING simplicity rule.
275+
("MULTILINESTRING ((0 0, 2 2), (1 0, 1 2))", False), # Lines intersect at (1,1)
276+
("MULTILINESTRING EMPTY", True), # Empty multilinestring
277+
# MULTIPOLYGONS
278+
("MULTIPOLYGON (((0 0, 0 1, 1 1, 1 0, 0 0)))", True), # Single simple polygon
279+
(
280+
"MULTIPOLYGON (((0 0, 0 2, 2 2, 2 0, 0 0)), ((3 0, 3 2, 5 2, 5 0, 3 0)))",
281+
True,
282+
), # Two disjoint polygons
283+
(
284+
"MULTIPOLYGON (((0 0, 0 2, 2 2, 2 0, 0 0)), ((1 1, 1 3, 3 3, 3 1, 1 1)))",
285+
True,
286+
), # Touching at point
287+
(
288+
"MULTIPOLYGON (((0 0, 0 3, 3 3, 3 0, 0 0)), ((1 1, 1 2, 2 2, 2 1, 1 1)))",
289+
True,
290+
), # One inside another (donut)
291+
(
292+
"MULTIPOLYGON (((0 0, 0 2, 2 2, 2 0, 0 0)), ((0 0, 0 1, 1 1, 1 0, 0 0)))",
293+
True,
294+
), # Simple: The boundaries do not cross
295+
("MULTIPOLYGON EMPTY", True), # Empty multipolygon
296+
# GEOMETRYCOLLECTIONS
297+
(
298+
"GEOMETRYCOLLECTION (POINT (1 1), LINESTRING (0 0, 1 1))",
299+
True,
300+
), # Simple components
301+
(
302+
"GEOMETRYCOLLECTION (LINESTRING (0 0, 2 2), LINESTRING (0 2, 2 0))",
303+
True,
304+
),
305+
("GEOMETRYCOLLECTION EMPTY", True), # Empty collection
306+
# EDGE CASES
307+
("POINT (1 1)", True), # Repeated for completeness
308+
(
309+
"LINESTRING (1 1, 1 1)",
310+
True,
311+
), # Simple: Start and end points are the only intersecting points.
312+
(
313+
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0), (0.2 0.2, 0.2 0.8, 0.8 0.8, 0.8 0.2, 0.2 0.2))",
314+
True,
315+
), # Proper hole
316+
(
317+
"POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))",
318+
True,
319+
), # Another valid hole
320+
(
321+
"LINESTRING (0 0, 1 0, 1 1, 0 1, 0.5 1, 0.5 0)",
322+
False,
323+
), # Self-touching at non-endpoint
324+
],
325+
)
326+
def test_st_issimple(eng, geom, expected):
327+
eng = eng.create_or_skip()
328+
eng.assert_query_result(f"SELECT ST_IsSimple({geom_or_null(geom)})", expected)
329+
330+
214331
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
215332
@pytest.mark.parametrize(
216333
("geom", "expected"),

0 commit comments

Comments
 (0)