Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Functions are explicitly modeled after the [PostGIS API](https://postgis.net/doc
| ST_GeometryN | | Return an element of a geometry collection. |
| ST_GeometryType | ✅ | Returns the SQL-MM type of a geometry as text. |
| ST_InteriorRingN | | Returns the Nth interior ring (hole) of a Polygon. |
| ST_IsClosed | | Tests if a LineStrings's start and end points are coincident. |
| ST_IsClosed | | Tests if a LineStrings's start and end points are coincident. |
| ST_IsCollection | | Tests if a geometry is a geometry collection type. |
| ST_IsEmpty | | Tests if a geometry is empty. |
| ST_IsPolygonCCW | | Tests if Polygons have exterior rings oriented counter-clockwise and interior rings oriented clockwise. |
Expand Down
1 change: 1 addition & 0 deletions python/python/geodatafusion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def register_all_native(ctx: SessionContext):
# accessors
ctx.register_udf(udf(native.CoordDim()))
ctx.register_udf(udf(native.EndPoint()))
ctx.register_udf(udf(native.IsClosed()))
ctx.register_udf(udf(native.GeometryType()))
ctx.register_udf(udf(native.M()))
ctx.register_udf(udf(native.NDims()))
Expand Down
4 changes: 4 additions & 0 deletions python/python/geodatafusion/native/_accessors.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class EndPoint:
def __init__(self) -> None: ...
def __datafusion_scalar_udf__(self) -> object: ...

class IsClosed:
def __init__(self) -> None: ...
def __datafusion_scalar_udf__(self) -> object: ...

class NDims:
def __init__(self) -> None: ...
def __datafusion_scalar_udf__(self) -> object: ...
Expand Down
5 changes: 3 additions & 2 deletions python/src/udf/native/accessors.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use geodatafusion::udf::native::accessors::{
CoordDim, EndPoint, GeometryType, M, NDims, NPoints, NumInteriorRings, ST_GeometryType,
StartPoint, X, Y, Z,
CoordDim, EndPoint, GeometryType, IsClosed, M, NDims, NPoints, NumInteriorRings,
ST_GeometryType, StartPoint, X, Y, Z,
};

use crate::{impl_udf, impl_udf_coord_type_arg};
Expand All @@ -11,6 +11,7 @@ impl_udf!(X, PyX, "X");
impl_udf!(Y, PyY, "Y");
impl_udf!(Z, PyZ, "Z");
impl_udf!(M, PyM, "M");
impl_udf!(IsClosed, PyIsClosed, "IsClosed");
impl_udf_coord_type_arg!(EndPoint, PyEndPoint, "EndPoint");
impl_udf_coord_type_arg!(StartPoint, PyStartPoint, "StartPoint");
impl_udf!(NPoints, PyNPoints, "NPoints");
Expand Down
1 change: 1 addition & 0 deletions python/src/udf/native/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub(crate) fn native(m: &Bound<PyModule>) -> PyResult<()> {
// accessors
m.add_class::<accessors::PyCoordDim>()?;
m.add_class::<accessors::PyEndPoint>()?;
m.add_class::<accessors::PyIsClosed>()?;
m.add_class::<accessors::PyM>()?;
m.add_class::<accessors::PyGeometryType>()?;
m.add_class::<accessors::PyNDims>()?;
Expand Down
15 changes: 15 additions & 0 deletions python/tests/udf/native/test_accessors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import annotations

from arro3.core import Table
from datafusion import SessionContext
import geodatafusion
from geodatafusion import register_all


def test_st_is_closed_geoarrow():
ctx = SessionContext()
register_all(ctx)
sql = "SELECT ST_IsClosed(ST_GeomFromText('POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')) as geom"
df = ctx.sql(sql)
table = df.to_arrow_table()
assert table.column("geom")[0].as_py() is True
185 changes: 185 additions & 0 deletions rust/geodatafusion/src/udf/native/accessors/is_closed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use std::any::Any;
use std::sync::{Arc, OnceLock};

use arrow_array::BooleanArray;
use arrow_array::builder::BooleanBuilder;
use arrow_schema::DataType;
use datafusion::error::Result;
use datafusion::logical_expr::scalar_doc_sections::DOC_SECTION_OTHER;
use datafusion::logical_expr::{
ColumnarValue, Documentation, ScalarFunctionArgs, ScalarUDFImpl, Signature,
};
use geo_traits::{CoordTrait, GeometryTrait, LineStringTrait, MultiLineStringTrait};
use geoarrow_array::{GeoArrowArrayAccessor, WrapArray, downcast_geoarrow_array};
use geoarrow_schema::GeoArrowType;

use crate::data_types::any_single_geometry_type_input;
use crate::error::GeoDataFusionResult;

#[derive(Debug, Eq, PartialEq, Hash)]
pub struct IsClosed;

impl IsClosed {
pub fn new() -> Self {
Self {}
}
}

impl Default for IsClosed {
fn default() -> Self {
Self::new()
}
}

static DOCUMENTATION: OnceLock<Documentation> = OnceLock::new();

impl ScalarUDFImpl for IsClosed {
fn as_any(&self) -> &dyn Any {
self
}

fn name(&self) -> &str {
"st_isclosed"
}

fn signature(&self) -> &Signature {
any_single_geometry_type_input()
}

fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
Ok(DataType::Boolean)
}

fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
Ok(is_closed_impl(args)?)
}

fn documentation(&self) -> Option<&Documentation> {
Some(DOCUMENTATION.get_or_init(|| {
Documentation::builder(
DOC_SECTION_OTHER,
"Tests if a LineStrings's start and end points are coincident.",
"ST_IsClosed(geom)",
)
.with_argument("geom", "geometry")
.build()
}))
}
}

fn is_closed_impl(args: ScalarFunctionArgs) -> GeoDataFusionResult<ColumnarValue> {
let array = ColumnarValue::values_to_arrays(&args.args)?
.into_iter()
.next()
.unwrap();
let geo_type = GeoArrowType::from_arrow_field(&args.arg_fields[0])?;
let geo_array = geo_type.wrap_array(&array)?;
let geo_array_ref = geo_array.as_ref();

let result = downcast_geoarrow_array!(geo_array_ref, impl_is_closed)?;

Ok(ColumnarValue::Array(Arc::new(result)))
}

fn impl_is_closed<'a>(
array: &'a impl GeoArrowArrayAccessor<'a>,
) -> GeoDataFusionResult<BooleanArray> {
let mut builder = BooleanBuilder::with_capacity(array.len());

for item in array.iter() {
match item {
Some(geom) => {
let geom = geom?;
let is_closed = match geom.as_type() {
geo_traits::GeometryType::LineString(ls) => check_ls_closed(ls),
geo_traits::GeometryType::MultiLineString(mls) => {
mls.num_line_strings() > 0
&& mls.line_strings().all(|ls| check_ls_closed(&ls))
}
geo_traits::GeometryType::Point(_)
| geo_traits::GeometryType::MultiPoint(_)
| geo_traits::GeometryType::Polygon(_)
| geo_traits::GeometryType::MultiPolygon(_) => true,
_ => false,
};
builder.append_value(is_closed);
}
None => {
builder.append_null();
}
}
}

Ok(builder.finish())
}

fn check_ls_closed(ls: &impl LineStringTrait<T = f64>) -> bool {
let n = ls.num_coords();
if n < 2 {
return false;
}
if let (Some(first), Some(last)) = (ls.coord(0), ls.coord(n - 1)) {
first.x() == last.x() && first.y() == last.y()
} else {
false
}
}

#[cfg(test)]
mod test {
use arrow_array::cast::AsArray;
use datafusion::prelude::SessionContext;

use super::*;
use crate::udf::native::io::GeomFromText;

#[tokio::test]
async fn test_st_isclosed() {
let ctx = SessionContext::new();
ctx.register_udf(IsClosed::new().into());
ctx.register_udf(GeomFromText::new(Default::default()).into());

let cases = vec![
("LINESTRING(0 0, 1 1, 0 1, 0 0)", true, "closed linestring"),
("LINESTRING(0 0, 1 1, 1 0)", false, "open linestring"),
("LINESTRING(0 0, 0 0)", true, "single segment closed"),
("LINESTRING EMPTY", false, "empty linestring is not closed"),
(
"MULTILINESTRING((0 0, 1 1, 0 0), (2 2, 3 3, 2 2))",
true,
"all closed",
),
(
"MULTILINESTRING((0 0, 1 1, 0 0), (2 2, 3 3, 2 3))",
false,
"one open",
),
(
"MULTILINESTRING EMPTY",
false,
"empty multilinestring is not closed",
),
("POINT(0 0)", true, "point is closed"),
("MULTIPOINT(0 0, 1 1)", true, "multipoint is closed"),
("POLYGON((0 0, 1 0, 1 1, 0 0))", true, "polygon is closed"),
(
"MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)))",
true,
"multipolygon is closed",
),
];

for (wkt, expected, description) in cases {
let sql = format!("SELECT ST_IsClosed(ST_GeomFromText('{}'))", wkt);
let df = ctx
.sql(&sql)
.await
.unwrap_or_else(|_| panic!("Failed to execute SQL for {}", description));

let batch = df.collect().await.unwrap().into_iter().next().unwrap();
let col = batch.column(0).as_boolean();

assert_eq!(col.value(0), expected, "Failed on {}: {}", description, wkt);
}
}
}
3 changes: 3 additions & 0 deletions rust/geodatafusion/src/udf/native/accessors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
mod coord_dim;
mod geometry_type;
mod is_closed;
mod line_string;
mod npoints;
mod num_interior_rings;
mod point;

pub use coord_dim::{CoordDim, NDims};
pub use geometry_type::{GeometryType, ST_GeometryType};
pub use is_closed::IsClosed;
pub use line_string::{EndPoint, StartPoint};
pub use npoints::NPoints;
pub use num_interior_rings::NumInteriorRings;
Expand All @@ -17,6 +19,7 @@ pub fn register(session_context: &datafusion::prelude::SessionContext) {
session_context.register_udf(NDims.into());
session_context.register_udf(GeometryType.into());
session_context.register_udf(ST_GeometryType.into());
session_context.register_udf(IsClosed.into());
session_context.register_udf(EndPoint::default().into());
session_context.register_udf(StartPoint::default().into());
session_context.register_udf(NPoints.into());
Expand Down
Loading