This is an automated email from the ASF dual-hosted git repository.
paleolimbot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new cd3733b feat(c/sedona-geos): Plumb remaining parameters for ST_Buffer
(#241)
cd3733b is described below
commit cd3733b6267f5a885aa07316634d1917a1d65e32
Author: Abeeujah <[email protected]>
AuthorDate: Wed Oct 29 19:28:56 2025 +0100
feat(c/sedona-geos): Plumb remaining parameters for ST_Buffer (#241)
Co-authored-by: Dewey Dunnington <[email protected]>
---
c/sedona-geos/src/register.rs | 16 +-
c/sedona-geos/src/st_buffer.rs | 608 ++++++++++++++++++++--
compose.yml | 2 +-
python/sedonadb/tests/functions/test_functions.py | 136 +++++
4 files changed, 719 insertions(+), 43 deletions(-)
diff --git a/c/sedona-geos/src/register.rs b/c/sedona-geos/src/register.rs
index b5ccde7..c4c5e3a 100644
--- a/c/sedona-geos/src/register.rs
+++ b/c/sedona-geos/src/register.rs
@@ -17,10 +17,17 @@
use sedona_expr::scalar_udf::ScalarKernelRef;
use crate::{
- distance::st_distance_impl, st_area::st_area_impl,
st_buffer::st_buffer_impl,
- st_centroid::st_centroid_impl, st_convexhull::st_convex_hull_impl,
st_dwithin::st_dwithin_impl,
- st_isring::st_is_ring_impl, st_issimple::st_is_simple_impl,
st_isvalid::st_is_valid_impl,
- st_isvalidreason::st_is_valid_reason_impl, st_length::st_length_impl,
+ distance::st_distance_impl,
+ st_area::st_area_impl,
+ st_buffer::{st_buffer_impl, st_buffer_style_impl},
+ st_centroid::st_centroid_impl,
+ st_convexhull::st_convex_hull_impl,
+ st_dwithin::st_dwithin_impl,
+ st_isring::st_is_ring_impl,
+ st_issimple::st_is_simple_impl,
+ st_isvalid::st_is_valid_impl,
+ st_isvalidreason::st_is_valid_reason_impl,
+ st_length::st_length_impl,
st_perimeter::st_perimeter_impl,
st_simplifypreservetopology::st_simplify_preserve_topology_impl,
st_unaryunion::st_unary_union_impl,
@@ -39,6 +46,7 @@ pub fn scalar_kernels() -> Vec<(&'static str,
ScalarKernelRef)> {
vec![
("st_area", st_area_impl()),
("st_buffer", st_buffer_impl()),
+ ("st_buffer", st_buffer_style_impl()),
("st_centroid", st_centroid_impl()),
("st_contains", st_contains_impl()),
("st_convexhull", st_convex_hull_impl()),
diff --git a/c/sedona-geos/src/st_buffer.rs b/c/sedona-geos/src/st_buffer.rs
index f0e2b65..2af2e62 100644
--- a/c/sedona-geos/src/st_buffer.rs
+++ b/c/sedona-geos/src/st_buffer.rs
@@ -18,10 +18,11 @@ use std::sync::Arc;
use arrow_array::builder::BinaryBuilder;
use arrow_schema::DataType;
+use datafusion_common::cast::as_float64_array;
use datafusion_common::error::Result;
-use datafusion_common::DataFusionError;
+use datafusion_common::{DataFusionError, ScalarValue};
use datafusion_expr::ColumnarValue;
-use geos::{BufferParams, Geom};
+use geos::{BufferParams, CapStyle, Geom, JoinStyle};
use sedona_expr::scalar_udf::{ScalarKernelRef, SedonaScalarKernel};
use sedona_geometry::wkb_factory::WKB_MIN_PROBABLE_BYTES;
use sedona_schema::{
@@ -32,6 +33,18 @@ use sedona_schema::{
use crate::executor::GeosExecutor;
/// ST_Buffer() implementation using the geos crate
+///
+/// Supports two signatures:
+/// - ST_Buffer(geometry: Geometry, distance: Double)
+/// - ST_Buffer(geometry: Geometry, distance: Double, bufferStyleParameters:
String)
+///
+/// Buffer style parameters format: "key1=value1 key2=value2 ..."
+/// Supported parameters:
+/// - endcap: round, flat/butt, square
+/// - join: round, mitre/miter, bevel
+/// - side: both, left, right
+/// - mitre_limit/miter_limit: numeric value
+/// - quad_segs/quadrant_segments: integer value
pub fn st_buffer_impl() -> ScalarKernelRef {
Arc::new(STBuffer {})
}
@@ -54,50 +67,78 @@ impl SedonaScalarKernel for STBuffer {
arg_types: &[SedonaType],
args: &[ColumnarValue],
) -> Result<ColumnarValue> {
- // Default params
- let params_builder = BufferParams::builder();
+ invoke_batch_impl(arg_types, args)
+ }
+}
- let params = params_builder
- .build()
- .map_err(|e| DataFusionError::External(Box::new(e)))?;
-
- // Extract the constant scalar value before looping over the input
geometries
- let distance: Option<f64>;
- let arg1 = args[1].cast_to(&DataType::Float64, None)?;
- if let ColumnarValue::Scalar(scalar_arg) = &arg1 {
- if scalar_arg.is_null() {
- distance = None;
- } else {
- distance = Some(f64::try_from(scalar_arg.clone())?);
- }
- } else {
- return Err(DataFusionError::Execution(format!(
- "Invalid distance: {:?}",
- args[1]
- )));
- }
+pub fn st_buffer_style_impl() -> ScalarKernelRef {
+ Arc::new(STBufferStyle {})
+}
+#[derive(Debug)]
+struct STBufferStyle {}
- let executor = GeosExecutor::new(arg_types, args);
- let mut builder = BinaryBuilder::with_capacity(
- executor.num_iterations(),
- WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+impl SedonaScalarKernel for STBufferStyle {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let matcher = ArgMatcher::new(
+ vec![
+ ArgMatcher::is_geometry(),
+ ArgMatcher::is_numeric(),
+ ArgMatcher::is_string(),
+ ],
+ WKB_GEOMETRY,
);
- executor.execute_wkb_void(|wkb| {
- match (wkb, distance) {
- (Some(wkb), Some(distance)) => {
- invoke_scalar(&wkb, distance, ¶ms, &mut builder)?;
- builder.append_value([]);
- }
- _ => builder.append_null(),
- }
- Ok(())
- })?;
+ matcher.match_args(args)
+ }
- executor.finish(Arc::new(builder.finish()))
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ invoke_batch_impl(arg_types, args)
}
}
+fn invoke_batch_impl(arg_types: &[SedonaType], args: &[ColumnarValue]) ->
Result<ColumnarValue> {
+ let executor = GeosExecutor::new(arg_types, args);
+ let mut builder = BinaryBuilder::with_capacity(
+ executor.num_iterations(),
+ WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+ );
+
+ // Extract Args
+ let distance_value = args[1]
+ .cast_to(&DataType::Float64, None)?
+ .to_array(executor.num_iterations())?;
+ let distance_array = as_float64_array(&distance_value)?;
+ let mut distance_iter = distance_array.iter();
+
+ let buffer_style_params = extract_optional_string(args.get(2))?;
+
+ // Build BufferParams based on style parameters
+ let params = parse_buffer_params(buffer_style_params.as_deref())?;
+
+ // Parse 'side' from the style parameters
+ let (is_left, is_right) =
parse_buffer_side_style(buffer_style_params.as_deref());
+
+ executor.execute_wkb_void(|wkb| {
+ match (wkb, distance_iter.next().unwrap()) {
+ (Some(wkb), Some(mut distance)) => {
+ if (is_left && distance < 0.0) || (is_right && distance > 0.0)
{
+ distance = -distance;
+ }
+ invoke_scalar(&wkb, distance, ¶ms, &mut builder)?;
+ builder.append_value([]);
+ }
+ _ => builder.append_null(),
+ }
+ Ok(())
+ })?;
+
+ executor.finish(Arc::new(builder.finish()))
+}
+
fn invoke_scalar(
geos_geom: &geos::Geometry,
distance: f64,
@@ -107,6 +148,7 @@ fn invoke_scalar(
let geometry = geos_geom
.buffer_with_params(distance, params)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
+
let wkb = geometry
.to_wkb()
.map_err(|e| DataFusionError::Execution(format!("Failed to convert to
wkb: {e}")))?;
@@ -115,6 +157,142 @@ fn invoke_scalar(
Ok(())
}
+fn extract_optional_string(arg: Option<&ColumnarValue>) ->
Result<Option<String>> {
+ let Some(arg) = arg else { return Ok(None) };
+ let casted = arg.cast_to(&DataType::Utf8, None)?;
+ match &casted {
+ ColumnarValue::Scalar(ScalarValue::Utf8(Some(s)) |
ScalarValue::LargeUtf8(Some(s))) => {
+ Ok(Some(s.clone()))
+ }
+ ColumnarValue::Scalar(scalar) if scalar.is_null() => Ok(None),
+ ColumnarValue::Scalar(_) => Ok(None),
+ _ => Err(DataFusionError::Execution(format!(
+ "Expected scalar bufferStyleParameters, got: {arg:?}",
+ ))),
+ }
+}
+
+fn parse_buffer_side_style(params: Option<&str>) -> (bool, bool) {
+ params
+ .map(|s| {
+ let mut left = false;
+ let mut right = false;
+ for tok in s.split_whitespace() {
+ if let Some((k, v)) = tok.split_once('=') {
+ if k.eq_ignore_ascii_case("side") {
+ if v.eq_ignore_ascii_case("left") {
+ left = true;
+ right = false;
+ } else if v.eq_ignore_ascii_case("right") {
+ right = true;
+ left = false;
+ }
+ }
+ }
+ }
+ (left, right)
+ })
+ .unwrap_or((false, false))
+}
+
+fn parse_buffer_params(params_str: Option<&str>) -> Result<BufferParams> {
+ let Some(params_str) = params_str else {
+ return BufferParams::builder()
+ .build()
+ .map_err(|e| DataFusionError::External(Box::new(e)));
+ };
+
+ let mut params_builder = BufferParams::builder();
+ let mut end_cap_specified = false;
+
+ for param in params_str.split_whitespace() {
+ let Some((key, value)) = param.split_once('=') else {
+ return Err(DataFusionError::Execution(format!(
+ "Missing value for buffer parameter: {param}",
+ )));
+ };
+
+ if key.eq_ignore_ascii_case("endcap") {
+ params_builder =
params_builder.end_cap_style(parse_cap_style(value)?);
+ end_cap_specified = true;
+ } else if key.eq_ignore_ascii_case("join") {
+ params_builder =
params_builder.join_style(parse_join_style(value)?);
+ } else if key.eq_ignore_ascii_case("side") {
+ let single_sided = is_single_sided(value)?;
+ if single_sided && !end_cap_specified {
+ params_builder =
params_builder.end_cap_style(CapStyle::Square);
+ }
+ params_builder = params_builder.single_sided(single_sided);
+ } else if key.eq_ignore_ascii_case("mitre_limit") ||
key.eq_ignore_ascii_case("miter_limit")
+ {
+ let limit: f64 = parse_number(value, "mitre_limit")?;
+ params_builder = params_builder.mitre_limit(limit);
+ } else if key.eq_ignore_ascii_case("quad_segs")
+ || key.eq_ignore_ascii_case("quadrant_segments")
+ {
+ let segs: i32 = parse_number(value, "quadrant_segments")?;
+ params_builder = params_builder.quadrant_segments(segs);
+ } else {
+ return Err(DataFusionError::Execution(format!(
+ "Invalid buffer parameter: {key} \
+ (accept: 'endcap', 'join', 'mitre_limit', 'miter_limit',
'quad_segs', 'quadrant_segments' and 'side')",
+ )));
+ }
+ }
+
+ params_builder
+ .build()
+ .map_err(|e| DataFusionError::External(Box::new(e)))
+}
+
+fn parse_cap_style(value: &str) -> Result<CapStyle> {
+ if value.eq_ignore_ascii_case("round") {
+ Ok(CapStyle::Round)
+ } else if value.eq_ignore_ascii_case("flat") ||
value.eq_ignore_ascii_case("butt") {
+ Ok(CapStyle::Flat)
+ } else if value.eq_ignore_ascii_case("square") {
+ Ok(CapStyle::Square)
+ } else {
+ Err(DataFusionError::Execution(format!(
+ "Invalid endcap style: '{value}'. Valid options: round, flat,
butt, square",
+ )))
+ }
+}
+
+fn parse_join_style(value: &str) -> Result<JoinStyle> {
+ if value.eq_ignore_ascii_case("round") {
+ Ok(JoinStyle::Round)
+ } else if value.eq_ignore_ascii_case("mitre") ||
value.eq_ignore_ascii_case("miter") {
+ Ok(JoinStyle::Mitre)
+ } else if value.eq_ignore_ascii_case("bevel") {
+ Ok(JoinStyle::Bevel)
+ } else {
+ Err(DataFusionError::Execution(format!(
+ "Invalid join style: '{value}'. Valid options: round, mitre,
miter, bevel",
+ )))
+ }
+}
+
+fn is_single_sided(value: &str) -> Result<bool> {
+ if value.eq_ignore_ascii_case("both") {
+ Ok(false)
+ } else if value.eq_ignore_ascii_case("left") ||
value.eq_ignore_ascii_case("right") {
+ Ok(true)
+ } else {
+ Err(DataFusionError::Execution(format!(
+ "Invalid side: '{value}'. Valid options: both, left, right",
+ )))
+ }
+}
+
+fn parse_number<T: std::str::FromStr>(value: &str, param_name: &str) ->
Result<T> {
+ value.parse().map_err(|_| {
+ DataFusionError::Execution(format!(
+ "Invalid {param_name} value: '{value}'. Expected a valid number",
+ ))
+ })
+}
+
#[cfg(test)]
mod tests {
use arrow_array::ArrayRef;
@@ -163,4 +341,358 @@ mod tests {
let envelope_result =
envelope_tester.invoke_array(buffer_result).unwrap();
assert_array_equal(&envelope_result, &expected_envelope);
}
+
+ #[rstest]
+ fn udf_with_buffer_params(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)]
sedona_type: SedonaType) {
+ let udf = SedonaScalarUDF::from_kernel("st_buffer",
st_buffer_style_impl());
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Utf8),
+ ],
+ );
+ tester.assert_return_type(WKB_GEOMETRY);
+
+ // Envelope checks result in different values for different GEOS
versions.
+ // This test at least ensures that the buffer parameters are plugged
in.
+ let buffer_result_flat = tester
+ .invoke_scalar_scalar_scalar("LINESTRING (0 0, 10 0)", 2.0,
"endcap=flat".to_string())
+ .unwrap();
+
+ let buffer_result_square = tester
+ .invoke_scalar_scalar_scalar("LINESTRING (0 0, 10 0)", 1.0,
"endcap=square".to_string())
+ .unwrap();
+
+ assert_ne!(buffer_result_flat, buffer_result_square);
+ }
+
+ #[rstest]
+ fn udf_with_quad_segs(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)]
sedona_type: SedonaType) {
+ let udf = SedonaScalarUDF::from_kernel("st_buffer",
st_buffer_style_impl());
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Utf8),
+ ],
+ );
+ tester.assert_return_type(WKB_GEOMETRY);
+
+ let envelope_udf = sedona_functions::st_envelope::st_envelope_udf();
+ let envelope_tester = ScalarUdfTester::new(envelope_udf.into(),
vec![WKB_GEOMETRY]);
+ let input_wkt = "POINT (5 5)";
+ let buffer_dist = 3.0;
+
+ let buffer_result_default = tester
+ .invoke_scalar_scalar_scalar(input_wkt, buffer_dist,
"endcap=round".to_string())
+ .unwrap();
+ let envelope_result_default = envelope_tester
+ .invoke_scalar(buffer_result_default)
+ .unwrap();
+
+ let expected_envelope = "POLYGON((2 2, 2 8, 8 8, 8 2, 2 2))";
+ tester.assert_scalar_result_equals(envelope_result_default,
expected_envelope);
+
+ let buffer_result_low_segs = tester
+ .invoke_scalar_scalar_scalar(
+ input_wkt,
+ buffer_dist,
+ "quad_segs=1 endcap=round".to_string(),
+ )
+ .unwrap();
+ let envelope_result_low_segs = envelope_tester
+ .invoke_scalar(buffer_result_low_segs)
+ .unwrap();
+ tester.assert_scalar_result_equals(envelope_result_low_segs,
expected_envelope);
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_endcap() {
+ let err = parse_buffer_params(Some("endcap=invalid")).err().unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid endcap style: 'invalid'. Valid options: round, flat,
butt, square"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_join() {
+ let err = parse_buffer_params(Some("join=invalid")).err().unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid join style: 'invalid'. Valid options: round, mitre,
miter, bevel"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_side() {
+ let err = parse_buffer_params(Some("side=invalid")).err().unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid side: 'invalid'. Valid options: both, left, right"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_mitre_limit() {
+ let err = parse_buffer_params(Some("mitre_limit=not_a_number"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid mitre_limit value: 'not_a_number'. Expected a valid
number"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_miter_limit() {
+ let err = parse_buffer_params(Some("miter_limit=abc")).err().unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid mitre_limit value: 'abc'. Expected a valid number"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_quad_segs() {
+ let err = parse_buffer_params(Some("quad_segs=not_an_int"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid quadrant_segments value: 'not_an_int'. Expected a valid
number"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_quadrant_segments() {
+ let err = parse_buffer_params(Some("quadrant_segments=xyz"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid quadrant_segments value: 'xyz'. Expected a valid number"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_multiple_invalid_params() {
+ // Test that the first invalid parameter is caught
+ let err = parse_buffer_params(Some("endcap=wrong join=mitre"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid endcap style: 'wrong'. Valid options: round, flat, butt,
square"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_mixed_with_valid() {
+ // Test invalid parameter after valid ones
+ let err = parse_buffer_params(Some("endcap=round join=invalid"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid join style: 'invalid'. Valid options: round, mitre,
miter, bevel"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_invalid_param_name() {
+ let err = parse_buffer_params(Some("unknown_param=value"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Invalid buffer parameter: unknown_param (accept: 'endcap',
'join', 'mitre_limit', 'miter_limit', 'quad_segs', 'quadrant_segments' and
'side')"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_missing_value() {
+ let err = parse_buffer_params(Some("endcap=round bare_param
join=mitre"))
+ .err()
+ .unwrap();
+ assert_eq!(
+ err.message(),
+ "Missing value for buffer parameter: bare_param"
+ );
+ }
+
+ #[test]
+ fn test_parse_buffer_params_duplicate_params_no_error() {
+ let result = parse_buffer_params(Some("endcap=round endcap=flat"));
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn test_parse_buffer_params_quad_segs_out_of_range() {
+ let result = parse_buffer_params(Some("quad_segs=-5"));
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn test_parse_buffer_params_side_functional() {
+ let wkt_line = "LINESTRING(50 50, 150 150 ,150 50)";
+ let line = geos::Geometry::new_from_wkt(wkt_line).unwrap();
+ let buffer_distance = 100.0;
+
+ // BufferParams don't implement types that makes them testable
+ let result_params = parse_buffer_params(Some("side=right")).unwrap();
+ let expected_params = BufferParams::builder()
+ .end_cap_style(CapStyle::Square)
+ .single_sided(true)
+ .build()
+ .unwrap();
+
+ // Testing via behavior here
+ let result_buffer = line
+ .buffer_with_params(buffer_distance, &result_params)
+ .unwrap();
+ let expected_buffer = line
+ .buffer_with_params(buffer_distance, &expected_params)
+ .unwrap();
+
+ // Assert: Compare the resulting Geometry
+ assert!(result_buffer.equals_exact(&expected_buffer, 0.1).unwrap());
+ }
+
+ #[test]
+ fn test_parse_buffer_params_non_default_cap_with_side() {
+ let wkt_line = "LINESTRING(50 50, 150 150 ,150 50)";
+ let line = geos::Geometry::new_from_wkt(wkt_line).unwrap();
+ let result_params = parse_buffer_params(Some("side=right
endcap=round")).unwrap();
+
+ // Assert (Expected): The cap should be Flat, and it should be
single-sided
+ let expected_params = BufferParams::builder()
+ .end_cap_style(CapStyle::Round)
+ .single_sided(true)
+ .build()
+ .unwrap();
+
+ // Check functional equivalence by generating and comparing geometries
+ let buffer_distance = 84.3;
+
+ let result_buffer = line
+ .buffer_with_params(buffer_distance, &result_params)
+ .unwrap();
+ let expected_buffer = line
+ .buffer_with_params(buffer_distance, &expected_params)
+ .unwrap();
+
+ assert!(result_buffer.equals_exact(&expected_buffer, 0.1).unwrap());
+ }
+
+ #[test]
+ fn test_parse_buffer_params_explicit_default_side() {
+ let wkt_line = "LINESTRING (0 0, 1 0)";
+ let line = geos::Geometry::new_from_wkt(wkt_line).unwrap();
+ let buffer_distance = 0.1;
+
+ let result_params = parse_buffer_params(Some("side=both")).unwrap();
+ let expected_params = BufferParams::builder().build().unwrap();
+
+ let result_buffer = line
+ .buffer_with_params(buffer_distance, &result_params)
+ .unwrap();
+ let expected_buffer = line
+ .buffer_with_params(buffer_distance, &expected_params)
+ .unwrap();
+
+ assert!(result_buffer.equals_exact(&expected_buffer, 0.1).unwrap());
+ }
+
+ #[test]
+ fn test_side_right_geos_3_13() {
+ let wkt = "LINESTRING(50 50, 150 150, 150 50)";
+ let line = geos::Geometry::new_from_wkt(wkt).unwrap();
+ let distance = 100.0;
+
+ // Test single-sided buffer (GEOS 3.13+ removes artifacts, giving
12713.61)
+ // PostGIS with GEOS 3.9 returns 16285.08 due to including geometric
artifacts
+ // GEOS 3.12+ improvements:
https://github.com/libgeos/geos/commit/091f6d99
+ let params_single =
BufferParams::builder().single_sided(true).build().unwrap();
+
+ let buffer_right = line.buffer_with_params(-distance,
¶ms_single).unwrap();
+ let area_right = buffer_right.area().unwrap();
+
+ // Expected area with GEOS 3.13 (improved algorithm without artifacts)
+ assert!(
+ (area_right - 12713.605978550266).abs() < 0.1,
+ "Expected GEOS 3.13+ area ~12713.61, got {}",
+ area_right
+ );
+ }
+
+ #[test]
+ fn test_empty_and_invalid_input() {
+ assert_eq!(
+ parse_buffer_side_style(None),
+ (false, false),
+ "Should return (false, false) for None."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("")),
+ (false, false),
+ "Should return (false, false) for an empty string."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("mitre_limit=5.0")),
+ (false, false),
+ "Should return (false, false) for an invalid key."
+ );
+ }
+
+ #[test]
+ fn test_single_side_and_case_insensitivity() {
+ assert_eq!(
+ parse_buffer_side_style(Some("side=left")),
+ (true, false),
+ "Should detect 'left'."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("side=RIGHT")),
+ (false, true),
+ "Should detect 'RIGHT' case-insensitively."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("SiDe=LeFt")),
+ (true, false),
+ "Should handle mixed case key and value."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("join=mitre SIDE=RIGHT
mitre_limit=5.0")),
+ (false, true),
+ "Should ignore other params and detect 'RIGHT'."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("side=center")),
+ (false, false),
+ "Should ignore invalid side values."
+ );
+ }
+
+ #[test]
+ fn test_both_sides_present() {
+ assert_eq!(
+ parse_buffer_side_style(Some("side=left side=right")),
+ (false, true),
+ "Should detect both left and right."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("side=right side=left join=round")),
+ (true, false),
+ "Should detect both regardless of order."
+ );
+ assert_eq!(
+ parse_buffer_side_style(Some("SIDE=RIGHT endcap=round side=left")),
+ (true, false),
+ "Should handle complex string with both sides."
+ );
+ }
}
diff --git a/compose.yml b/compose.yml
index 9039419..1189a9b 100644
--- a/compose.yml
+++ b/compose.yml
@@ -17,7 +17,7 @@
services:
postgis:
platform: linux/amd64
- image: postgis/postgis:latest
+ image: postgis/postgis:18-3.6
ports:
- "5432:5432"
environment:
diff --git a/python/sedonadb/tests/functions/test_functions.py
b/python/sedonadb/tests/functions/test_functions.py
index addc774..5d2b250 100644
--- a/python/sedonadb/tests/functions/test_functions.py
+++ b/python/sedonadb/tests/functions/test_functions.py
@@ -176,6 +176,142 @@ def test_st_buffer(eng, geom, dist, expected_area):
)
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "dist", "buffer_style_parameters", "expected_area"),
+ [
+ (None, None, None, None),
+ ("POINT(100 90)", 50, "'quad_segs=8'", 7803.612880645131),
+ (
+ "LINESTRING(50 50,150 150,150 50)",
+ 10,
+ "'endcap=round join=round'",
+ 5016.204476944362,
+ ),
+ (
+ "POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))",
+ 2,
+ "'join=miter'",
+ 196.0,
+ ),
+ (
+ "LINESTRING(0 0, 10 0)",
+ 5,
+ "'endcap=square'",
+ 200.0,
+ ),
+ (
+ "POINT(0 0)",
+ 10,
+ "'quad_segs=4'",
+ 306.1467458920718,
+ ),
+ (
+ "POINT(0 0)",
+ 10,
+ "'quad_segs=16'",
+ 313.654849054594,
+ ),
+ (
+ "LINESTRING(0 0, 100 0, 100 100)",
+ 5,
+ "'join=bevel'",
+ 2065.536128806451,
+ ),
+ (
+ "LINESTRING(0 0, 50 0)",
+ 10,
+ "'endcap=flat'",
+ 1000.0,
+ ),
+ (
+ "POLYGON((0 0, 0 20, 20 20, 20 0, 0 0))",
+ -2,
+ "'join=round'",
+ 256.0,
+ ),
+ (
+ "POLYGON((0 0, 0 100, 100 100, 100 0, 0 0), (20 20, 20 80, 80 80,
80 20, 20 20))",
+ 5,
+ "'join=round quad_segs=4'",
+ 9576.536686473019,
+ ),
+ (
+ "MULTIPOINT((10 10), (30 30))",
+ 5,
+ "'quad_segs=8'",
+ 156.0722576129026,
+ ),
+ (
+ "GEOMETRYCOLLECTION(POINT(10 10), LINESTRING(50 50, 60 60))",
+ 3,
+ "'endcap=round join=round'",
+ 141.0388264830308,
+ ),
+ (
+ "POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))",
+ 0,
+ "'join=miter'",
+ 100.0,
+ ),
+ (
+ "POINT(0 0)",
+ 0.1,
+ "'quad_segs=8'",
+ 0.031214451522580514,
+ ),
+ (
+ "LINESTRING(0 0, 50 0, 50 50)",
+ 10,
+ "'join=miter miter_limit=2'",
+ 2312.1445152258043,
+ ),
+ (
+ "LINESTRING(0 0, 0 100)",
+ 10,
+ "'side=left'",
+ 1000.0,
+ ),
+ # GEOS version difference: GEOS 3.9 (PostGIS) returns 16285.08 with
artifacts
+ # GEOS 3.12+ (SedonaDB) returns 12713.61 without artifacts (more
accurate)
+ # See: https://github.com/libgeos/geos/commit/091f6d99
+ (
+ "LINESTRING (50 50, 150 150, 150 50)",
+ 100,
+ "'side=right'",
+ 12713.605978550266,
+ ),
+ (
+ "POLYGON ((50 50, 50 150, 150 150, 150 50, 50 50))",
+ 20,
+ "'side=left'",
+ 10000.0, # GEOS 3.9 (PostGIS): 19248.58
+ ),
+ (
+ "POLYGON ((50 50, 50 150, 150 150, 150 50, 50 50))",
+ 20,
+ "'side=right endcap=flat'",
+ 6400.0, # GEOS 3.9 (PostGIS): 3600.0
+ ),
+ (
+ "LINESTRING (50 50, 150 150, 150 50)",
+ 100,
+ "'side=both'",
+ 69888.089291866,
+ ),
+ ],
+)
+def test_st_buffer_style_parameters(
+ eng, geom, dist, buffer_style_parameters, expected_area
+):
+ eng = eng.create_or_skip()
+ eng.assert_query_result(
+ f"SELECT ST_Area(ST_Buffer({geom_or_null(geom)}, {val_or_null(dist)},
{val_or_null(buffer_style_parameters)}))",
+ expected_area,
+ numeric_epsilon=1e-9,
+ )
+
+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
@pytest.mark.parametrize(
("geom", "expected"),