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, &params, &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, &params, &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, 
&params_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"),

Reply via email to