diff --git a/c/sedona-geos/src/lib.rs b/c/sedona-geos/src/lib.rs index cddf0585e..d05367d0d 100644 --- a/c/sedona-geos/src/lib.rs +++ b/c/sedona-geos/src/lib.rs @@ -14,6 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. + mod binary_predicates; mod distance; mod executor; @@ -43,6 +44,7 @@ mod st_numpoints; mod st_perimeter; mod st_polygonize; mod st_polygonize_agg; +mod st_relate; mod st_simplify; mod st_simplifypreservetopology; mod st_snap; diff --git a/c/sedona-geos/src/register.rs b/c/sedona-geos/src/register.rs index 314982881..83d90e15b 100644 --- a/c/sedona-geos/src/register.rs +++ b/c/sedona-geos/src/register.rs @@ -73,6 +73,7 @@ pub fn scalar_kernels() -> Vec<(&'static str, Vec)> { "st_overlaps" => crate::binary_predicates::st_overlaps_impl, "st_perimeter" => crate::st_perimeter::st_perimeter_impl, "st_polygonize" => crate::st_polygonize::st_polygonize_impl, + "st_relate" => crate::st_relate::st_relate_impl, "st_simplify" => crate::st_simplify::st_simplify_impl, "st_simplifypreservetopology" => crate::st_simplifypreservetopology::st_simplify_preserve_topology_impl, "st_snap" => crate::st_snap::st_snap_impl, diff --git a/c/sedona-geos/src/st_relate.rs b/c/sedona-geos/src/st_relate.rs new file mode 100644 index 000000000..03cc5dcaf --- /dev/null +++ b/c/sedona-geos/src/st_relate.rs @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +use std::sync::Arc; + +use arrow_array::builder::StringBuilder; +use arrow_schema::DataType; +use datafusion_common::error::Result; +use datafusion_common::DataFusionError; +use datafusion_expr::ColumnarValue; +use geos::Geom; +use sedona_expr::{ + item_crs::ItemCrsKernel, + scalar_udf::{ScalarKernelRef, SedonaScalarKernel}, +}; +use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; + +use crate::executor::GeosExecutor; + +/// ST_Relate implementation using GEOS +pub fn st_relate_impl() -> Vec { + ItemCrsKernel::wrap_impl(STRelate {}) +} + +#[derive(Debug)] +struct STRelate {} + +impl SedonaScalarKernel for STRelate { + fn return_type(&self, args: &[SedonaType]) -> Result> { + let matcher = ArgMatcher::new( + vec![ArgMatcher::is_geometry(), ArgMatcher::is_geometry()], + SedonaType::Arrow(DataType::Utf8), + ); + + matcher.match_args(args) + } + + fn invoke_batch( + &self, + arg_types: &[SedonaType], + args: &[ColumnarValue], + ) -> Result { + let executor = GeosExecutor::new(arg_types, args); + + // ST_Relate returns a 9-char DE-9IM string per row; 9 bytes * n rows + let mut builder = + StringBuilder::with_capacity(executor.num_iterations(), 9 * executor.num_iterations()); + + executor.execute_wkb_wkb_void(|wkb1, wkb2| { + match (wkb1, wkb2) { + (Some(g1), Some(g2)) => { + let relate = g1 + .relate(g2) + .map_err(|e| DataFusionError::External(Box::new(e)))?; + + builder.append_value(relate); + } + _ => builder.append_null(), + } + Ok(()) + })?; + + executor.finish(Arc::new(builder.finish())) + } +} + +#[cfg(test)] +mod tests { + use arrow_array::{create_array as arrow_array, ArrayRef}; + use datafusion_common::ScalarValue; + use rstest::rstest; + use sedona_expr::scalar_udf::SedonaScalarUDF; + use sedona_schema::datatypes::{WKB_GEOMETRY, WKB_VIEW_GEOMETRY}; + use sedona_testing::compare::assert_array_equal; + use sedona_testing::create::create_array; + use sedona_testing::testers::ScalarUdfTester; + + use super::*; + + #[rstest] + fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType) { + let udf = SedonaScalarUDF::from_impl("st_relate", st_relate_impl()); + let tester = ScalarUdfTester::new(udf.into(), vec![sedona_type.clone(), sedona_type]); + tester.assert_return_type(DataType::Utf8); + + // Two disjoint points — DE-9IM should be "FF0FFF0F2" + let result = tester + .invoke_scalar_scalar("POINT (0 0)", "POINT (1 1)") + .unwrap(); + tester.assert_scalar_result_equals(result, "FF0FFF0F2"); + + // NULL inputs should return NULL + let result = tester + .invoke_scalar_scalar(ScalarValue::Null, ScalarValue::Null) + .unwrap(); + assert!(result.is_null()); + + // Array inputs + let lhs = create_array( + &[ + Some("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"), + Some("POINT (0.5 0.5)"), + None, + ], + &WKB_GEOMETRY, + ); + let rhs = create_array( + &[ + Some("POINT (0.5 0.5)"), + Some("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"), + Some("POINT (0 0)"), + ], + &WKB_GEOMETRY, + ); + + // actual values from GEOS + let expected: ArrayRef = arrow_array!(Utf8, [Some("0F2FF1FF2"), Some("0FFFFF212"), None]); + assert_array_equal(&tester.invoke_array_array(lhs, rhs).unwrap(), &expected); + } +} diff --git a/docs/reference/sql/st_relate.qmd b/docs/reference/sql/st_relate.qmd new file mode 100644 index 000000000..ecfd6e5f7 --- /dev/null +++ b/docs/reference/sql/st_relate.qmd @@ -0,0 +1,34 @@ +--- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +title: ST_Relate +description: Returns the DE-9IM intersection matrix string for two geometries. +kernels: + - returns: string + args: [geometry, geometry] +--- +## Description +Returns the DE-9IM (Dimensionally Extended 9-Intersection Model) intersection matrix +as a 9-character string describing the spatial relationship between two geometries. + +## Examples +```sql +SELECT ST_Relate( + ST_GeomFromWKT('POINT(0 0)'), + ST_GeomFromWKT('POINT(1 1)') +); +``` diff --git a/python/sedonadb/tests/functions/test_predicates.py b/python/sedonadb/tests/functions/test_predicates.py index 9760ddc29..bba196702 100644 --- a/python/sedonadb/tests/functions/test_predicates.py +++ b/python/sedonadb/tests/functions/test_predicates.py @@ -442,3 +442,56 @@ def test_st_overlaps(eng, geom1, geom2, expected): f"SELECT ST_Overlaps({geom_or_null(geom1)}, {geom_or_null(geom2)})", expected, ) + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("geom1", "geom2", "expected"), + [ + (None, None, None), + ("POINT (0 0)", None, None), + (None, "POINT (0 0)", None), + ("POINT (0 0)", "POINT (1 1)", "FF0FFF0F2"), + ("POINT (0 0)", "POINT (0 0)", "0FFFFFFF2"), + ("POINT (0 0)", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "F0FFFF212"), + ("POINT (0.5 0.5)", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "0FFFFF212"), + ( + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + "POLYGON ((5 5, 6 5, 6 6, 5 6, 5 5))", + "FF2FF1212", + ), + ( + "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", + "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))", + "212101212", + ), + ("POINT (0 0)", "LINESTRING (0 0, 1 1)", "F0FFFF102"), + ("LINESTRING (0 0, 2 2)", "LINESTRING (1 1, 3 3)", "1010F0102"), + ( + "GEOMETRYCOLLECTION (POINT (0 0), LINESTRING (0 0, 1 1))", + "POINT (0 0)", + "FF10F0FF2", + ), + ( + "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", + "POLYGON ((2 0, 4 0, 4 2, 2 2, 2 0))", + "FF2F11212", + ), # touching polygons + ( + "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0))", + "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", + "212FF1FF2", + ), # polygon containment + ( + "POLYGON ((0 0, 6 0, 6 6, 0 6, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))", + "POINT (1 1)", + "0F2FF1FF2", + ), # point in a polygon hole + ], +) +def test_st_relate(eng, geom1, geom2, expected): + eng = eng.create_or_skip() + eng.assert_query_result( + f"SELECT ST_Relate({geom_or_null(geom1)}, {geom_or_null(geom2)})", + expected, + )