diff --git a/README.md b/README.md index 625ab63..49d23a8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Functions are explicitly modeled after the [PostGIS API](https://postgis.net/doc | 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_IsCollection | | Tests if a geometry is a geometry collection type. | -| ST_IsEmpty | | Tests if a geometry is empty. | +| ST_IsEmpty | ✅ | Tests if a geometry is empty. | | ST_IsPolygonCCW | | Tests if Polygons have exterior rings oriented counter-clockwise and interior rings oriented clockwise. | | ST_IsPolygonCW | | Tests if Polygons have exterior rings oriented clockwise and interior rings oriented counter-clockwise. | | ST_IsRing | | Tests if a LineString is closed and simple. | diff --git a/rust/geodatafusion/src/udf/native/accessors/is_empty.rs b/rust/geodatafusion/src/udf/native/accessors/is_empty.rs new file mode 100644 index 0000000..cfa6bf4 --- /dev/null +++ b/rust/geodatafusion/src/udf/native/accessors/is_empty.rs @@ -0,0 +1,223 @@ +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::{ + GeometryCollectionTrait, GeometryTrait, LineStringTrait, MultiLineStringTrait, MultiPointTrait, + MultiPolygonTrait, PointTrait, PolygonTrait, +}; +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 IsEmpty; + +impl IsEmpty { + pub fn new() -> Self { + Self {} + } +} + +impl Default for IsEmpty { + fn default() -> Self { + Self::new() + } +} + +static DOCUMENTATION: OnceLock = OnceLock::new(); + +impl ScalarUDFImpl for IsEmpty { + fn as_any(&self) -> &dyn Any { + self + } + + fn name(&self) -> &str { + "st_isempty" + } + + fn signature(&self) -> &Signature { + any_single_geometry_type_input() + } + + fn return_type(&self, _arg_types: &[DataType]) -> Result { + Ok(DataType::Boolean) + } + + fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { + Ok(is_empty_impl(args)?) + } + + fn documentation(&self) -> Option<&Documentation> { + Some(DOCUMENTATION.get_or_init(|| { + Documentation::builder( + DOC_SECTION_OTHER, + "Tests if a geometry is empty. ST_IsEmpty(NULL) is NULL.", + "ST_IsEmpty(geom)", + ) + .with_argument("geom", "geometry") + .build() + })) + } +} + +fn is_empty_impl(args: ScalarFunctionArgs) -> GeoDataFusionResult { + 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_empty)?; + + Ok(ColumnarValue::Array(Arc::new(result))) +} + +fn impl_is_empty<'a>( + array: &'a impl GeoArrowArrayAccessor<'a>, +) -> GeoDataFusionResult { + let mut builder = BooleanBuilder::with_capacity(array.len()); + + for item in array.iter() { + match item { + // A present geometry. + // Emptiness is the absence of coordinates, + // which is distinct from a SQL NULL (handled below). + Some(geom) => { + let geom = geom?; + let is_empty = match geom.as_type() { + geo_traits::GeometryType::Point(p) => p.coord().is_none(), + geo_traits::GeometryType::LineString(ls) => ls.num_coords() == 0, + geo_traits::GeometryType::Polygon(p) => { + p.exterior().is_none_or(|ring| ring.num_coords() == 0) + } + geo_traits::GeometryType::MultiPoint(mp) => mp.num_points() == 0, + geo_traits::GeometryType::MultiLineString(mls) => mls.num_line_strings() == 0, + geo_traits::GeometryType::MultiPolygon(mp) => mp.num_polygons() == 0, + geo_traits::GeometryType::GeometryCollection(gc) => gc.num_geometries() == 0, + // Rect/Triangle/Line always carry coordinates and so are never empty. + // (geoarrow has no curved-geometry support, so e.g. CIRCULARSTRING EMPTY + // from the PostGIS docs is not representable here.) + geo_traits::GeometryType::Rect(_) + | geo_traits::GeometryType::Triangle(_) + | geo_traits::GeometryType::Line(_) => false, + }; + builder.append_value(is_empty); + } + // SQL NULL in, SQL NULL out. + // This matches the PostGIS behavior, + // which explicitly calls out its behavior as non-conforming to SQL-MM. + None => { + builder.append_null(); + } + } + } + + Ok(builder.finish()) +} + +#[cfg(test)] +mod test { + use arrow_array::Array; + use arrow_array::cast::AsArray; + use datafusion::prelude::SessionContext; + + use super::*; + use crate::udf::native::io::GeomFromText; + + #[tokio::test] + async fn test_st_isempty() { + let ctx = SessionContext::new(); + ctx.register_udf(IsEmpty::new().into()); + ctx.register_udf(GeomFromText::new(Default::default()).into()); + + // Explicitly noted examples come from the PostGIS documentation (CC-BY-SA-3.0). + let cases = vec![ + ( + "GEOMETRYCOLLECTION EMPTY", + true, + "PostGIS doc example: empty geometry collection", + ), + ("POLYGON EMPTY", true, "PostGIS doc example: empty polygon"), + // Degenerate (collinear, zero-area) but NOT empty: emptiness is about the + // presence of coordinates, not area. + ( + "POLYGON((1 2, 3 4, 5 6, 1 2))", + false, + "PostGIS doc example: degenerate but non-empty polygon", + ), + // Empty geometries of every supported type are empty. + ("POINT EMPTY", true, "empty point"), + ("LINESTRING EMPTY", true, "empty linestring"), + ("MULTIPOINT EMPTY", true, "empty multipoint"), + ("MULTILINESTRING EMPTY", true, "empty multilinestring"), + ("MULTIPOLYGON EMPTY", true, "empty multipolygon"), + // Negative examples: a populated geometry of each type is never empty. + ("POINT(1 2)", false, "non-empty point"), + ("LINESTRING(0 0, 1 1)", false, "non-empty linestring"), + ( + "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + false, + "non-empty polygon", + ), + ("MULTIPOINT(0 0, 1 1)", false, "non-empty multipoint"), + ( + "MULTILINESTRING((0 0, 1 1))", + false, + "non-empty multilinestring", + ), + ( + "MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)))", + false, + "non-empty multipolygon", + ), + ( + "GEOMETRYCOLLECTION(POINT(0 0))", + false, + "non-empty geometry collection", + ), + ]; + + for (wkt, expected, description) in cases { + let sql = format!("SELECT ST_IsEmpty(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); + } + } + + #[tokio::test] + async fn test_st_isempty_null() { + let ctx = SessionContext::new(); + ctx.register_udf(IsEmpty::new().into()); + ctx.register_udf(GeomFromText::new(Default::default()).into()); + + // PostGIS explicitly calls out ST_IsEmpty(NULL) as NULL in the docs. + let df = ctx + .sql("SELECT ST_IsEmpty(ST_GeomFromText(NULL))") + .await + .unwrap(); + + let batch = df.collect().await.unwrap().into_iter().next().unwrap(); + let col = batch.column(0).as_boolean(); + + assert!(col.is_null(0), "ST_IsEmpty(NULL) should be NULL"); + } +} diff --git a/rust/geodatafusion/src/udf/native/accessors/mod.rs b/rust/geodatafusion/src/udf/native/accessors/mod.rs index 582eeeb..057f12f 100644 --- a/rust/geodatafusion/src/udf/native/accessors/mod.rs +++ b/rust/geodatafusion/src/udf/native/accessors/mod.rs @@ -1,6 +1,7 @@ mod coord_dim; mod geometry_type; mod is_closed; +mod is_empty; mod line_string; mod npoints; mod num_interior_rings; @@ -9,6 +10,7 @@ mod point; pub use coord_dim::{CoordDim, NDims}; pub use geometry_type::{GeometryType, ST_GeometryType}; pub use is_closed::IsClosed; +pub use is_empty::IsEmpty; pub use line_string::{EndPoint, StartPoint}; pub use npoints::NPoints; pub use num_interior_rings::NumInteriorRings; @@ -20,6 +22,7 @@ pub fn register(session_context: &datafusion::prelude::SessionContext) { session_context.register_udf(GeometryType.into()); session_context.register_udf(ST_GeometryType.into()); session_context.register_udf(IsClosed.into()); + session_context.register_udf(IsEmpty.into()); session_context.register_udf(EndPoint::default().into()); session_context.register_udf(StartPoint::default().into()); session_context.register_udf(NPoints.into());