From 50e09aead20a71acc4ddc7de8722c3bb110bbe9d Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Thu, 7 May 2026 23:29:03 -0700 Subject: [PATCH 1/2] [GH-2925] Add ST_Expand(box2d, ...) overloads Adds Box2D variants alongside the existing Geometry-input ST_Expand: ST_Expand(box: Box2D, units: double) -> Box2D ST_Expand(box: Box2D, dx: double, dy: double) -> Box2D Negative deltas are allowed and may produce a degenerate Box2D where xmin > xmax or ymin > ymax. The result is returned as-is; callers can detect degenerate output via the accessor functions. NULL on null input. JVM, Python, Flink wrappers all updated. The Python and Scala DataFrame API wrappers were already polymorphic over Column, so no wrapper-side change was needed; the JVM-side overload resolution handles Box2D vs Geometry. Flink ScalarFunction needed two new eval overloads on the existing class. Closes #2925. --- .../org/apache/sedona/common/Functions.java | 22 +++++++++++ .../apache/sedona/common/FunctionsTest.java | 24 ++++++++++++ .../sedona/flink/expressions/Functions.java | 23 +++++++++++ .../org/apache/sedona/flink/FunctionTest.java | 19 ++++++++++ python/tests/sql/test_function.py | 18 +++++++++ .../sedona_sql/expressions/Functions.scala | 9 +++-- .../apache/sedona/sql/functionTestScala.scala | 38 +++++++++++++++++++ 7 files changed, 150 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index c527a2049ca..325abcef51d 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -235,6 +235,28 @@ public static Geometry expand(Geometry geometry, double deltaX, double deltaY) { return expand(geometry, deltaX, deltaY, 0); } + /** Expand a {@link Box2D} uniformly on both axes. NULL on null input. */ + public static Box2D expand(Box2D box, double uniformDelta) { + return expand(box, uniformDelta, uniformDelta); + } + + /** + * Expand a {@link Box2D} by the given per-axis deltas. Negative deltas shrink the bbox; if a + * negative delta produces {@code xmin > xmax} or {@code ymin > ymax}, the resulting Box2D is + * returned as-is (callers can detect the degenerate result via the accessor functions). NULL on + * null input. + */ + public static Box2D expand(Box2D box, double deltaX, double deltaY) { + if (box == null) { + return null; + } + return new Box2D( + box.getXMin() - deltaX, + box.getYMin() - deltaY, + box.getXMax() + deltaX, + box.getYMax() + deltaY); + } + public static Geometry expand(Geometry geometry, double deltaX, double deltaY, double deltaZ) { if (geometry == null || geometry.isEmpty()) { return geometry; diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java index 1f869ec29af..d7e01c4ac17 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -176,6 +176,30 @@ public void box2dAsText() { assertNull(Functions.box2dAsText(null)); } + @Test + public void expandBox2D() { + Box2D box = new Box2D(1.0, 2.0, 4.0, 5.0); + + Box2D uniform = Functions.expand(box, 1.0); + assertEquals(0.0, uniform.getXMin(), 0.0); + assertEquals(1.0, uniform.getYMin(), 0.0); + assertEquals(5.0, uniform.getXMax(), 0.0); + assertEquals(6.0, uniform.getYMax(), 0.0); + + Box2D perAxis = Functions.expand(box, 2.0, 0.5); + assertEquals(-1.0, perAxis.getXMin(), 0.0); + assertEquals(1.5, perAxis.getYMin(), 0.0); + assertEquals(6.0, perAxis.getXMax(), 0.0); + assertEquals(5.5, perAxis.getYMax(), 0.0); + + // Negative deltas may produce a degenerate bbox (xmin > xmax); we return as-is. + Box2D shrunkPastZero = Functions.expand(new Box2D(0.0, 0.0, 1.0, 1.0), -2.0); + assertTrue(shrunkPastZero.getXMin() > shrunkPastZero.getXMax()); + + assertNull(Functions.expand((Box2D) null, 1.0)); + assertNull(Functions.expand((Box2D) null, 1.0, 1.0)); + } + @Test public void asWKB() throws Exception { Geometry geometry = GEOMETRY_FACTORY.createPoint(new Coordinate(1.0, 2.0)); diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java index 2f0103a639a..8465e80ab58 100644 --- a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java +++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java @@ -581,6 +581,29 @@ public Geometry eval( Geometry geom = (Geometry) o; return org.apache.sedona.common.Functions.expand(geom, deltaX, deltaY, deltaZ); } + + @DataTypeHint(value = "RAW", rawSerializer = Box2DTypeSerializer.class, bridgedTo = Box2D.class) + public Box2D eval( + @DataTypeHint( + value = "RAW", + rawSerializer = Box2DTypeSerializer.class, + bridgedTo = Box2D.class) + Box2D box, + @DataTypeHint(value = "Double") Double uniformDelta) { + return org.apache.sedona.common.Functions.expand(box, uniformDelta); + } + + @DataTypeHint(value = "RAW", rawSerializer = Box2DTypeSerializer.class, bridgedTo = Box2D.class) + public Box2D eval( + @DataTypeHint( + value = "RAW", + rawSerializer = Box2DTypeSerializer.class, + bridgedTo = Box2D.class) + Box2D box, + @DataTypeHint(value = "Double") Double deltaX, + @DataTypeHint(value = "Double") Double deltaY) { + return org.apache.sedona.common.Functions.expand(box, deltaX, deltaY); + } } public static class ST_Dimension extends ScalarFunction { diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java index 15fa3ae8868..0bc23111075 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -535,6 +535,25 @@ public void testExpand() { assertEquals(expected, actual); } + @Test + public void testExpandBox2D() { + Table t = + tableEnv.sqlQuery( + "SELECT ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 1.0) AS uniform," + + " ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 2.0, 0.5) AS per_axis"); + Row row = first(t); + Box2D uniform = (Box2D) row.getField(0); + assertEquals(0.0, uniform.getXMin(), 0.0); + assertEquals(1.0, uniform.getYMin(), 0.0); + assertEquals(5.0, uniform.getXMax(), 0.0); + assertEquals(6.0, uniform.getYMax(), 0.0); + Box2D perAxis = (Box2D) row.getField(1); + assertEquals(-1.0, perAxis.getXMin(), 0.0); + assertEquals(1.5, perAxis.getYMin(), 0.0); + assertEquals(6.0, perAxis.getXMax(), 0.0); + assertEquals(5.5, perAxis.getYMax(), 0.0); + } + @Test public void testFlipCoordinates() { Table pointTable = createPointTable_real(testDataSize); diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index 9ff47d5b4fe..c26798d9d8d 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -243,6 +243,24 @@ def test_st_expand(self): expected = "POLYGON Z((44 45 4, 44 85 4, 86 85 0, 86 45 0, 44 45 4))" assert expected == actual + def test_st_expand_box_2d(self): + df = self.spark.sql(""" + SELECT + ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 1.0) AS uniform, + ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 2.0, 0.5) AS per_axis + """) + row = df.first() + uniform = row[0] + assert uniform.xmin == 0.0 + assert uniform.ymin == 1.0 + assert uniform.xmax == 5.0 + assert uniform.ymax == 6.0 + per_axis = row[1] + assert per_axis.xmin == -1.0 + assert per_axis.ymin == 1.5 + assert per_axis.xmax == 6.0 + assert per_axis.ymax == 5.5 + def test_st_centroid(self): polygon_wkt_df = ( self.spark.read.format("csv") diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index c9dc113e595..6af47e51603 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -260,9 +260,12 @@ private[apache] case class ST_Box2D(inputExpressions: Seq[Expression]) private[apache] case class ST_Expand(inputExpressions: Seq[Expression]) extends InferredExpression( - inferrableFunction4(Functions.expand), - inferrableFunction3(Functions.expand), - inferrableFunction2(Functions.expand)) { + inferrableFunction4((g: Geometry, dx: Double, dy: Double, dz: Double) => + Functions.expand(g, dx, dy, dz)), + inferrableFunction3((g: Geometry, dx: Double, dy: Double) => Functions.expand(g, dx, dy)), + inferrableFunction2((g: Geometry, delta: Double) => Functions.expand(g, delta)), + inferrableFunction3((b: Box2D, dx: Double, dy: Double) => Functions.expand(b, dx, dy)), + inferrableFunction2((b: Box2D, delta: Double) => Functions.expand(b, delta))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala index ef5d316e81d..06a9028e9ac 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala @@ -262,6 +262,44 @@ class functionTestScala assertEquals(expected, actual) } + it("Passed ST_Expand for Box2D") { + val df = sparkSession.sql(""" + WITH t AS ( + SELECT ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')) AS bbox, + ST_Box2D(ST_GeomFromText(NULL)) AS bbox_null + ) + SELECT + ST_Expand(bbox, 1.0) AS uniform, + ST_Expand(bbox, 2.0, 0.5) AS per_axis, + ST_Expand(bbox, -1.0) AS shrink, + ST_Expand(bbox_null, 1.0) AS null_uniform, + ST_Expand(bbox_null, 1.0, 1.0) AS null_per_axis + FROM t + """) + val row = df.collect()(0) + + val uniform = row.getAs[Box2D]("uniform") + assert(uniform.getXMin == 0.0) + assert(uniform.getYMin == 1.0) + assert(uniform.getXMax == 5.0) + assert(uniform.getYMax == 6.0) + + val perAxis = row.getAs[Box2D]("per_axis") + assert(perAxis.getXMin == -1.0) + assert(perAxis.getYMin == 1.5) + assert(perAxis.getXMax == 6.0) + assert(perAxis.getYMax == 5.5) + + val shrink = row.getAs[Box2D]("shrink") + assert(shrink.getXMin == 2.0) + assert(shrink.getYMin == 3.0) + assert(shrink.getXMax == 3.0) + assert(shrink.getYMax == 4.0) + + assert(row.isNullAt(3)) + assert(row.isNullAt(4)) + } + it("Passed ST_YMax") { var test = sparkSession.sql( "SELECT ST_YMax(ST_GeomFromWKT('POLYGON ((-3 -3, 3 -3, 3 -2, -3 -1, -3 -3))'))") From 5158213dd2b8badfc96f4f1a9ce6498eb7aee2b9 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Thu, 7 May 2026 23:56:54 -0700 Subject: [PATCH 2/2] Address review on ST_Expand Box2D: add NULL propagation tests Both Python and Flink tests now exercise NULL Box2D input through the uniform and per-axis ST_Expand signatures, confirming NULL propagates through the deserialization paths. --- .../test/java/org/apache/sedona/flink/FunctionTest.java | 9 +++++++++ python/tests/sql/test_function.py | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java index 0bc23111075..acb18ffa0ff 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -552,6 +552,15 @@ public void testExpandBox2D() { assertEquals(1.5, perAxis.getYMin(), 0.0); assertEquals(6.0, perAxis.getXMax(), 0.0); assertEquals(5.5, perAxis.getYMax(), 0.0); + + // NULL Box2D input propagates to NULL for both signatures. + Table tNull = + tableEnv.sqlQuery( + "SELECT ST_Expand(ST_Box2D(ST_GeomFromText(CAST(NULL AS STRING))), 1.0) AS u," + + " ST_Expand(ST_Box2D(ST_GeomFromText(CAST(NULL AS STRING))), 1.0, 1.0) AS p"); + Row nullRow = first(tNull); + assertNull(nullRow.getField(0)); + assertNull(nullRow.getField(1)); } @Test diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index c26798d9d8d..11535d09937 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -247,7 +247,9 @@ def test_st_expand_box_2d(self): df = self.spark.sql(""" SELECT ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 1.0) AS uniform, - ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 2.0, 0.5) AS per_axis + ST_Expand(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))')), 2.0, 0.5) AS per_axis, + ST_Expand(ST_Box2D(ST_GeomFromText(NULL)), 1.0) AS null_uniform, + ST_Expand(ST_Box2D(ST_GeomFromText(NULL)), 1.0, 1.0) AS null_per_axis """) row = df.first() uniform = row[0] @@ -260,6 +262,9 @@ def test_st_expand_box_2d(self): assert per_axis.ymin == 1.5 assert per_axis.xmax == 6.0 assert per_axis.ymax == 5.5 + # NULL Box2D input deserializes to None for both signatures. + assert row[2] is None + assert row[3] is None def test_st_centroid(self): polygon_wkt_df = (