Skip to content

Commit 733a566

Browse files
authored
feat: ST_IsClosed (#69)
* 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 * refactor: move ST_IsClosed from validation to native accessors * refactor: fix fmt with cargo fmt
1 parent c2e86b4 commit 733a566

8 files changed

Lines changed: 213 additions & 3 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
@@ -61,6 +61,7 @@ def register_all_native(ctx: SessionContext):
6161
# accessors
6262
ctx.register_udf(udf(native.CoordDim()))
6363
ctx.register_udf(udf(native.EndPoint()))
64+
ctx.register_udf(udf(native.IsClosed()))
6465
ctx.register_udf(udf(native.GeometryType()))
6566
ctx.register_udf(udf(native.M()))
6667
ctx.register_udf(udf(native.NDims()))

python/python/geodatafusion/native/_accessors.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ class EndPoint:
66
def __init__(self) -> None: ...
77
def __datafusion_scalar_udf__(self) -> object: ...
88

9+
class IsClosed:
10+
def __init__(self) -> None: ...
11+
def __datafusion_scalar_udf__(self) -> object: ...
12+
913
class NDims:
1014
def __init__(self) -> None: ...
1115
def __datafusion_scalar_udf__(self) -> object: ...

python/src/udf/native/accessors.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use geodatafusion::udf::native::accessors::{
2-
CoordDim, EndPoint, GeometryType, M, NDims, NPoints, NumInteriorRings, ST_GeometryType,
3-
StartPoint, X, Y, Z,
2+
CoordDim, EndPoint, GeometryType, IsClosed, M, NDims, NPoints, NumInteriorRings,
3+
ST_GeometryType, StartPoint, X, Y, Z,
44
};
55

66
use crate::{impl_udf, impl_udf_coord_type_arg};
@@ -11,6 +11,7 @@ impl_udf!(X, PyX, "X");
1111
impl_udf!(Y, PyY, "Y");
1212
impl_udf!(Z, PyZ, "Z");
1313
impl_udf!(M, PyM, "M");
14+
impl_udf!(IsClosed, PyIsClosed, "IsClosed");
1415
impl_udf_coord_type_arg!(EndPoint, PyEndPoint, "EndPoint");
1516
impl_udf_coord_type_arg!(StartPoint, PyStartPoint, "StartPoint");
1617
impl_udf!(NPoints, PyNPoints, "NPoints");

python/src/udf/native/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub(crate) fn native(m: &Bound<PyModule>) -> PyResult<()> {
1010
// accessors
1111
m.add_class::<accessors::PyCoordDim>()?;
1212
m.add_class::<accessors::PyEndPoint>()?;
13+
m.add_class::<accessors::PyIsClosed>()?;
1314
m.add_class::<accessors::PyM>()?;
1415
m.add_class::<accessors::PyGeometryType>()?;
1516
m.add_class::<accessors::PyNDims>()?;
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+
}

rust/geodatafusion/src/udf/native/accessors/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
mod coord_dim;
22
mod geometry_type;
3+
mod is_closed;
34
mod line_string;
45
mod npoints;
56
mod num_interior_rings;
67
mod point;
78

89
pub use coord_dim::{CoordDim, NDims};
910
pub use geometry_type::{GeometryType, ST_GeometryType};
11+
pub use is_closed::IsClosed;
1012
pub use line_string::{EndPoint, StartPoint};
1113
pub use npoints::NPoints;
1214
pub use num_interior_rings::NumInteriorRings;
@@ -17,6 +19,7 @@ pub fn register(session_context: &datafusion::prelude::SessionContext) {
1719
session_context.register_udf(NDims.into());
1820
session_context.register_udf(GeometryType.into());
1921
session_context.register_udf(ST_GeometryType.into());
22+
session_context.register_udf(IsClosed.into());
2023
session_context.register_udf(EndPoint::default().into());
2124
session_context.register_udf(StartPoint::default().into());
2225
session_context.register_udf(NPoints.into());

0 commit comments

Comments
 (0)