Skip to content

Commit c863a46

Browse files
authored
[GH-2926] Add ST_BoxIntersects and ST_BoxContains for Box2D (#2932)
1 parent f11b20b commit c863a46

11 files changed

Lines changed: 299 additions & 0 deletions

File tree

common/src/main/java/org/apache/sedona/common/Predicates.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package org.apache.sedona.common;
2020

21+
import org.apache.sedona.common.geometryObjects.Box2D;
2122
import org.apache.sedona.common.sphere.Spheroid;
2223
import org.locationtech.jts.geom.*;
2324
import org.locationtech.jts.operation.relate.RelateOp;
@@ -27,6 +28,55 @@ public static boolean contains(Geometry leftGeometry, Geometry rightGeometry) {
2728
return leftGeometry.contains(rightGeometry);
2829
}
2930

31+
/**
32+
* Closed-interval bbox intersection: true if {@code a} and {@code b} overlap on <em>both</em> the
33+
* X and Y axes (matches PostGIS {@code &&} on box2d). Edge- and corner-touching boxes count as
34+
* intersecting.
35+
*
36+
* <p>Both arguments must have ordered bounds ({@code xmin <= xmax} and {@code ymin <= ymax}).
37+
* Sedona's Box2D type allows inverted bounds ({@code xmin > xmax}) — that ordering is reserved
38+
* for a future antimeridian-wraparound semantics on geography bboxes (cf. sedona-db's {@code
39+
* WraparoundInterval}). Until those semantics ship, planar predicates throw on inverted input
40+
* rather than silently returning misleading results. SQL callers see NULL in/out null
41+
* propagation; this Java entry point throws on null.
42+
*/
43+
public static boolean boxIntersects(Box2D a, Box2D b) {
44+
requireOrderedPlanarBox(a, "a");
45+
requireOrderedPlanarBox(b, "b");
46+
return !(a.getXMax() < b.getXMin()
47+
|| a.getXMin() > b.getXMax()
48+
|| a.getYMax() < b.getYMin()
49+
|| a.getYMin() > b.getYMax());
50+
}
51+
52+
/**
53+
* True if {@code a} fully contains {@code b} on <em>both</em> the X and Y axes (closed intervals;
54+
* matches PostGIS {@code ~} on box2d). Equal boxes contain each other.
55+
*
56+
* <p>Same ordered-bound contract as {@link #boxIntersects(Box2D, Box2D)} — inverted bounds throw
57+
* because planar containment with inverted intervals has no defined meaning until antimeridian
58+
* wraparound semantics ship.
59+
*/
60+
public static boolean boxContains(Box2D a, Box2D b) {
61+
requireOrderedPlanarBox(a, "a");
62+
requireOrderedPlanarBox(b, "b");
63+
return a.getXMin() <= b.getXMin()
64+
&& a.getYMin() <= b.getYMin()
65+
&& a.getXMax() >= b.getXMax()
66+
&& a.getYMax() >= b.getYMax();
67+
}
68+
69+
private static void requireOrderedPlanarBox(Box2D box, String argName) {
70+
if (box.getXMin() > box.getXMax() || box.getYMin() > box.getYMax()) {
71+
throw new IllegalArgumentException(
72+
"Box2D argument '"
73+
+ argName
74+
+ "' has inverted bounds (xmin > xmax or ymin > ymax). Planar Box2D predicates "
75+
+ "require ordered intervals; inverted bounds are reserved for future antimeridian "
76+
+ "wraparound semantics.");
77+
}
78+
}
79+
3080
public static boolean intersects(Geometry leftGeometry, Geometry rightGeometry) {
3181
return leftGeometry.intersects(rightGeometry);
3282
}

common/src/test/java/org/apache/sedona/common/PredicatesTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static org.apache.sedona.common.Functions.crossesDateLine;
2323
import static org.junit.Assert.*;
2424

25+
import org.apache.sedona.common.geometryObjects.Box2D;
2526
import org.junit.Test;
2627
import org.locationtech.jts.geom.Coordinate;
2728
import org.locationtech.jts.geom.Geometry;
@@ -32,6 +33,55 @@ public class PredicatesTest extends TestBase {
3233

3334
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
3435

36+
@Test
37+
public void testBoxIntersects() {
38+
Box2D a = new Box2D(0.0, 0.0, 5.0, 5.0);
39+
40+
// Full overlap
41+
assertTrue(Predicates.boxIntersects(a, new Box2D(1.0, 1.0, 2.0, 2.0)));
42+
// Partial overlap
43+
assertTrue(Predicates.boxIntersects(a, new Box2D(3.0, 3.0, 7.0, 7.0)));
44+
// Edge-touching (closed intervals)
45+
assertTrue(Predicates.boxIntersects(a, new Box2D(5.0, 0.0, 10.0, 5.0)));
46+
// Corner-touching (closed intervals)
47+
assertTrue(Predicates.boxIntersects(a, new Box2D(5.0, 5.0, 10.0, 10.0)));
48+
// Disjoint on X
49+
assertFalse(Predicates.boxIntersects(a, new Box2D(6.0, 0.0, 10.0, 5.0)));
50+
// Disjoint on Y
51+
assertFalse(Predicates.boxIntersects(a, new Box2D(0.0, 6.0, 5.0, 10.0)));
52+
}
53+
54+
@Test
55+
public void testBoxContains() {
56+
Box2D outer = new Box2D(0.0, 0.0, 10.0, 10.0);
57+
58+
assertTrue(Predicates.boxContains(outer, new Box2D(2.0, 2.0, 5.0, 5.0)));
59+
// Boundaries are inclusive
60+
assertTrue(Predicates.boxContains(outer, new Box2D(0.0, 0.0, 10.0, 10.0)));
61+
assertTrue(Predicates.boxContains(outer, new Box2D(0.0, 0.0, 1.0, 1.0)));
62+
// Outside on X
63+
assertFalse(Predicates.boxContains(outer, new Box2D(-1.0, 0.0, 5.0, 5.0)));
64+
// Crosses boundary on X
65+
assertFalse(Predicates.boxContains(outer, new Box2D(5.0, 0.0, 11.0, 5.0)));
66+
}
67+
68+
@Test
69+
public void testBoxPredicatesRejectInvertedBounds() {
70+
// Box2D allows xmin > xmax (reserved for future antimeridian wraparound); planar predicates
71+
// refuse to evaluate them rather than silently returning misleading results.
72+
Box2D normal = new Box2D(0.0, 0.0, 5.0, 5.0);
73+
Box2D wrapX = new Box2D(170.0, 10.0, -170.0, 20.0); // longitude crosses antimeridian
74+
Box2D wrapY = new Box2D(0.0, 5.0, 5.0, 0.0); // ymin > ymax
75+
76+
IllegalArgumentException ex1 =
77+
assertThrows(IllegalArgumentException.class, () -> Predicates.boxIntersects(wrapX, normal));
78+
assertTrue(ex1.getMessage().contains("inverted bounds"));
79+
80+
IllegalArgumentException ex2 =
81+
assertThrows(IllegalArgumentException.class, () -> Predicates.boxContains(normal, wrapY));
82+
assertTrue(ex2.getMessage().contains("inverted bounds"));
83+
}
84+
3585
@Test
3686
public void testDWithinSuccess() {
3787
Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1));

flink/src/main/java/org/apache/sedona/flink/Catalog.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ public static UserDefinedFunction[] getFuncs() {
247247

248248
public static UserDefinedFunction[] getPredicates() {
249249
return new UserDefinedFunction[] {
250+
new Predicates.ST_BoxContains(),
251+
new Predicates.ST_BoxIntersects(),
250252
new Predicates.ST_Intersects(),
251253
new Predicates.ST_Contains(),
252254
new Predicates.ST_Crosses(),

flink/src/main/java/org/apache/sedona/flink/expressions/Predicates.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,49 @@
2020

2121
import org.apache.flink.table.annotation.DataTypeHint;
2222
import org.apache.flink.table.functions.ScalarFunction;
23+
import org.apache.sedona.common.geometryObjects.Box2D;
24+
import org.apache.sedona.flink.Box2DTypeSerializer;
2325
import org.apache.sedona.flink.GeometryTypeSerializer;
2426
import org.locationtech.jts.geom.Geometry;
2527

2628
public class Predicates {
2729

30+
public static class ST_BoxIntersects extends ScalarFunction {
31+
@DataTypeHint("Boolean")
32+
public Boolean eval(
33+
@DataTypeHint(
34+
value = "RAW",
35+
rawSerializer = Box2DTypeSerializer.class,
36+
bridgedTo = Box2D.class)
37+
Box2D a,
38+
@DataTypeHint(
39+
value = "RAW",
40+
rawSerializer = Box2DTypeSerializer.class,
41+
bridgedTo = Box2D.class)
42+
Box2D b) {
43+
if (a == null || b == null) return null;
44+
return org.apache.sedona.common.Predicates.boxIntersects(a, b);
45+
}
46+
}
47+
48+
public static class ST_BoxContains extends ScalarFunction {
49+
@DataTypeHint("Boolean")
50+
public Boolean eval(
51+
@DataTypeHint(
52+
value = "RAW",
53+
rawSerializer = Box2DTypeSerializer.class,
54+
bridgedTo = Box2D.class)
55+
Box2D a,
56+
@DataTypeHint(
57+
value = "RAW",
58+
rawSerializer = Box2DTypeSerializer.class,
59+
bridgedTo = Box2D.class)
60+
Box2D b) {
61+
if (a == null || b == null) return null;
62+
return org.apache.sedona.common.Predicates.boxContains(a, b);
63+
}
64+
}
65+
2866
public static class ST_Intersects extends ScalarFunction {
2967
/** Constructor for relation checking without duplicate removal */
3068
public ST_Intersects() {}

flink/src/test/java/org/apache/sedona/flink/PredicateTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ public static void onceExecutedBeforeAll() {
3434
initialize();
3535
}
3636

37+
@Test
38+
public void testBoxIntersects() {
39+
Table t =
40+
tableEnv.sqlQuery(
41+
"WITH boxes AS ("
42+
+ " SELECT ST_Box2D(ST_GeomFromWKT('POLYGON((0 0, 0 5, 5 5, 5 0, 0 0))')) AS a,"
43+
+ " ST_Box2D(ST_GeomFromWKT('POLYGON((3 3, 3 7, 7 7, 7 3, 3 3))')) AS overlap,"
44+
+ " ST_Box2D(ST_GeomFromWKT('POLYGON((6 6, 6 7, 7 7, 7 6, 6 6))')) AS disjoint)"
45+
+ " SELECT ST_BoxIntersects(a, overlap), ST_BoxIntersects(a, disjoint) FROM boxes");
46+
org.apache.flink.types.Row row = first(t);
47+
assertEquals(true, row.getField(0));
48+
assertEquals(false, row.getField(1));
49+
}
50+
51+
@Test
52+
public void testBoxContains() {
53+
Table t =
54+
tableEnv.sqlQuery(
55+
"WITH boxes AS ("
56+
+ " SELECT ST_Box2D(ST_GeomFromWKT('POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))')) AS outer_box,"
57+
+ " ST_Box2D(ST_GeomFromWKT('POLYGON((2 2, 2 5, 5 5, 5 2, 2 2))')) AS inner_box,"
58+
+ " ST_Box2D(ST_GeomFromWKT('POLYGON((5 5, 5 11, 11 11, 11 5, 5 5))')) AS overlap)"
59+
+ " SELECT ST_BoxContains(outer_box, inner_box), ST_BoxContains(outer_box, overlap) FROM boxes");
60+
org.apache.flink.types.Row row = first(t);
61+
assertEquals(true, row.getField(0));
62+
assertEquals(false, row.getField(1));
63+
}
64+
3765
@Test
3866
public void testIntersects() {
3967
Table pointTable = createPointTable(testDataSize);

python/sedona/spark/sql/st_predicates.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,38 @@
3030
_call_predicate_function = partial(call_sedona_function, "st_predicates")
3131

3232

33+
@validate_argument_types
34+
def ST_BoxContains(a: ColumnOrName, b: ColumnOrName) -> Column:
35+
"""Check whether Box2D a fully contains Box2D b (closed intervals).
36+
37+
Mirrors PostGIS ``~`` on box2d. NULL on null input.
38+
39+
:param a: Outer Box2D column.
40+
:type a: ColumnOrName
41+
:param b: Inner Box2D column.
42+
:type b: ColumnOrName
43+
:return: True if a contains b, false otherwise.
44+
:rtype: Column
45+
"""
46+
return _call_predicate_function("ST_BoxContains", (a, b))
47+
48+
49+
@validate_argument_types
50+
def ST_BoxIntersects(a: ColumnOrName, b: ColumnOrName) -> Column:
51+
"""Check whether Box2D a and Box2D b share any point (closed intervals).
52+
53+
Mirrors PostGIS ``&&`` on box2d. NULL on null input.
54+
55+
:param a: First Box2D column.
56+
:type a: ColumnOrName
57+
:param b: Second Box2D column.
58+
:type b: ColumnOrName
59+
:return: True if a and b overlap, false otherwise.
60+
:rtype: Column
61+
"""
62+
return _call_predicate_function("ST_BoxIntersects", (a, b))
63+
64+
3365
@validate_argument_types
3466
def ST_Contains(a: ColumnOrName, b: ColumnOrName) -> Column:
3567
"""Check whether geometry a contains geometry b.

python/tests/sql/test_predicate.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@
2626

2727
class TestPredicate(TestBase):
2828

29+
def test_st_box_intersects_and_contains(self):
30+
df = self.spark.sql("""
31+
WITH t AS (
32+
SELECT
33+
ST_Box2D(ST_GeomFromText('POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))')) AS a,
34+
ST_Box2D(ST_GeomFromText('POLYGON((2 2, 2 5, 5 5, 5 2, 2 2))')) AS inside,
35+
ST_Box2D(ST_GeomFromText('POLYGON((5 5, 5 11, 11 11, 11 5, 5 5))')) AS overlap,
36+
ST_Box2D(ST_GeomFromText('POLYGON((11 11, 11 12, 12 12, 12 11, 11 11))')) AS disjoint
37+
)
38+
SELECT
39+
ST_BoxIntersects(a, inside) AS i_inside,
40+
ST_BoxIntersects(a, overlap) AS i_overlap,
41+
ST_BoxIntersects(a, disjoint) AS i_disjoint,
42+
ST_BoxContains(a, inside) AS c_inside,
43+
ST_BoxContains(a, overlap) AS c_overlap
44+
FROM t
45+
""")
46+
row = df.first()
47+
assert row[0] is True
48+
assert row[1] is True
49+
assert row[2] is False
50+
assert row[3] is True
51+
assert row[4] is False
52+
2953
def test_st_contains(self):
3054
point_csv_df = (
3155
self.spark.read.format("csv")

spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ object Catalog extends AbstractCatalog with Logging {
163163

164164
// Predicates
165165
val predicateExprs: Seq[FunctionDescription] = Seq(
166+
function[ST_BoxContains](),
167+
function[ST_BoxIntersects](),
166168
function[ST_Contains](),
167169
function[ST_CoveredBy](),
168170
function[ST_Covers](),

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,38 @@ private[apache] case class ST_Contains(inputExpressions: Seq[Expression])
8989
}
9090
}
9191

92+
/**
93+
* Closed-interval bbox intersection over two Box2D arguments. Returns true if the boxes overlap
94+
* on both the X and Y axes (matches PostGIS `&&` on box2d). Edge- and corner-touching boxes count
95+
* as intersecting. Throws on inverted bounds (xmin>xmax / ymin>ymax) since planar predicates have
96+
* no defined meaning for inverted intervals; that ordering is reserved for future
97+
* antimeridian-wraparound semantics.
98+
*
99+
* @param inputExpressions
100+
*/
101+
private[apache] case class ST_BoxIntersects(inputExpressions: Seq[Expression])
102+
extends InferredExpression(Predicates.boxIntersects _) {
103+
104+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
105+
copy(inputExpressions = newChildren)
106+
}
107+
}
108+
109+
/**
110+
* Closed-interval bbox containment over two Box2D arguments. Returns true if argument `a` fully
111+
* contains argument `b` on both axes (matches PostGIS `~` on box2d). Equal boxes contain each
112+
* other. Throws on inverted bounds for the same reason as ST_BoxIntersects.
113+
*
114+
* @param inputExpressions
115+
*/
116+
private[apache] case class ST_BoxContains(inputExpressions: Seq[Expression])
117+
extends InferredExpression(Predicates.boxContains _) {
118+
119+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
120+
copy(inputExpressions = newChildren)
121+
}
122+
}
123+
92124
/**
93125
* Test if leftGeometry full intersects rightGeometry. Supports both Geometry (JTS) and Geography
94126
* (S2) inputs via InferredExpression dual dispatch.

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import org.apache.spark.sql.Column
2424
import org.apache.spark.sql.sedona_sql.DataFrameShims._
2525

2626
object st_predicates {
27+
def ST_BoxContains(a: Column, b: Column): Column = wrapExpression[ST_BoxContains](a, b)
28+
def ST_BoxContains(a: String, b: String): Column = wrapExpression[ST_BoxContains](a, b)
29+
30+
def ST_BoxIntersects(a: Column, b: Column): Column = wrapExpression[ST_BoxIntersects](a, b)
31+
def ST_BoxIntersects(a: String, b: String): Column = wrapExpression[ST_BoxIntersects](a, b)
32+
2733
def ST_Contains(a: Column, b: Column): Column = wrapExpression[ST_Contains](a, b)
2834
def ST_Contains(a: String, b: String): Column = wrapExpression[ST_Contains](a, b)
2935

0 commit comments

Comments
 (0)