Skip to content

Commit dbf9cda

Browse files
committed
feat: ST_IsClosed
refactor: update ST_IsClosed implementation and add tests refactor: update ST_IsClosed tests with comprehensive cases chore: fixed format on is_closed.rs feat: add ST_IsClosed to Python bindings and update tests chore: fix formatting refactor: move ST_IsClosed to validation module refactor: move IsClosed from measurement to validation refactor: update error with IsClosed UDF refactor: add pytest validation test for ST_IsClosed UDF
1 parent c2e86b4 commit dbf9cda

9 files changed

Lines changed: 212 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Functions are explicitly modeled after the [PostGIS API](https://postgis.net/doc
5858
| ST_GeometryN | | Return an element of a geometry collection. |
5959
| ST_GeometryType || Returns the SQL-MM type of a geometry as text. |
6060
| ST_InteriorRingN | | Returns the Nth interior ring (hole) of a Polygon. |
61-
| ST_IsClosed | | Tests if a LineStrings's start and end points are coincident. |
61+
| ST_IsClosed | | Tests if a LineStrings's start and end points are coincident. |
6262
| ST_IsCollection | | Tests if a geometry is a geometry collection type. |
6363
| ST_IsEmpty | | Tests if a geometry is empty. |
6464
| ST_IsPolygonCCW | | Tests if Polygons have exterior rings oriented counter-clockwise and interior rings oriented clockwise. |

python/python/geodatafusion/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def register_all_geo(ctx: SessionContext):
4343
ctx.register_udf(udf(geo.Within()))
4444

4545
# validation
46+
ctx.register_udf(udf(geo.IsClosed()))
4647
ctx.register_udf(udf(geo.IsValid()))
4748
ctx.register_udf(udf(geo.IsValidReason()))
4849

python/python/geodatafusion/geo/_validation.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
class IsClosed:
2+
def __init__(self) -> None: ...
3+
def __datafusion_scalar_udf__(self) -> object: ...
4+
15
class IsValid:
26
def __init__(self) -> None: ...
37
def __datafusion_scalar_udf__(self) -> object: ...

python/src/udf/geo/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub(crate) fn geo(m: &Bound<PyModule>) -> PyResult<()> {
3434
m.add_class::<relationships::PyWithin>()?;
3535

3636
// validation
37+
m.add_class::<validation::PyIsClosed>()?;
3738
m.add_class::<validation::PyIsValid>()?;
3839
m.add_class::<validation::PyIsValidReason>()?;
3940

python/src/udf/geo/validation.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use geodatafusion::udf::geo::validation::{IsValid, IsValidReason};
1+
use geodatafusion::udf::geo::validation::{IsClosed, IsValid, IsValidReason};
22

33
use crate::impl_udf;
44

5+
impl_udf!(IsClosed, PyIsClosed, "IsClosed");
56
impl_udf!(IsValid, PyIsValid, "IsValid");
67
impl_udf!(IsValidReason, PyIsValidReason, "IsValidReason");

python/tests/udf/geo/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from arro3.core import Table
4+
from datafusion import SessionContext
5+
import geodatafusion
6+
from geodatafusion import register_all
7+
8+
9+
def test_st_is_closed_geoarrow():
10+
ctx = SessionContext()
11+
register_all(ctx)
12+
sql = "SELECT ST_IsClosed(ST_GeomFromText('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')) as geom"
13+
df = ctx.sql(sql)
14+
table = df.to_arrow_table()
15+
assert table.column("geom")[0].as_py() is True
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use std::any::Any;
2+
use std::sync::{Arc, OnceLock};
3+
4+
use arrow_array::BooleanArray;
5+
use arrow_array::builder::BooleanBuilder;
6+
use arrow_schema::DataType;
7+
use datafusion::error::Result;
8+
use datafusion::logical_expr::scalar_doc_sections::DOC_SECTION_OTHER;
9+
use datafusion::logical_expr::{
10+
ColumnarValue, Documentation, ScalarFunctionArgs, ScalarUDFImpl, Signature,
11+
};
12+
use geo_traits::{CoordTrait, GeometryTrait, LineStringTrait, MultiLineStringTrait};
13+
use geoarrow_array::{GeoArrowArrayAccessor, WrapArray, downcast_geoarrow_array};
14+
use geoarrow_schema::GeoArrowType;
15+
16+
use crate::data_types::any_single_geometry_type_input;
17+
use crate::error::GeoDataFusionResult;
18+
19+
#[derive(Debug, Eq, PartialEq, Hash)]
20+
pub struct IsClosed;
21+
22+
impl IsClosed {
23+
pub fn new() -> Self {
24+
Self {}
25+
}
26+
}
27+
28+
impl Default for IsClosed {
29+
fn default() -> Self {
30+
Self::new()
31+
}
32+
}
33+
34+
static DOCUMENTATION: OnceLock<Documentation> = OnceLock::new();
35+
36+
impl ScalarUDFImpl for IsClosed {
37+
fn as_any(&self) -> &dyn Any {
38+
self
39+
}
40+
41+
fn name(&self) -> &str {
42+
"st_isclosed"
43+
}
44+
45+
fn signature(&self) -> &Signature {
46+
any_single_geometry_type_input()
47+
}
48+
49+
fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
50+
Ok(DataType::Boolean)
51+
}
52+
53+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
54+
Ok(is_closed_impl(args)?)
55+
}
56+
57+
fn documentation(&self) -> Option<&Documentation> {
58+
Some(DOCUMENTATION.get_or_init(|| {
59+
Documentation::builder(
60+
DOC_SECTION_OTHER,
61+
"Tests if a LineStrings's start and end points are coincident.",
62+
"ST_IsClosed(geom)",
63+
)
64+
.with_argument("geom", "geometry")
65+
.build()
66+
}))
67+
}
68+
}
69+
70+
fn is_closed_impl(args: ScalarFunctionArgs) -> GeoDataFusionResult<ColumnarValue> {
71+
let array = ColumnarValue::values_to_arrays(&args.args)?
72+
.into_iter()
73+
.next()
74+
.unwrap();
75+
let geo_type = GeoArrowType::from_arrow_field(&args.arg_fields[0])?;
76+
let geo_array = geo_type.wrap_array(&array)?;
77+
let geo_array_ref = geo_array.as_ref();
78+
79+
let result = downcast_geoarrow_array!(geo_array_ref, impl_is_closed)?;
80+
81+
Ok(ColumnarValue::Array(Arc::new(result)))
82+
}
83+
84+
fn impl_is_closed<'a>(
85+
array: &'a impl GeoArrowArrayAccessor<'a>,
86+
) -> GeoDataFusionResult<BooleanArray> {
87+
let mut builder = BooleanBuilder::with_capacity(array.len());
88+
89+
for item in array.iter() {
90+
match item {
91+
Some(geom) => {
92+
let geom = geom?;
93+
let is_closed = match geom.as_type() {
94+
geo_traits::GeometryType::LineString(ls) => check_ls_closed(ls),
95+
geo_traits::GeometryType::MultiLineString(mls) => {
96+
mls.num_line_strings() > 0
97+
&& mls.line_strings().all(|ls| check_ls_closed(&ls))
98+
}
99+
geo_traits::GeometryType::Point(_)
100+
| geo_traits::GeometryType::MultiPoint(_)
101+
| geo_traits::GeometryType::Polygon(_)
102+
| geo_traits::GeometryType::MultiPolygon(_) => true,
103+
_ => false,
104+
};
105+
builder.append_value(is_closed);
106+
}
107+
None => {
108+
builder.append_null();
109+
}
110+
}
111+
}
112+
113+
Ok(builder.finish())
114+
}
115+
116+
fn check_ls_closed(ls: &impl LineStringTrait<T = f64>) -> bool {
117+
let n = ls.num_coords();
118+
if n < 2 {
119+
return false;
120+
}
121+
if let (Some(first), Some(last)) = (ls.coord(0), ls.coord(n - 1)) {
122+
first.x() == last.x() && first.y() == last.y()
123+
} else {
124+
false
125+
}
126+
}
127+
128+
#[cfg(test)]
129+
mod test {
130+
use arrow_array::cast::AsArray;
131+
use datafusion::prelude::SessionContext;
132+
133+
use super::*;
134+
use crate::udf::native::io::GeomFromText;
135+
136+
#[tokio::test]
137+
async fn test_st_isclosed() {
138+
let ctx = SessionContext::new();
139+
ctx.register_udf(IsClosed::new().into());
140+
ctx.register_udf(GeomFromText::new(Default::default()).into());
141+
142+
let cases = vec![
143+
("LINESTRING(0 0, 1 1, 0 1, 0 0)", true, "closed linestring"),
144+
("LINESTRING(0 0, 1 1, 1 0)", false, "open linestring"),
145+
("LINESTRING(0 0, 0 0)", true, "single segment closed"),
146+
("LINESTRING EMPTY", false, "empty linestring is not closed"),
147+
(
148+
"MULTILINESTRING((0 0, 1 1, 0 0), (2 2, 3 3, 2 2))",
149+
true,
150+
"all closed",
151+
),
152+
(
153+
"MULTILINESTRING((0 0, 1 1, 0 0), (2 2, 3 3, 2 3))",
154+
false,
155+
"one open",
156+
),
157+
(
158+
"MULTILINESTRING EMPTY",
159+
false,
160+
"empty multilinestring is not closed",
161+
),
162+
("POINT(0 0)", true, "point is closed"),
163+
("MULTIPOINT(0 0, 1 1)", true, "multipoint is closed"),
164+
("POLYGON((0 0, 1 0, 1 1, 0 0))", true, "polygon is closed"),
165+
(
166+
"MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)))",
167+
true,
168+
"multipolygon is closed",
169+
),
170+
];
171+
172+
for (wkt, expected, description) in cases {
173+
let sql = format!("SELECT ST_IsClosed(ST_GeomFromText('{}'))", wkt);
174+
let df = ctx
175+
.sql(&sql)
176+
.await
177+
.unwrap_or_else(|_| panic!("Failed to execute SQL for {}", description));
178+
179+
let batch = df.collect().await.unwrap().into_iter().next().unwrap();
180+
let col = batch.column(0).as_boolean();
181+
182+
assert_eq!(col.value(0), expected, "Failed on {}: {}", description, wkt);
183+
}
184+
}
185+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
mod is_closed;
12
mod is_valid;
23
mod is_valid_reason;
34

5+
pub use is_closed::IsClosed;
46
pub use is_valid::IsValid;
57
pub use is_valid_reason::IsValidReason;
68

79
pub fn register(session_context: &datafusion::prelude::SessionContext) {
10+
session_context.register_udf(IsClosed.into());
811
session_context.register_udf(IsValid.into());
912
session_context.register_udf(IsValidReason.into());
1013
}

0 commit comments

Comments
 (0)