Skip to content

Commit e0a807f

Browse files
authored
[GH-2830] Adds Geography dual-dispatch to ST_Intersects (#2856)
1 parent 772e3d5 commit e0a807f

10 files changed

Lines changed: 333 additions & 24 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ public boolean S2_intersects(
2929
return S2BooleanOperation.intersects(geo1.shapeIndex, geo2.shapeIndex, options);
3030
}
3131

32+
/**
33+
* Fast intersects between a single point and a ShapeIndex. Avoids building a ShapeIndex for the
34+
* point side — only the complex geometry needs an index. Uses S2ClosestEdgeQuery with
35+
* includeInteriors=true (default) so a point in a polygon interior returns distance 0.
36+
*/
37+
public static boolean S2_intersectsPointWithIndex(S2Point point, ShapeIndexGeography geo) {
38+
S2ClosestEdgeQuery query = S2ClosestEdgeQuery.builder().build(geo.shapeIndex);
39+
S2ClosestEdgeQuery.PointTarget<S1ChordAngle> target =
40+
new S2ClosestEdgeQuery.PointTarget<>(point);
41+
Optional<S2BestEdgesQueryBase.Result> result = query.findClosestEdge(target);
42+
if (!result.isPresent()) {
43+
return false;
44+
}
45+
return ((S1ChordAngle) result.get().distance()).getLength2() == 0.0;
46+
}
47+
3248
public boolean S2_equals(
3349
ShapeIndexGeography geo1, ShapeIndexGeography geo2, S2BooleanOperation.Options options) {
3450
return S2BooleanOperation.equals(geo1.shapeIndex, geo2.shapeIndex, options);

common/src/main/java/org/apache/sedona/common/geography/Functions.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,33 @@ public static boolean equals(Geography g1, Geography g2) {
312312
return pred.S2_equals(toShapeIndex(g1), toShapeIndex(g2), s2Options());
313313
}
314314

315+
/**
316+
* Spherical intersection test using S2 boolean operations. Takes fast paths for point-to-point
317+
* and point-to-complex inputs backed by WKBGeography, avoiding ShapeIndex construction on the
318+
* point side.
319+
*/
320+
public static boolean intersects(Geography g1, Geography g2) {
321+
if (g1 == null || g2 == null) return false;
322+
if (g1 instanceof WKBGeography && g2 instanceof WKBGeography) {
323+
WKBGeography w1 = (WKBGeography) g1;
324+
WKBGeography w2 = (WKBGeography) g2;
325+
// Fast path: point-to-point intersects iff the points are equal
326+
if (w1.isPoint() && w2.isPoint()) {
327+
return w1.extractPoint().equalsPoint(w2.extractPoint());
328+
}
329+
// Fast path: point-to-complex uses PointTarget (avoids building ShapeIndex for point side)
330+
if (w1.isPoint()) {
331+
return Predicates.S2_intersectsPointWithIndex(w1.extractPoint(), toShapeIndex(w2));
332+
}
333+
if (w2.isPoint()) {
334+
return Predicates.S2_intersectsPointWithIndex(w2.extractPoint(), toShapeIndex(w1));
335+
}
336+
}
337+
// General path via ShapeIndex
338+
Predicates pred = new Predicates();
339+
return pred.S2_intersects(toShapeIndex(g1), toShapeIndex(g2), s2Options());
340+
}
341+
315342
/**
316343
* Spherical "distance within" test. Returns true iff the minimum geodesic distance between g1 and
317344
* g2 (in meters) is less than or equal to {@code distanceMeters}.

common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,67 @@ public void equals_nullHandling() throws ParseException {
489489
assertFalse(Functions.equals(null, null));
490490
}
491491

492+
@Test
493+
public void intersects_overlappingPolygons() throws ParseException {
494+
Geography g1 = Constructors.geogFromWKT("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", 4326);
495+
Geography g2 = Constructors.geogFromWKT("POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))", 4326);
496+
assertTrue(Functions.intersects(g1, g2));
497+
}
498+
499+
@Test
500+
public void intersects_disjointPolygons() throws ParseException {
501+
Geography g1 = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
502+
Geography g2 = Constructors.geogFromWKT("POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))", 4326);
503+
assertFalse(Functions.intersects(g1, g2));
504+
}
505+
506+
@Test
507+
public void intersects_pointInPolygon() throws ParseException {
508+
Geography g1 = Constructors.geogFromWKT("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", 4326);
509+
Geography g2 = Constructors.geogFromWKT("POINT (1 1)", 4326);
510+
assertTrue(Functions.intersects(g1, g2));
511+
}
512+
513+
@Test
514+
public void intersects_pointToPoint_samePoint() throws ParseException {
515+
// Exercises the point-to-point fast path (no ShapeIndex built on either side)
516+
Geography g1 = Constructors.geogFromWKT("POINT (1 2)", 4326);
517+
Geography g2 = Constructors.geogFromWKT("POINT (1 2)", 4326);
518+
assertTrue(Functions.intersects(g1, g2));
519+
}
520+
521+
@Test
522+
public void intersects_pointToPoint_differentPoints() throws ParseException {
523+
Geography g1 = Constructors.geogFromWKT("POINT (1 2)", 4326);
524+
Geography g2 = Constructors.geogFromWKT("POINT (3 4)", 4326);
525+
assertFalse(Functions.intersects(g1, g2));
526+
}
527+
528+
@Test
529+
public void intersects_pointOnLinestring() throws ParseException {
530+
// Exercises the point-to-complex fast path
531+
Geography line = Constructors.geogFromWKT("LINESTRING (0 0, 2 0)", 4326);
532+
Geography pt = Constructors.geogFromWKT("POINT (1 0)", 4326);
533+
assertTrue(Functions.intersects(line, pt));
534+
assertTrue(Functions.intersects(pt, line));
535+
}
536+
537+
@Test
538+
public void intersects_pointOffLinestring() throws ParseException {
539+
Geography line = Constructors.geogFromWKT("LINESTRING (0 0, 2 0)", 4326);
540+
Geography pt = Constructors.geogFromWKT("POINT (5 5)", 4326);
541+
assertFalse(Functions.intersects(line, pt));
542+
assertFalse(Functions.intersects(pt, line));
543+
}
544+
545+
@Test
546+
public void intersects_nullHandling() throws ParseException {
547+
Geography g = Constructors.geogFromWKT("POINT (1 1)", 4326);
548+
assertFalse(Functions.intersects(g, null));
549+
assertFalse(Functions.intersects(null, g));
550+
assertFalse(Functions.intersects(null, null));
551+
}
552+
492553
@Test
493554
public void contains_nullHandling() throws ParseException {
494555
Geography g1 = Constructors.geogFromWKT("POINT (1 1)", 4326);

docs/api/sql/geography/Geography-Functions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ These functions operate on geography type objects.
5353
| [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.0 |
5454
| [ST_Length](Geography-Functions/ST_Length.md) | Double | Return the spherical length of a geography in meters, summed along great-circle edges. | v1.9.1 |
5555
| [ST_Contains](Geography-Functions/ST_Contains.md) | Boolean | Test whether geography A fully contains geography B. | v1.9.0 |
56+
| [ST_Intersects](Geography-Functions/ST_Intersects.md) | Boolean | Test whether two geographies intersect. | v1.9.1 |
5657
| [ST_DWithin](Geography-Functions/ST_DWithin.md) | Boolean | Test whether two geographies are within a given geodesic distance (in meters) of each other. | v1.9.1 |
5758
| [ST_Within](Geography-Functions/ST_Within.md) | Boolean | Test whether geography A is fully within geography B. | v1.9.1 |
5859
| [ST_Equals](Geography-Functions/ST_Equals.md) | Boolean | Test whether two geographies are spatially equal. | v1.9.1 |
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
20+
# ST_Intersects
21+
22+
Introduction: Tests whether two geography objects intersect on the sphere using S2 spherical boolean operations. Returns `true` if `A` and `B` share any portion of space (including a single boundary point), and `false` if they are fully disjoint.
23+
24+
Edges are interpreted as great-circle arcs, so the test is correct even when geographies cross the antimeridian or wrap around the poles — situations where a planar `ST_Intersects` would be wrong.
25+
26+
![ST_Intersects returning true](../../../../image/ST_Intersects_geography/ST_Intersects_geography_true.svg "ST_Intersects returning true")
27+
![ST_Intersects returning false](../../../../image/ST_Intersects_geography/ST_Intersects_geography_false.svg "ST_Intersects returning false")
28+
29+
Format:
30+
31+
`ST_Intersects (A: Geography, B: Geography)`
32+
33+
Return type: `Boolean`
34+
35+
Since: `v1.9.1`
36+
37+
SQL Example — overlapping polygons:
38+
39+
```sql
40+
SELECT ST_Intersects(
41+
ST_GeogFromWKT('POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))', 4326),
42+
ST_GeogFromWKT('POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))', 4326)
43+
);
44+
```
45+
46+
Output:
47+
48+
```
49+
true
50+
```
51+
52+
SQL Example — disjoint polygons:
53+
54+
```sql
55+
SELECT ST_Intersects(
56+
ST_GeogFromWKT('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))', 4326),
57+
ST_GeogFromWKT('POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))', 4326)
58+
);
59+
```
60+
61+
Output:
62+
63+
```
64+
false
65+
```
Lines changed: 54 additions & 0 deletions
Loading
Lines changed: 66 additions & 0 deletions
Loading

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,9 @@ private[apache] case class ST_Contains(inputExpressions: Seq[Expression])
9696
* @param inputExpressions
9797
*/
9898
private[apache] case class ST_Intersects(inputExpressions: Seq[Expression])
99-
extends ST_Predicate
100-
with CodegenFallback {
101-
102-
override def evalGeom(leftGeometry: Geometry, rightGeometry: Geometry): Boolean = {
103-
Predicates.intersects(leftGeometry, rightGeometry)
104-
}
99+
extends InferredExpression(
100+
inferrableFunction2(Predicates.intersects),
101+
inferrableFunction2(org.apache.sedona.common.geography.Functions.intersects)) {
105102

106103
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
107104
copy(inputExpressions = newChildren)

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

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
6161
// * ST_Contains — broadcast joins route GeographyUDT inputs through a dedicated index/refine
6262
// path (see SpatialIndexExec.geographyShape / BroadcastIndexJoinExec.geographyShape). The
6363
// partition/range path still falls back to row-by-row evaluation.
64-
// * ST_Within / ST_Equals — no broadcast index path yet (the Geography refiner is
65-
// ST_Contains-specific), so we gate Geography inputs at the matcher (via
64+
// * ST_Intersects / ST_Within / ST_Equals — no broadcast index path yet (the Geography
65+
// refiner is ST_Contains-specific), so we gate Geography inputs at the matcher (via
6666
// `inferredJoinDetection`) and let Spark evaluate the predicate row-by-row.
6767
// Other ST_Predicates reject Geography inputs at analysis time, so no guard is needed there.
6868
private def isGeographyInput(shape: Expression): Boolean =
@@ -98,16 +98,6 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
9898
predicate: ST_Predicate,
9999
extraCondition: Option[Expression] = None): Option[JoinQueryDetection] = {
100100
predicate match {
101-
case ST_Intersects(Seq(leftShape, rightShape)) =>
102-
Some(
103-
JoinQueryDetection(
104-
left,
105-
right,
106-
leftShape,
107-
rightShape,
108-
SpatialPredicate.INTERSECTS,
109-
false,
110-
extraCondition))
111101
case ST_Covers(Seq(leftShape, rightShape)) =>
112102
Some(
113103
JoinQueryDetection(
@@ -217,9 +207,9 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
217207
val queryDetection: Option[JoinQueryDetection] = condition.flatMap {
218208
case joinConditionMatcher(predicate, extraCondition) =>
219209
predicate match {
220-
// ST_Contains / ST_Equals / ST_Within are InferredExpression (not ST_Predicate) so
221-
// they can't sit inside getJoinDetection; they're also the only predicates currently
222-
// accepting Geography inputs.
210+
// ST_Contains / ST_Intersects / ST_Within / ST_Equals are InferredExpression (not
211+
// ST_Predicate) so they can't sit inside getJoinDetection; they're also the only
212+
// predicates currently accepting Geography inputs.
223213
//
224214
// ST_Contains: when either operand is GeographyUDT we still detect the join here and
225215
// set `geographyShape = true`; planBroadcastJoin will route the work to the
@@ -238,9 +228,17 @@ class JoinQueryDetector(sparkSession: SparkSession) extends SparkStrategy {
238228
isGeography = false,
239229
extraCondition,
240230
geographyShape = geographyShape))
241-
// ST_Within / ST_Equals on Geography have no broadcast index path yet (the Geography
242-
// refiner is ST_Contains-specific), so gate Geography inputs and let them fall back
243-
// to row-by-row evaluation.
231+
// ST_Intersects / ST_Within / ST_Equals on Geography have no broadcast index path
232+
// yet (the Geography refiner is ST_Contains-specific), so gate Geography inputs and
233+
// let them fall back to row-by-row evaluation.
234+
case ST_Intersects(Seq(leftShape, rightShape)) =>
235+
inferredJoinDetection(
236+
left,
237+
right,
238+
leftShape,
239+
rightShape,
240+
SpatialPredicate.INTERSECTS,
241+
extraCondition)
244242
case ST_Within(Seq(leftShape, rightShape)) =>
245243
inferredJoinDetection(
246244
left,

0 commit comments

Comments
 (0)