diff --git a/common/src/main/java/org/apache/sedona/common/Constructors.java b/common/src/main/java/org/apache/sedona/common/Constructors.java index 307fd83033e..d22dd62f411 100644 --- a/common/src/main/java/org/apache/sedona/common/Constructors.java +++ b/common/src/main/java/org/apache/sedona/common/Constructors.java @@ -289,6 +289,39 @@ public static Geometry makeEnvelope(double minX, double minY, double maxX, doubl return makeEnvelope(minX, minY, maxX, maxY, 0); } + /** + * Convert a {@link Box2D} to a Geometry. Mirrors PostGIS {@code box2d::geometry}: dispatches on + * dimensionality so the result matches what {@code ST_Envelope(geom)} would have produced for the + * source geometry. Degenerate boxes return: + * + * + * + * Returns NULL on null input. + */ + public static Geometry geomFromBox2D(Box2D box) { + if (box == null) { + return null; + } + double xmin = box.getXMin(); + double ymin = box.getYMin(); + double xmax = box.getXMax(); + double ymax = box.getYMax(); + boolean xCollapsed = xmin == xmax; + boolean yCollapsed = ymin == ymax; + if (xCollapsed && yCollapsed) { + return GEOMETRY_FACTORY.createPoint(new Coordinate(xmin, ymin)); + } + if (xCollapsed || yCollapsed) { + return GEOMETRY_FACTORY.createLineString( + new Coordinate[] {new Coordinate(xmin, ymin), new Coordinate(xmax, ymax)}); + } + return polygonFromEnvelope(xmin, ymin, xmax, ymax); + } + /** * Build a {@link Box2D} from two corner points. The corners are taken verbatim — no swapping or * validation of ordering — so {@code xmin > xmax} or {@code ymin > ymax} are preserved as diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index 05ba4617423..c527a2049ca 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -868,6 +868,33 @@ public static String asWKT(Geometry geometry) { return GeomUtils.getWKT(geometry); } + /** + * PostGIS-format text for a Box2D: {@code BOX(x1 y1, x2 y2)}. NULL on null input. + * + *

Values are emitted exactly as stored on the Box2D — this method does not normalize the + * corners. Sedona's Box2D allows {@code xmin > xmax} (or {@code ymin > ymax}); that ordering is + * reserved for a future antimeridian-wraparound semantics on geography bboxes (cf. sedona-db's + * {@code WraparoundInterval}). The text faithfully reflects what {@code ST_XMin} / {@code + * ST_XMax} / etc. would return. + * + *

Not WKT (WKT has no {@code BOX} type), so this lives outside the {@code asWKT} family to + * keep that API a true geometry serializer. + */ + public static String box2dAsText(Box2D box) { + if (box == null) { + return null; + } + return "BOX(" + + box.getXMin() + + " " + + box.getYMin() + + ", " + + box.getXMax() + + " " + + box.getYMax() + + ")"; + } + public static byte[] asEWKB(Geometry geometry) { return GeomUtils.getEWKB(geometry); } diff --git a/common/src/test/java/org/apache/sedona/common/ConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/ConstructorsTest.java index 1cb4d17dc70..66e67db4d5c 100644 --- a/common/src/test/java/org/apache/sedona/common/ConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/ConstructorsTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.*; +import org.apache.sedona.common.geometryObjects.Box2D; import org.apache.sedona.common.utils.GeomUtils; import org.junit.Test; import org.locationtech.jts.geom.*; @@ -241,6 +242,30 @@ public void makeEnvelope() { assertEquals(expected, actual); } + @Test + public void geomFromBox2D() { + // 2-D box → POLYGON + Geometry polygon = Constructors.geomFromBox2D(new Box2D(1.0, 2.0, 4.0, 5.0)); + assertTrue(polygon instanceof Polygon); + assertEquals("POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))", Functions.asWKT(polygon)); + + // Collapsed in both axes → POINT (matches PostGIS box2d::geometry and ST_Envelope(point)). + Geometry point = Constructors.geomFromBox2D(new Box2D(3.0, 3.0, 3.0, 3.0)); + assertTrue(point instanceof Point); + assertEquals("POINT (3 3)", Functions.asWKT(point)); + + // Collapsed in one axis → LINESTRING (matches ST_Envelope of an axis-aligned line). + Geometry horizontalLine = Constructors.geomFromBox2D(new Box2D(1.0, 5.0, 4.0, 5.0)); + assertTrue(horizontalLine instanceof LineString); + assertEquals("LINESTRING (1 5, 4 5)", Functions.asWKT(horizontalLine)); + + Geometry verticalLine = Constructors.geomFromBox2D(new Box2D(2.0, 1.0, 2.0, 4.0)); + assertTrue(verticalLine instanceof LineString); + assertEquals("LINESTRING (2 1, 2 4)", Functions.asWKT(verticalLine)); + + assertNull(Constructors.geomFromBox2D(null)); + } + @Test public void makePointM() { Geometry point = Constructors.makePointM(1, 2, 3); diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java index 4d8a376485a..be21a0d9ff0 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -26,6 +26,7 @@ import com.google.common.math.DoubleMath; import java.util.*; import java.util.stream.Collectors; +import org.apache.sedona.common.geometryObjects.Box2D; import org.apache.sedona.common.sphere.Haversine; import org.apache.sedona.common.sphere.Spheroid; import org.apache.sedona.common.utils.*; @@ -166,6 +167,15 @@ public void asWKT() throws Exception { assertEquals(expectedResult, actualResult); } + @Test + public void box2dAsText() { + assertEquals("BOX(1.0 2.0, 4.0 5.0)", Functions.box2dAsText(new Box2D(1.0, 2.0, 4.0, 5.0))); + assertEquals( + "BOX(-180.0 -90.0, 180.0 90.0)", + Functions.box2dAsText(new Box2D(-180.0, -90.0, 180.0, 90.0))); + assertNull(Functions.box2dAsText(null)); + } + @Test public void asWKB() throws Exception { Geometry geometry = GEOMETRY_FACTORY.createPoint(new Coordinate(1.0, 2.0)); diff --git a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala index 3bb5ea7f8c3..3ea2ce4235e 100644 --- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala +++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala @@ -57,6 +57,7 @@ object Catalog extends AbstractCatalog with Logging { function[ST_GeomFromEWKT](), function[ST_GeomFromWKB](), function[ST_GeomFromEWKB](), + function[ST_GeomFromBox2D](), function[ST_GeomFromGeoJSON](), function[ST_GeomFromGML](), function[ST_GeomFromKML](), diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala index c622833b875..57549812512 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala @@ -552,6 +552,22 @@ private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression]) } } +/** + * Convert a Box2D to a closed rectangular polygon Geometry. Equivalent to PostGIS {@code + * box2d::geometry}. Exposed as a function rather than a Catalyst implicit cast because UDT-to-UDT + * implicit casts require Catalyst-level work; ST_GeomFromBox2D lives alongside the other + * ST_GeomFrom* constructors. + * + * @param inputExpressions + */ +private[apache] case class ST_GeomFromBox2D(inputExpressions: Seq[Expression]) + extends InferredExpression(Constructors.geomFromBox2D _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + private[apache] trait UserDataGenerator { def generateUserData( minInputLength: Integer, diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index cfb789da6cc..c9dc113e595 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -612,8 +612,10 @@ private[apache] case class ST_SimplifyPolygonHull(inputExpressions: Seq[Expressi private[apache] case class ST_AsText(inputExpressions: Seq[Expression]) extends InferredExpression( - inferrableFunction1(Functions.asWKT), - inferrableFunction1(org.apache.sedona.common.geography.Functions.asText)) { + inferrableFunction1((g: Geometry) => Functions.asWKT(g)), + inferrableFunction1((g: Geography) => + org.apache.sedona.common.geography.Functions.asText(g)), + inferrableFunction1((b: Box2D) => Functions.box2dAsText(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/constructorTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/constructorTestScala.scala index eb171b9272b..bd9693565cb 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/constructorTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/constructorTestScala.scala @@ -176,6 +176,21 @@ class constructorTestScala extends TestBaseScala with Matchers { assert(bbox.getYMax == 5.0) } + it("Passed ST_GeomFromBox2D") { + val df = sparkSession.sql(""" + SELECT + ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 2.0), ST_Point(4.0, 5.0)))) AS poly, + ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(3.0, 3.0), ST_Point(3.0, 3.0)))) AS point, + ST_AsText(ST_GeomFromBox2D(ST_MakeBox2D(ST_Point(1.0, 5.0), ST_Point(4.0, 5.0)))) AS line, + ST_GeomFromBox2D(ST_MakeBox2D(ST_GeomFromText(NULL), ST_Point(1.0, 1.0))) AS null_geom + """) + val row = df.collect()(0) + assert(row.getString(0) == "POLYGON ((1 2, 1 5, 4 5, 4 2, 1 2))") + assert(row.getString(1) == "POINT (3 3)") + assert(row.getString(2) == "LINESTRING (1 5, 4 5)") + assert(row.isNullAt(3)) + } + it("ST_MakeBox2D rejects non-point input") { val ex = intercept[Exception] { sparkSession diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala index b4e424f6ec8..ef5d316e81d 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala @@ -274,6 +274,17 @@ class functionTestScala assert(test.take(1)(0).get(0).asInstanceOf[Double] == -3.0) } + it("Passed ST_AsText for Box2D") { + val df = sparkSession.sql(""" + SELECT + ST_AsText(ST_Box2D(ST_GeomFromText('POLYGON((1 2, 1 5, 4 5, 4 2, 1 2))'))) AS wkt, + ST_AsText(ST_Box2D(ST_GeomFromText(NULL))) AS null_wkt + """) + val row = df.collect()(0) + assert(row.getString(0) == "BOX(1.0 2.0, 4.0 5.0)") + assert(row.isNullAt(1)) + } + it("Passed ST_XMin / XMax / YMin / YMax for Box2D") { val df = sparkSession.sql(""" WITH t AS (