Skip to content

Commit b1aaed5

Browse files
authored
[GH-2885] Add ST_GeomFromBox2D and ST_AsText(box2d) (#2899)
1 parent 2ed3c64 commit b1aaed5

9 files changed

Lines changed: 142 additions & 2 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,39 @@ public static Geometry makeEnvelope(double minX, double minY, double maxX, doubl
289289
return makeEnvelope(minX, minY, maxX, maxY, 0);
290290
}
291291

292+
/**
293+
* Convert a {@link Box2D} to a Geometry. Mirrors PostGIS {@code box2d::geometry}: dispatches on
294+
* dimensionality so the result matches what {@code ST_Envelope(geom)} would have produced for the
295+
* source geometry. Degenerate boxes return:
296+
*
297+
* <ul>
298+
* <li>{@code POINT} when {@code xmin == xmax && ymin == ymax}
299+
* <li>{@code LINESTRING} when exactly one of the X / Y intervals collapses
300+
* <li>{@code POLYGON} otherwise
301+
* </ul>
302+
*
303+
* Returns NULL on null input.
304+
*/
305+
public static Geometry geomFromBox2D(Box2D box) {
306+
if (box == null) {
307+
return null;
308+
}
309+
double xmin = box.getXMin();
310+
double ymin = box.getYMin();
311+
double xmax = box.getXMax();
312+
double ymax = box.getYMax();
313+
boolean xCollapsed = xmin == xmax;
314+
boolean yCollapsed = ymin == ymax;
315+
if (xCollapsed && yCollapsed) {
316+
return GEOMETRY_FACTORY.createPoint(new Coordinate(xmin, ymin));
317+
}
318+
if (xCollapsed || yCollapsed) {
319+
return GEOMETRY_FACTORY.createLineString(
320+
new Coordinate[] {new Coordinate(xmin, ymin), new Coordinate(xmax, ymax)});
321+
}
322+
return polygonFromEnvelope(xmin, ymin, xmax, ymax);
323+
}
324+
292325
/**
293326
* Build a {@link Box2D} from two corner points. The corners are taken verbatim — no swapping or
294327
* validation of ordering — so {@code xmin > xmax} or {@code ymin > ymax} are preserved as

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,33 @@ public static String asWKT(Geometry geometry) {
868868
return GeomUtils.getWKT(geometry);
869869
}
870870

871+
/**
872+
* PostGIS-format text for a Box2D: {@code BOX(x1 y1, x2 y2)}. NULL on null input.
873+
*
874+
* <p>Values are emitted exactly as stored on the Box2D — this method does not normalize the
875+
* corners. Sedona's Box2D allows {@code xmin > xmax} (or {@code ymin > ymax}); that ordering is
876+
* reserved for a future antimeridian-wraparound semantics on geography bboxes (cf. sedona-db's
877+
* {@code WraparoundInterval}). The text faithfully reflects what {@code ST_XMin} / {@code
878+
* ST_XMax} / etc. would return.
879+
*
880+
* <p>Not WKT (WKT has no {@code BOX} type), so this lives outside the {@code asWKT} family to
881+
* keep that API a true geometry serializer.
882+
*/
883+
public static String box2dAsText(Box2D box) {
884+
if (box == null) {
885+
return null;
886+
}
887+
return "BOX("
888+
+ box.getXMin()
889+
+ " "
890+
+ box.getYMin()
891+
+ ", "
892+
+ box.getXMax()
893+
+ " "
894+
+ box.getYMax()
895+
+ ")";
896+
}
897+
871898
public static byte[] asEWKB(Geometry geometry) {
872899
return GeomUtils.getEWKB(geometry);
873900
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import static org.junit.Assert.*;
2222

23+
import org.apache.sedona.common.geometryObjects.Box2D;
2324
import org.apache.sedona.common.utils.GeomUtils;
2425
import org.junit.Test;
2526
import org.locationtech.jts.geom.*;
@@ -241,6 +242,30 @@ public void makeEnvelope() {
241242
assertEquals(expected, actual);
242243
}
243244

245+
@Test
246+
public void geomFromBox2D() {
247+
// 2-D box → POLYGON
248+
Geometry polygon = Constructors.geomFromBox2D(new Box2D(1.0, 2.0, 4.0, 5.0));
249+
assertTrue(polygon instanceof Polygon);
250+
assertEquals("POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))", Functions.asWKT(polygon));
251+
252+
// Collapsed in both axes → POINT (matches PostGIS box2d::geometry and ST_Envelope(point)).
253+
Geometry point = Constructors.geomFromBox2D(new Box2D(3.0, 3.0, 3.0, 3.0));
254+
assertTrue(point instanceof Point);
255+
assertEquals("POINT (3 3)", Functions.asWKT(point));
256+
257+
// Collapsed in one axis → LINESTRING (matches ST_Envelope of an axis-aligned line).
258+
Geometry horizontalLine = Constructors.geomFromBox2D(new Box2D(1.0, 5.0, 4.0, 5.0));
259+
assertTrue(horizontalLine instanceof LineString);
260+
assertEquals("LINESTRING (1 5, 4 5)", Functions.asWKT(horizontalLine));
261+
262+
Geometry verticalLine = Constructors.geomFromBox2D(new Box2D(2.0, 1.0, 2.0, 4.0));
263+
assertTrue(verticalLine instanceof LineString);
264+
assertEquals("LINESTRING (2 1, 2 4)", Functions.asWKT(verticalLine));
265+
266+
assertNull(Constructors.geomFromBox2D(null));
267+
}
268+
244269
@Test
245270
public void makePointM() {
246271
Geometry point = Constructors.makePointM(1, 2, 3);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.common.math.DoubleMath;
2727
import java.util.*;
2828
import java.util.stream.Collectors;
29+
import org.apache.sedona.common.geometryObjects.Box2D;
2930
import org.apache.sedona.common.sphere.Haversine;
3031
import org.apache.sedona.common.sphere.Spheroid;
3132
import org.apache.sedona.common.utils.*;
@@ -166,6 +167,15 @@ public void asWKT() throws Exception {
166167
assertEquals(expectedResult, actualResult);
167168
}
168169

170+
@Test
171+
public void box2dAsText() {
172+
assertEquals("BOX(1.0 2.0, 4.0 5.0)", Functions.box2dAsText(new Box2D(1.0, 2.0, 4.0, 5.0)));
173+
assertEquals(
174+
"BOX(-180.0 -90.0, 180.0 90.0)",
175+
Functions.box2dAsText(new Box2D(-180.0, -90.0, 180.0, 90.0)));
176+
assertNull(Functions.box2dAsText(null));
177+
}
178+
169179
@Test
170180
public void asWKB() throws Exception {
171181
Geometry geometry = GEOMETRY_FACTORY.createPoint(new Coordinate(1.0, 2.0));

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ object Catalog extends AbstractCatalog with Logging {
5757
function[ST_GeomFromEWKT](),
5858
function[ST_GeomFromWKB](),
5959
function[ST_GeomFromEWKB](),
60+
function[ST_GeomFromBox2D](),
6061
function[ST_GeomFromGeoJSON](),
6162
function[ST_GeomFromGML](),
6263
function[ST_GeomFromKML](),

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,22 @@ private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression])
552552
}
553553
}
554554

555+
/**
556+
* Convert a Box2D to a closed rectangular polygon Geometry. Equivalent to PostGIS {@code
557+
* box2d::geometry}. Exposed as a function rather than a Catalyst implicit cast because UDT-to-UDT
558+
* implicit casts require Catalyst-level work; ST_GeomFromBox2D lives alongside the other
559+
* ST_GeomFrom* constructors.
560+
*
561+
* @param inputExpressions
562+
*/
563+
private[apache] case class ST_GeomFromBox2D(inputExpressions: Seq[Expression])
564+
extends InferredExpression(Constructors.geomFromBox2D _) {
565+
566+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
567+
copy(inputExpressions = newChildren)
568+
}
569+
}
570+
555571
private[apache] trait UserDataGenerator {
556572
def generateUserData(
557573
minInputLength: Integer,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,8 +612,10 @@ private[apache] case class ST_SimplifyPolygonHull(inputExpressions: Seq[Expressi
612612

613613
private[apache] case class ST_AsText(inputExpressions: Seq[Expression])
614614
extends InferredExpression(
615-
inferrableFunction1(Functions.asWKT),
616-
inferrableFunction1(org.apache.sedona.common.geography.Functions.asText)) {
615+
inferrableFunction1((g: Geometry) => Functions.asWKT(g)),
616+
inferrableFunction1((g: Geography) =>
617+
org.apache.sedona.common.geography.Functions.asText(g)),
618+
inferrableFunction1((b: Box2D) => Functions.box2dAsText(b))) {
617619

618620
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
619621
copy(inputExpressions = newChildren)

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,21 @@ class constructorTestScala extends TestBaseScala with Matchers {
176176
assert(bbox.getYMax == 5.0)
177177
}
178178

179+
it("Passed ST_GeomFromBox2D") {
180+
val df = sparkSession.sql("""
181+
SELECT
182+
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 2.0), ST_Point(4.0, 5.0)))) AS poly,
183+
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(3.0, 3.0), ST_Point(3.0, 3.0)))) AS point,
184+
ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 5.0), ST_Point(4.0, 5.0)))) AS line,
185+
ST_GeomFromBox2D(ST_MakeBox2D(ST_GeomFromText(NULL), ST_Point(1.0, 1.0))) AS null_geom
186+
""")
187+
val row = df.collect()(0)
188+
assert(row.getString(0) == "POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))")
189+
assert(row.getString(1) == "POINT (3 3)")
190+
assert(row.getString(2) == "LINESTRING (1 5, 4 5)")
191+
assert(row.isNullAt(3))
192+
}
193+
179194
it("ST_MakeBox2D rejects non-point input") {
180195
val ex = intercept[Exception] {
181196
sparkSession

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,17 @@ class functionTestScala
274274
assert(test.take(1)(0).get(0).asInstanceOf[Double] == -3.0)
275275
}
276276

277+
it("Passed ST_AsText for Box2D") {
278+
val df = sparkSession.sql("""
279+
SELECT
280+
ST_AsText(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))'))) AS wkt,
281+
ST_AsText(ST_Box2D(ST_GeomFromText(NULL))) AS null_wkt
282+
""")
283+
val row = df.collect()(0)
284+
assert(row.getString(0) == "BOX(1.0 2.0, 4.0 5.0)")
285+
assert(row.isNullAt(1))
286+
}
287+
277288
it("Passed ST_XMin / XMax / YMin / YMax for Box2D") {
278289
val df = sparkSession.sql("""
279290
WITH t AS (

0 commit comments

Comments
 (0)