Skip to content

Commit 771480b

Browse files
committed
[GH-3031] Box3D spatial join: index ST_Intersects / ST_Contains on Box3D
Reuses the existing 2D R-tree pipeline by projecting each Box3D to its XY footprint at the join boundary. The Z axis is re-checked per surviving candidate by folding the original Box3D predicate back into `extraCondition`, so candidates that overlap in XY but not Z are filtered out by the per-pair refine step in TraitJoinQueryExec. - JoinQueryDetector: replaced the two Box3D short-circuits with planned `JoinQueryDetection`s. New `isBox3DPair` helper; the spatial predicate is `SpatialPredicate.INTERSECTS` for both arms (the R-tree's planar containment ignores Z anyway; the post-filter does the actual check). The original `ST_Intersects` / `ST_Contains` expression is ANDed back into `extraCondition` so the Z axis is rechecked per pair. - TraitJoinQueryBase.shapeToGeometry: added a Box3DUDT case that materialises the XY footprint as a JTS rectangle (Constructors. polygonFromEnvelope). Validates ordered bounds on all three axes so inverted-bound input throws IllegalArgumentException, matching the scalar Box3D predicate contract. Tests: - New Box3DJoinSuite mirrors Box2DJoinSuite. Test fixtures include a discriminating row (L4) with the same XY footprint as L1/L2 but a Z range far above every right side, so every candidate it produces via the 2D R-tree is killed by the Z refine. Covers broadcast index join, range join, argument-order symmetry, closed-interval edge touching, and inverted-bound throw. - Removed the prior "falls back to row-by-row" regression test from Box3DIntersectsContainsSuite — that contract no longer holds, the join is now indexed.
1 parent 4c7ddf6 commit 771480b

4 files changed

Lines changed: 235 additions & 57 deletions

File tree

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/strategy/join/JoinQueryDetector.scala

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,13 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
7777
left.dataType.isInstanceOf[Box2DUDT] && right.dataType.isInstanceOf[Box2DUDT]
7878

7979
/**
80-
* Either shape expression resolves to Box3DUDT. The join executors only know how to materialise
81-
* Geometry / Geography / Box2D values; Box3D inputs would fall into the generic shape path,
82-
* cast as a Geometry binary, and fail at runtime. Skip join planning so the predicate falls
83-
* back to row-by-row evaluation where the scalar Box3D overload of `ST_Intersects` /
84-
* `ST_Contains` handles it correctly.
80+
* Both shape expressions resolve to Box3DUDT. The join executor materialises each Box3D as the
81+
* XY footprint rectangle so the 2D partitioner / R-tree pipeline can prune candidate pairs on
82+
* X/Y overlap. The Z axis is re-checked per candidate via the scalar Box3D predicate carried in
83+
* `extraCondition`.
8584
*/
86-
private def hasBox3DInput(left: Expression, right: Expression): Boolean =
87-
left.dataType.isInstanceOf[Box3DUDT] || right.dataType.isInstanceOf[Box3DUDT]
85+
private def isBox3DPair(left: Expression, right: Expression): Boolean =
86+
left.dataType.isInstanceOf[Box3DUDT] && right.dataType.isInstanceOf[Box3DUDT]
8887

8988
/**
9089
* Build a JoinQueryDetection for an InferredExpression predicate (ST_Contains, ST_Within, ...)
@@ -280,16 +279,36 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
280279
SpatialPredicate.COVERS,
281280
isGeography = false,
282281
extraCondition))
283-
// Box3D inputs short-circuit out of join planning. The join executors only handle
284-
// Geometry / Geography / Box2D; treating a Box3D shape as a Geometry binary fails at
285-
// runtime. Returning None drops to row-by-row evaluation where the scalar Box3D
286-
// overload of ST_Intersects / ST_Contains handles the predicate correctly.
287-
case ST_Intersects(Seq(leftShape, rightShape))
288-
if hasBox3DInput(leftShape, rightShape) =>
289-
None
290-
case ST_Contains(Seq(leftShape, rightShape))
291-
if hasBox3DInput(leftShape, rightShape) =>
292-
None
282+
// Box3D-on-Box3D variants of ST_Intersects / ST_Contains. The R-tree pass uses the
283+
// XY footprint of each Box3D (materialised in TraitJoinQueryBase.shapeToGeometry),
284+
// which is a coarse-grained 2D filter. The Z axis is checked per surviving candidate
285+
// by folding the original predicate back into `extraCondition` — TraitJoinQueryExec
286+
// applies it to every joined row after the index probe, so candidate pairs that
287+
// overlap in XY but not Z are filtered out. SpatialPredicate.INTERSECTS is used for
288+
// both arms because the R-tree's planar containment ignores Z anyway; the post-filter
289+
// does the actual containment check.
290+
case spatial @ ST_Intersects(Seq(leftShape, rightShape))
291+
if isBox3DPair(leftShape, rightShape) =>
292+
Some(
293+
JoinQueryDetection(
294+
left,
295+
right,
296+
leftShape,
297+
rightShape,
298+
SpatialPredicate.INTERSECTS,
299+
isGeography = false,
300+
extraCondition = Some(extraCondition.map(And(_, spatial)).getOrElse(spatial))))
301+
case spatial @ ST_Contains(Seq(leftShape, rightShape))
302+
if isBox3DPair(leftShape, rightShape) =>
303+
Some(
304+
JoinQueryDetection(
305+
left,
306+
right,
307+
leftShape,
308+
rightShape,
309+
SpatialPredicate.INTERSECTS,
310+
isGeography = false,
311+
extraCondition = Some(extraCondition.map(And(_, spatial)).getOrElse(spatial))))
293312
// ST_Contains: when either operand is GeographyUDT we still detect the join here and
294313
// set `geographyShape = true`; planBroadcastJoin will route the work to the
295314
// Geography-aware index/refine path. Non-broadcast plans bail out in `apply` below

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/strategy/join/TraitJoinQueryBase.scala

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import org.apache.spark.rdd.RDD
2727
import org.apache.spark.sql.catalyst.InternalRow
2828
import org.apache.spark.sql.catalyst.expressions.{Expression, UnsafeRow}
2929
import org.apache.spark.sql.execution.SparkPlan
30-
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, RasterUDT}
30+
import org.apache.spark.sql.sedona_sql.UDT.{Box2DUDT, Box3DUDT, RasterUDT}
3131
import org.locationtech.jts.geom.{Geometry, GeometryFactory}
3232

3333
trait TraitJoinQueryBase {
@@ -246,8 +246,11 @@ object TraitJoinQueryBase {
246246

247247
/**
248248
* Materialise a shape column value as a JTS [[Geometry]]. Box2D-typed columns are turned into
249-
* the closed rectangular polygon implied by their `(xmin, ymin, xmax, ymax)` bounds; all other
250-
* shape columns are deserialised from the Sedona geometry binary form.
249+
* the closed rectangular polygon implied by their `(xmin, ymin, xmax, ymax)` bounds; Box3D
250+
* columns are projected to their XY footprint (`xmin, ymin, xmax, ymax`) and the Z axis is
251+
* re-checked per candidate pair by the scalar Box3D predicate that `JoinQueryDetector` folds
252+
* into the join's `extraCondition`. All other shape columns are deserialised from the Sedona
253+
* geometry binary form.
251254
*
252255
* Producing a JTS rectangle here lets the rest of the join machinery — partitioner, R-tree
253256
* `IndexBuilder`, refine evaluator — stay shape-agnostic. JTS already short-circuits
@@ -259,7 +262,8 @@ object TraitJoinQueryBase {
259262
* `IllegalArgumentException` raised by `Predicates.boxIntersects` / `boxContains`. Inverted
260263
* bounds have no defined planar meaning today (they are reserved for future
261264
* antimeridian-wraparound semantics on Geography bboxes) and would silently mis-prune the
262-
* R-tree if accepted here.
265+
* R-tree if accepted here. Box3D applies the same check on all three axes — Z has no wraparound
266+
* convention.
263267
*
264268
* Returns `null` when the shape column evaluates to NULL; the caller is expected to either skip
265269
* the row or substitute an empty geometry.
@@ -283,6 +287,22 @@ object TraitJoinQueryBase {
283287
"reserved for future antimeridian wraparound semantics.")
284288
}
285289
Constructors.polygonFromEnvelope(xmin, ymin, xmax, ymax)
290+
case _: Box3DUDT =>
291+
val box = evaluated.asInstanceOf[InternalRow]
292+
val xmin = box.getDouble(0)
293+
val ymin = box.getDouble(1)
294+
val zmin = box.getDouble(2)
295+
val xmax = box.getDouble(3)
296+
val ymax = box.getDouble(4)
297+
val zmax = box.getDouble(5)
298+
if (xmin > xmax || ymin > ymax || zmin > zmax) {
299+
throw new IllegalArgumentException(
300+
"Box3D join input has inverted bounds (xmin > xmax, ymin > ymax, or " +
301+
"zmin > zmax). Box3D predicates require ordered intervals on all three axes.")
302+
}
303+
// XY footprint only — the Z axis is rechecked per candidate by the scalar Box3D
304+
// predicate folded into `extraCondition`.
305+
Constructors.polygonFromEnvelope(xmin, ymin, xmax, ymax)
286306
case _ =>
287307
GeometrySerializer.deserialize(evaluated.asInstanceOf[Array[Byte]])
288308
}

spark/common/src/test/scala/org/apache/sedona/sql/Box3DIntersectsContainsSuite.scala

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -103,41 +103,5 @@ class Box3DIntersectsContainsSuite extends TestBaseScala {
103103
assert(row.isNullAt(1))
104104
}
105105

106-
// Regression for the JoinQueryDetector / TraitJoinQueryBase Box3D mishandling: prior to the
107-
// hasBox3DInput guard, a Box3D-on-Box3D join would fall into the generic shape path and try
108-
// to deserialise the Box3D struct as a Geometry binary, throwing at runtime. With the guard
109-
// in place, the join silently falls back to row-by-row evaluation and the scalar Box3D
110-
// overload of ST_Intersects / ST_Contains produces the correct boolean.
111-
it("Box3D-on-Box3D join falls back to row-by-row evaluation without a cast error") {
112-
import sparkSession.implicits._
113-
val left = Seq(("a1", 0.0, 0.0, 0.0, 5.0, 5.0, 5.0)).toDF(
114-
"id",
115-
"xmin",
116-
"ymin",
117-
"zmin",
118-
"xmax",
119-
"ymax",
120-
"zmax")
121-
val right = Seq(("b1", 1.0, 1.0, 1.0, 2.0, 2.0, 2.0)).toDF(
122-
"id",
123-
"xmin",
124-
"ymin",
125-
"zmin",
126-
"xmax",
127-
"ymax",
128-
"zmax")
129-
left.createOrReplaceTempView("left_box3d")
130-
right.createOrReplaceTempView("right_box3d")
131-
val df = sparkSession.sql("""
132-
SELECT L.id AS l_id, R.id AS r_id
133-
FROM left_box3d L
134-
JOIN right_box3d R ON ST_Intersects(
135-
ST_3DMakeBox(ST_PointZ(L.xmin, L.ymin, L.zmin), ST_PointZ(L.xmax, L.ymax, L.zmax)),
136-
ST_3DMakeBox(ST_PointZ(R.xmin, R.ymin, R.zmin), ST_PointZ(R.xmax, R.ymax, R.zmax)))
137-
""")
138-
val rows = df.collect()
139-
assert(rows.length == 1)
140-
assert(rows(0).getString(0) == "a1" && rows(0).getString(1) == "b1")
141-
}
142106
}
143107
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.sedona.sql
20+
21+
import org.apache.spark.sql.DataFrame
22+
import org.apache.spark.sql.functions.{broadcast, expr}
23+
import org.apache.spark.sql.sedona_sql.strategy.join.{BroadcastIndexJoinExec, RangeJoinExec}
24+
25+
class Box3DJoinSuite extends TestBaseScala {
26+
27+
import Box3DJoinSuite.TestBox3D
28+
29+
/**
30+
* Left and right boxes wired so result counts are predictable across the XY filter and the
31+
* Z-axis refine step. L4 is the discriminating row: same XY footprint as L1/L2 so the 2D R-tree
32+
* pairs it with every right in the (0..10) XY cluster, but its Z range is far above every
33+
* right's Z so the refine must reject all those candidates.
34+
*
35+
* - L1=(0..10, 0..10, 0..10), L2=same — dense cluster on Z=(0..10).
36+
* - L3=(20..30, 20..30, 0..10) — XY-disjoint outlier.
37+
* - L4=(0..10, 0..10, 50..60) — XY in the cluster, Z far above. Every L4-R* candidate the
38+
* R-tree emits has to be killed by the Z refine.
39+
*
40+
* - R1=(5..15, 5..15, 5..15), R2=(2..8, 2..8, 2..8) — overlap L1/L2 in XY *and* Z.
41+
* - R3=(40..50, 40..50, 0..10) — XY-disjoint.
42+
* - R4=(2..8, 2..8, 100..200) — XY in the cluster, Z separated from every left.
43+
*
44+
* True intersection pairs: 4 — (L1,R1), (L1,R2), (L2,R1), (L2,R2). Containment pairs
45+
* (closed-interval, all three axes): 2 — (L1,R2), (L2,R2). R1 extends past L1/L2 on all three
46+
* axes; R4 and L4 are Z-disjoint from anything they could contain or be contained by.
47+
*/
48+
private def leftBoxes: DataFrame = {
49+
import sparkSession.implicits._
50+
Seq(
51+
TestBox3D(1, 0, 0, 0, 10, 10, 10),
52+
TestBox3D(2, 0, 0, 0, 10, 10, 10),
53+
TestBox3D(3, 20, 20, 0, 30, 30, 10),
54+
TestBox3D(4, 0, 0, 50, 10, 10, 60))
55+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
56+
.selectExpr(
57+
"id",
58+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
59+
}
60+
61+
private def rightBoxes: DataFrame = {
62+
import sparkSession.implicits._
63+
Seq(
64+
TestBox3D(11, 5, 5, 5, 15, 15, 15),
65+
TestBox3D(12, 2, 2, 2, 8, 8, 8),
66+
TestBox3D(13, 40, 40, 0, 50, 50, 10),
67+
TestBox3D(14, 2, 2, 100, 8, 8, 200))
68+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
69+
.selectExpr(
70+
"id",
71+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
72+
}
73+
74+
describe("Box3D spatial join") {
75+
76+
it("ST_Intersects: broadcast index join filters Z-disjoint candidates") {
77+
val joined = leftBoxes
78+
.alias("L")
79+
.join(broadcast(rightBoxes.alias("R")), expr("ST_Intersects(L.box, R.box)"))
80+
81+
assert(joined.queryExecution.executedPlan.collectFirst { case _: BroadcastIndexJoinExec =>
82+
true
83+
}.isDefined)
84+
// 4 true intersections — (L4, R4) is excluded by the Z refine even though XY overlaps.
85+
assert(joined.count() == 4)
86+
}
87+
88+
it("ST_Intersects: argument order is symmetric") {
89+
val joined = leftBoxes
90+
.alias("L")
91+
.join(broadcast(rightBoxes.alias("R")), expr("ST_Intersects(R.box, L.box)"))
92+
assert(joined.count() == 4)
93+
}
94+
95+
it("ST_Contains: broadcast index join uses Box3D containment semantics") {
96+
val joined = leftBoxes
97+
.alias("L")
98+
.join(broadcast(rightBoxes.alias("R")), expr("ST_Contains(L.box, R.box)"))
99+
100+
assert(joined.queryExecution.executedPlan.collectFirst { case _: BroadcastIndexJoinExec =>
101+
true
102+
}.isDefined)
103+
// Only R2 is fully inside L1 / L2 on all three axes. R1 sticks out in X/Y/Z; R4 is
104+
// disjoint in Z.
105+
assert(joined.count() == 2)
106+
}
107+
108+
it("ST_Contains: edge-touching boxes count (closed-interval semantics)") {
109+
import sparkSession.implicits._
110+
val outer = Seq(TestBox3D(1, 0, 0, 0, 10, 10, 10))
111+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
112+
.selectExpr(
113+
"id",
114+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
115+
// Inner shares all three faces with the outer on the high side.
116+
val inner = Seq(TestBox3D(2, 5, 5, 5, 10, 10, 10))
117+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
118+
.selectExpr(
119+
"id",
120+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
121+
val joined = outer
122+
.alias("O")
123+
.join(broadcast(inner.alias("I")), expr("ST_Contains(O.box, I.box)"))
124+
assert(joined.count() == 1)
125+
}
126+
127+
it("ST_Intersects: non-broadcast range join produces the same count") {
128+
val joined = leftBoxes
129+
.alias("L")
130+
.join(rightBoxes.alias("R"), expr("ST_Intersects(L.box, R.box)"))
131+
132+
assert(joined.queryExecution.executedPlan.collectFirst { case _: RangeJoinExec =>
133+
true
134+
}.isDefined)
135+
assert(joined.count() == 4)
136+
}
137+
138+
it("Inverted Box3D bounds in a join throw IllegalArgumentException") {
139+
import sparkSession.implicits._
140+
val ordered = Seq(TestBox3D(1, 0, 0, 0, 10, 10, 10))
141+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
142+
.selectExpr(
143+
"id",
144+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
145+
// Z inverted on the right side.
146+
val invertedZ = Seq(TestBox3D(2, 0, 0, 10, 10, 10, 0))
147+
.toDF("id", "xmin", "ymin", "zmin", "xmax", "ymax", "zmax")
148+
.selectExpr(
149+
"id",
150+
"ST_3DMakeBox(ST_PointZ(xmin, ymin, zmin), ST_PointZ(xmax, ymax, zmax)) AS box")
151+
val ex = intercept[Exception] {
152+
ordered
153+
.alias("L")
154+
.join(broadcast(invertedZ.alias("R")), expr("ST_Intersects(L.box, R.box)"))
155+
.collect()
156+
}
157+
assert(
158+
Iterator
159+
.iterate(ex: Throwable)(_.getCause)
160+
.takeWhile(_ != null)
161+
.exists(_.isInstanceOf[IllegalArgumentException]))
162+
}
163+
}
164+
}
165+
166+
object Box3DJoinSuite {
167+
case class TestBox3D(
168+
id: Int,
169+
xmin: Double,
170+
ymin: Double,
171+
zmin: Double,
172+
xmax: Double,
173+
ymax: Double,
174+
zmax: Double)
175+
}

0 commit comments

Comments
 (0)