Skip to content

Commit 0b4da5d

Browse files
authored
[GH-2830] Adds ST_Buffer for the Geography type via dual-dispatch (#2865)
1 parent 3fd82e9 commit 0b4da5d

9 files changed

Lines changed: 404 additions & 1 deletion

File tree

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,53 @@ public static String asEWKT(Geography geography) {
199199
return geography.toEWKT();
200200
}
201201

202+
// ─── Level 4: spherical buffer ───────────────────────────────────────────
203+
204+
/**
205+
* Returns a Geography that represents the metric ε-buffer of {@code g} on the sphere, where
206+
* {@code radiusMeters} is interpreted as meters along the spheroid. Implementation reuses the
207+
* existing geometry-side spheroidal buffer (UTM project → JTS planar buffer → unproject), which
208+
* gives accurate sub-UTM-zone results; for very large geographies the UTM round-trip's accuracy
209+
* caveats apply (see ST_Buffer's docs).
210+
*/
211+
public static Geography buffer(Geography g, double radiusMeters) {
212+
return buffer(g, radiusMeters, "");
213+
}
214+
215+
/**
216+
* Geography is inherently spheroidal, so the {@code useSpheroid} flag (only meaningful for the
217+
* planar Geometry version of ST_Buffer) is rejected for Geography inputs. This overload exists to
218+
* give a clear, actionable error when callers try to pass it; without it the resolver would
219+
* coerce the boolean to a string and fail later inside the buffer-parameters parser with a
220+
* confusing message.
221+
*/
222+
public static Geography buffer(Geography g, double radiusMeters, boolean useSpheroid) {
223+
throw new IllegalArgumentException(
224+
"ST_Buffer does not accept a useSpheroid argument for Geography inputs (Geography is "
225+
+ "always spheroidal). Use ST_Buffer(geog, distance) or "
226+
+ "ST_Buffer(geog, distance, parameters) instead.");
227+
}
228+
229+
/**
230+
* Same as {@link #buffer(Geography, double)} but allows a JTS-style buffer parameters string
231+
* ({@code "quad_segs=8 endcap=round join=round mitre_limit=5.0 side=both"}). The string is parsed
232+
* by the existing geometry-side parser.
233+
*/
234+
public static Geography buffer(Geography g, double radiusMeters, String parameters) {
235+
if (g == null) return null;
236+
Geometry jts = toJTS(g);
237+
if (jts == null) return null;
238+
int srid = g.getSRID();
239+
// Geography is always lon/lat; default to WGS84 when the source has no SRID set.
240+
jts.setSRID(srid != 0 ? srid : 4326);
241+
Geometry buffered =
242+
org.apache.sedona.common.Functions.buffer(jts, radiusMeters, true, parameters);
243+
if (buffered == null) return null;
244+
Geography result = Constructors.geomToGeography(buffered);
245+
result.setSRID(srid);
246+
return result;
247+
}
248+
202249
// ─── Helpers ───────────────────────────────────────────────────────────────
203250

204251
private static Geometry toJTS(Geography g) {

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,4 +332,66 @@ public void contains_nullHandling() throws ParseException {
332332
assertFalse(Functions.contains(g1, null));
333333
assertFalse(Functions.contains(null, g1));
334334
}
335+
336+
// ─── Level 4: ST_Buffer ──────────────────────────────────────────────────
337+
338+
@Test
339+
public void buffer_nullInputReturnsNull() throws ParseException {
340+
assertNull(Functions.buffer(null, 100.0));
341+
assertNull(Functions.buffer(null, 100.0, "quad_segs=4"));
342+
}
343+
344+
@Test
345+
public void buffer_pointProducesEnclosingPolygon() throws ParseException {
346+
Geography origin = Constructors.geogFromWKT("POINT (0 0)", 4326);
347+
Geography buffered = Functions.buffer(origin, 1000.0); // 1 km on the sphere
348+
assertNotNull(buffered);
349+
assertEquals("ST_Polygon", Functions.geometryType(buffered));
350+
// A point ~785 m NE of origin should fall inside the 1 km buffer.
351+
Geography near = Constructors.geogFromWKT("POINT (0.005 0.005)", 4326);
352+
assertTrue(Functions.contains(buffered, near));
353+
// A point ~1.57 km NE should fall outside.
354+
Geography far = Constructors.geogFromWKT("POINT (0.01 0.01)", 4326);
355+
assertFalse(Functions.contains(buffered, far));
356+
}
357+
358+
@Test
359+
public void buffer_polygonContainsOriginalInterior() throws ParseException {
360+
Geography poly =
361+
Constructors.geogFromWKT("POLYGON ((0 0, 0.01 0, 0.01 0.01, 0 0.01, 0 0))", 4326);
362+
Geography buffered = Functions.buffer(poly, 200.0);
363+
assertNotNull(buffered);
364+
Geography inside = Constructors.geogFromWKT("POINT (0.005 0.005)", 4326);
365+
assertTrue("buffered polygon must contain its centroid", Functions.contains(buffered, inside));
366+
// A point 500 m beyond the original polygon's edge but inside the 200 m band would still
367+
// be outside; pick a point far enough that the buffer cannot reach it.
368+
Geography farOutside = Constructors.geogFromWKT("POINT (1 1)", 4326);
369+
assertFalse(Functions.contains(buffered, farOutside));
370+
}
371+
372+
@Test
373+
public void buffer_parametersStringHonored() throws ParseException {
374+
// quad_segs=2 produces a low-fidelity buffer (octagon for a point); quad_segs=64
375+
// produces a much smoother boundary. Vertex counts should differ accordingly.
376+
Geography origin = Constructors.geogFromWKT("POINT (0 0)", 4326);
377+
Geography coarse = Functions.buffer(origin, 1000.0, "quad_segs=2");
378+
Geography fine = Functions.buffer(origin, 1000.0, "quad_segs=64");
379+
assertNotNull(coarse);
380+
assertNotNull(fine);
381+
assertTrue(
382+
"fine buffer should have more vertices than coarse",
383+
Functions.nPoints(fine) > Functions.nPoints(coarse));
384+
}
385+
386+
@Test
387+
public void buffer_negativeRadiusShrinksPolygon() throws ParseException {
388+
Geography poly =
389+
Constructors.geogFromWKT("POLYGON ((0 0, 0.01 0, 0.01 0.01, 0 0.01, 0 0))", 4326);
390+
Geography shrunk = Functions.buffer(poly, -100.0);
391+
assertNotNull(shrunk);
392+
// Shrunk polygon is either smaller or empty; the original boundary point should now
393+
// be outside (or contains() returns false on an empty geometry, which is also acceptable).
394+
Geography boundary = Constructors.geogFromWKT("POINT (0 0)", 4326);
395+
assertFalse(Functions.contains(shrunk, boundary));
396+
}
335397
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ These functions operate on geography type objects.
4444
| [ST_Area](Geography-Functions/ST_Area.md) | Double | Return the geodesic area of a geography in square meters (WGS84 spheroid). | v1.9.1 |
4545
| [ST_AsEWKT](Geography-Functions/ST_AsEWKT.md) | String | Return the Extended Well-Known Text representation of a geography. | v1.8.0 |
4646
| [ST_AsText](Geography-Functions/ST_AsText.md) | String | Return the Well-Known Text (WKT) representation of a geography. | v1.9.1 |
47+
| [ST_Buffer](Geography-Functions/ST_Buffer.md) | Geography | Return the metric ε-buffer of a geography. Distance is always interpreted as meters along the spheroid. | v1.9.1 |
4748
| [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 |
4849
| [ST_GeometryType](Geography-Functions/ST_GeometryType.md) | String | Return the type of a geography as a string (e.g., "ST_Point", "ST_Polygon"). | v1.9.1 |
4950
| [ST_NPoints](Geography-Functions/ST_NPoints.md) | Integer | Return the number of points (vertices) in a geography. | v1.9.0 |
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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_Buffer
21+
22+
Introduction: Returns a `Geography` whose interior is the metric ε-buffer of the input on the sphere. The `distance` argument is always interpreted as **meters** along the spheroid — there is no `useSpheroid` flag because `Geography` is inherently spheroidal.
23+
24+
![ST_Buffer of a Geography point on the sphere](../../../../image/ST_Buffer_geography/ST_Buffer_geography_point.svg "ST_Buffer of a Geography point on the sphere")
25+
![ST_Buffer of a Geography polygon on the sphere](../../../../image/ST_Buffer_geography/ST_Buffer_geography_polygon.svg "ST_Buffer of a Geography polygon on the sphere")
26+
27+
Internally, Sedona projects the input to the most appropriate UTM zone (selected via `ST_BestSRID`), applies a planar buffer in that zone, and projects the result back to lon/lat. This produces accurate results for inputs that fit inside a single UTM zone (~6° wide). For larger inputs the same accuracy caveats apply as for `ST_Buffer` on `Geometry` with `useSpheroid = true`.
28+
29+
Format:
30+
31+
`ST_Buffer (geog: Geography, distanceMeters: Double)`
32+
33+
`ST_Buffer (geog: Geography, distanceMeters: Double, parameters: String)`
34+
35+
Return type: `Geography`
36+
37+
Since: `v1.9.1`
38+
39+
!!! note "`useSpheroid` is not accepted for Geography inputs"
40+
The 3-argument form `ST_Buffer(geom, distance, useSpheroid: Boolean)` from the `Geometry`
41+
overload is **rejected** when the first argument is a `Geography`. Geography is inherently
42+
spheroidal, so the flag would be either redundant (`useSpheroid = true`) or contradictory
43+
(`useSpheroid = false`). A call like
44+
45+
```sql
46+
SELECT ST_Buffer(ST_GeogFromWKT('POINT(0 0)', 4326), 1000.0, true); -- ❌ throws
47+
```
48+
49+
raises:
50+
51+
```
52+
IllegalArgumentException: ST_Buffer does not accept a useSpheroid argument for
53+
Geography inputs (Geography is always spheroidal). Use ST_Buffer(geog, distance) or
54+
ST_Buffer(geog, distance, parameters) instead.
55+
```
56+
57+
Drop the `useSpheroid` argument, or — if you really want a planar buffer — convert to
58+
Geometry first via `ST_GeogToGeometry` and use the `Geometry` overload of `ST_Buffer`.
59+
60+
The optional `parameters` string accepts the same JTS-style key/value pairs used by `ST_Buffer` for `Geometry`:
61+
62+
| Key | Default | Allowed values |
63+
| :--- | :--- | :--- |
64+
| `quad_segs` | `8` | positive integer — segments per quadrant in curved corners |
65+
| `endcap` | `round` | `round`, `flat`, `butt`, `square` |
66+
| `join` | `round` | `round`, `mitre` (or `miter`), `bevel` |
67+
| `mitre_limit` (or `miter_limit`) | `5.0` | positive decimal |
68+
| `side` | `both` | `both`, `left`, `right` (single-sided buffer for linestrings) |
69+
70+
Notes:
71+
72+
- A negative `distanceMeters` shrinks polygons (returning a smaller polygon, possibly `EMPTY` when the radius exceeds the polygon's narrowest dimension); for points and lines a negative buffer always produces an empty result.
73+
- The output `Geography` preserves the input's SRID. If the input has no SRID set, the result is normalised to WGS84 (EPSG:4326).
74+
75+
SQL Example
76+
77+
```sql
78+
-- 1 km buffer around a point
79+
SELECT ST_AsText(ST_Buffer(ST_GeogFromWKT('POINT(0 0)', 4326), 1000));
80+
81+
-- 200 m buffer around a polygon, with low-fidelity corners
82+
SELECT ST_AsText(ST_Buffer(
83+
ST_GeogFromWKT('POLYGON((0 0, 0.01 0, 0.01 0.01, 0 0.01, 0 0))', 4326),
84+
200,
85+
'quad_segs=4 endcap=square'
86+
));
87+
88+
-- Use the buffer as a containment test
89+
SELECT ST_Contains(
90+
ST_Buffer(ST_GeogFromWKT('POINT(0 0)', 4326), 1000),
91+
ST_GeogFromWKT('POINT(0.005 0.005)', 4326)
92+
);
93+
```
Lines changed: 45 additions & 0 deletions
Loading
Lines changed: 54 additions & 0 deletions
Loading

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ object FunctionResolver {
8686
// All arguments are NullType literals; every overload returns null, so the choice
8787
// between equally-good matches is semantically irrelevant. Prefer the first candidate.
8888
ambiguousMatches.head._1
89+
} else if (expressions.headOption.exists(_.dataType == NullType)) {
90+
// The first argument (the geometry/geography input) is a NullType literal. All
91+
// ST_* overloads return null on null input, so the dispatch choice is again
92+
// semantically irrelevant — prefer the first registered candidate. This keeps
93+
// calls like `ST_Buffer(null, 0)` working after Geography overloads were added.
94+
ambiguousMatches.head._1
8995
} else {
9096
val ambiguousTypesMsg = ambiguousMatches
9197
.map { case (function, _) =>

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,17 @@ private[apache] case class ST_Buffer(inputExpressions: Seq[Expression])
192192
extends InferredExpression(
193193
inferrableFunction2(Functions.buffer),
194194
inferrableFunction3(Functions.buffer),
195-
inferrableFunction4(Functions.buffer)) {
195+
inferrableFunction4(Functions.buffer),
196+
inferrableFunction2(org.apache.sedona.common.geography.Functions.buffer),
197+
// Explicit type ascription disambiguates the two 3-arg Geography buffer overloads
198+
// (`(Geography, double, String)` for the JTS-style parameters string, and
199+
// `(Geography, double, boolean)` which throws a clear error if `useSpheroid` is passed).
200+
inferrableFunction3(
201+
org.apache.sedona.common.geography.Functions
202+
.buffer(_: org.apache.sedona.common.S2Geography.Geography, _: Double, _: String)),
203+
inferrableFunction3(
204+
org.apache.sedona.common.geography.Functions
205+
.buffer(_: org.apache.sedona.common.S2Geography.Geography, _: Double, _: Boolean))) {
196206

197207
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
198208
copy(inputExpressions = newChildren)

0 commit comments

Comments
 (0)