Skip to content

Commit 9601b6b

Browse files
authored
feat(rust/sedona-functions): Implement ST_IsClosed (#219)
1 parent c876761 commit 9601b6b

File tree

6 files changed

+220
-0
lines changed

6 files changed

+220
-0
lines changed

python/sedonadb/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,5 @@ docs/_build/
8686

8787
# Pyenv
8888
.python-version
89+
90+
uv.lock

python/sedonadb/tests/functions/test_functions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,37 @@ def test_st_isempty(eng, geom, expected):
573573
eng.assert_query_result(f"SELECT ST_IsEmpty({geom_or_null(geom)})", expected)
574574

575575

576+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
577+
@pytest.mark.parametrize(
578+
("geom", "expected"),
579+
[
580+
(None, None),
581+
("LINESTRING(0 0, 1 1)", False),
582+
("LINESTRING(0 0, 0 1, 1 1, 0 0)", True),
583+
("MULTILINESTRING((0 0, 0 1, 1 1, 0 0),(0 0, 1 1))", False),
584+
("POINT(0 0)", True),
585+
("MULTIPOINT((0 0), (1 1))", True),
586+
("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", True),
587+
("GEOMETRYCOLLECTION (LINESTRING(0 0, 0 1, 1 1, 0 0))", True),
588+
(
589+
"GEOMETRYCOLLECTION (LINESTRING(0 0, 0 1, 1 1, 0 0), LINESTRING(0 0, 1 1))",
590+
False,
591+
),
592+
("POINT EMPTY", False),
593+
("LINESTRING EMPTY", False),
594+
("POLYGON EMPTY", False),
595+
("MULTIPOINT EMPTY", False),
596+
("MULTILINESTRING EMPTY", False),
597+
("MULTIPOLYGON EMPTY", False),
598+
("GEOMETRYCOLLECTION EMPTY", False),
599+
("GEOMETRYCOLLECTION (LINESTRING EMPTY)", False),
600+
],
601+
)
602+
def test_st_isclosed(eng, geom, expected):
603+
eng = eng.create_or_skip()
604+
eng.assert_query_result(f"SELECT ST_IsClosed({geom_or_null(geom)})", expected)
605+
606+
576607
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
577608
@pytest.mark.parametrize(
578609
("geom", "expected"),

rust/sedona-functions/benches/native-functions.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ fn criterion_benchmark(c: &mut Criterion) {
7070
benchmark::scalar(c, &f, "native", "st_isempty", Point);
7171
benchmark::scalar(c, &f, "native", "st_isempty", LineString(10));
7272

73+
benchmark::scalar(c, &f, "native", "st_isclosed", Point);
74+
benchmark::scalar(c, &f, "native", "st_isclosed", LineString(10));
75+
7376
benchmark::scalar(
7477
c,
7578
&f,

rust/sedona-functions/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mod st_geomfromwkb;
4040
mod st_geomfromwkt;
4141
mod st_haszm;
4242
pub mod st_intersection_aggr;
43+
pub mod st_isclosed;
4344
pub mod st_isempty;
4445
mod st_length;
4546
mod st_makeline;

rust/sedona-functions/src/register.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ pub fn default_function_set() -> FunctionSet {
104104
crate::st_xyzm_minmax::st_zmax_udf,
105105
crate::st_xyzm_minmax::st_mmin_udf,
106106
crate::st_xyzm_minmax::st_mmax_udf,
107+
crate::st_isclosed::st_isclosed_udf,
107108
);
108109

109110
register_aggregate_udfs!(
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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;
23+
use datafusion_expr::{scalar_doc_sections::DOC_SECTION_OTHER, Documentation, Volatility};
24+
use geo_traits::GeometryCollectionTrait;
25+
use geo_traits::{
26+
to_geo::{ToGeoLineString, ToGeoMultiLineString},
27+
GeometryTrait,
28+
};
29+
use sedona_common::sedona_internal_err;
30+
use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
31+
use sedona_geometry::is_empty::is_geometry_empty;
32+
use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher};
33+
use wkb::reader::Wkb;
34+
35+
use crate::executor::WkbExecutor;
36+
37+
pub fn st_isclosed_udf() -> SedonaScalarUDF {
38+
SedonaScalarUDF::new(
39+
"st_isclosed",
40+
vec![Arc::new(STIsClosed {})],
41+
Volatility::Immutable,
42+
Some(st_is_closed_doc()),
43+
)
44+
}
45+
46+
fn st_is_closed_doc() -> Documentation {
47+
Documentation::builder(
48+
DOC_SECTION_OTHER,
49+
"Return true if the geometry is closed",
50+
"ST_IsClosed (A: Geometry)",
51+
)
52+
.with_argument("geom", "geometry: Input geometry")
53+
.with_sql_example("SELECT ST_IsClosed(ST_GeomFromWKT('LINESTRING(0 0, 1 1, 0 1, 0 0)'))")
54+
.build()
55+
}
56+
57+
#[derive(Debug)]
58+
struct STIsClosed {}
59+
60+
impl SedonaScalarKernel for STIsClosed {
61+
fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
62+
let matcher = ArgMatcher::new(
63+
vec![ArgMatcher::is_geometry()],
64+
SedonaType::Arrow(DataType::Boolean),
65+
);
66+
67+
matcher.match_args(args)
68+
}
69+
70+
fn invoke_batch(
71+
&self,
72+
arg_types: &[SedonaType],
73+
args: &[datafusion_expr::ColumnarValue],
74+
) -> Result<datafusion_expr::ColumnarValue> {
75+
let executor = WkbExecutor::new(arg_types, args);
76+
let mut builder = BooleanBuilder::with_capacity(executor.num_iterations());
77+
78+
executor.execute_wkb_void(|maybe_item| {
79+
match maybe_item {
80+
Some(item) => {
81+
builder.append_value(invoke_scalar(&item)?);
82+
}
83+
None => builder.append_null(),
84+
}
85+
Ok(())
86+
})?;
87+
88+
executor.finish(Arc::new(builder.finish()))
89+
}
90+
}
91+
92+
fn invoke_scalar(item: &Wkb) -> Result<bool> {
93+
is_geometry_closed(item)
94+
}
95+
96+
fn is_geometry_closed(item: &Wkb) -> Result<bool> {
97+
if is_geometry_empty(&item).map_err(|e| {
98+
datafusion_common::error::DataFusionError::Execution(format!(
99+
"Failed to check if geometry is empty: {e}"
100+
))
101+
})? {
102+
return Ok(false);
103+
}
104+
match item.as_type() {
105+
geo_traits::GeometryType::LineString(linestring) => {
106+
Ok(linestring.to_line_string().is_closed())
107+
}
108+
geo_traits::GeometryType::MultiLineString(multilinestring) => {
109+
Ok(multilinestring.to_multi_line_string().is_closed())
110+
}
111+
geo_traits::GeometryType::GeometryCollection(geometry_collection) => geometry_collection
112+
.geometries()
113+
.try_fold(true, |acc, item| {
114+
is_geometry_closed(item).map(|is_closed| acc && is_closed)
115+
}),
116+
geo_traits::GeometryType::Point(_)
117+
| geo_traits::GeometryType::MultiPoint(_)
118+
| geo_traits::GeometryType::Polygon(_)
119+
| geo_traits::GeometryType::MultiPolygon(_) => Ok(true),
120+
_ => sedona_internal_err!("Invalid geometry type"),
121+
}
122+
}
123+
124+
#[cfg(test)]
125+
mod tests {
126+
use arrow_array::{create_array as arrow_array, ArrayRef};
127+
use datafusion_expr::ScalarUDF;
128+
use rstest::rstest;
129+
use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY};
130+
use sedona_testing::{compare::assert_array_equal, testers::ScalarUdfTester};
131+
132+
use super::*;
133+
134+
#[test]
135+
fn udf_metadata() {
136+
let udf: ScalarUDF = st_isclosed_udf().into();
137+
assert_eq!(udf.name(), "st_isclosed");
138+
assert!(udf.documentation().is_some());
139+
}
140+
141+
#[rstest]
142+
fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType) {
143+
use datafusion_common::ScalarValue;
144+
145+
let tester = ScalarUdfTester::new(st_isclosed_udf().into(), vec![sedona_type.clone()]);
146+
147+
tester.assert_return_type(DataType::Boolean);
148+
149+
let result = tester
150+
.invoke_wkb_scalar(Some("LINESTRING(0 0, 1 1, 0 1, 0 0)"))
151+
.unwrap();
152+
tester.assert_scalar_result_equals(result, ScalarValue::Boolean(Some(true)));
153+
154+
let result = tester.invoke_wkb_scalar(None).unwrap();
155+
tester.assert_scalar_result_equals(result, ScalarValue::Null);
156+
157+
let input_wkt = vec![
158+
None,
159+
Some("LINESTRING(0 0, 1 1)"),
160+
Some("LINESTRING(0 0, 0 1, 1 1, 0 0)"),
161+
Some("MULTILINESTRING((0 0, 0 1, 1 1, 0 0),(0 0, 1 1))"),
162+
Some("POINT(0 0)"),
163+
Some("MULTIPOINT((0 0), (1 1))"),
164+
Some("LINESTRING EMPTY"),
165+
Some("POINT EMPTY"),
166+
];
167+
let expected: ArrayRef = arrow_array!(
168+
Boolean,
169+
[
170+
None,
171+
Some(false),
172+
Some(true),
173+
Some(false),
174+
Some(true),
175+
Some(true),
176+
Some(false),
177+
Some(false)
178+
]
179+
);
180+
assert_array_equal(&tester.invoke_wkb_array(input_wkt).unwrap(), &expected);
181+
}
182+
}

0 commit comments

Comments
 (0)