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 d44aede0e85..307fd83033e 100644 --- a/common/src/main/java/org/apache/sedona/common/Constructors.java +++ b/common/src/main/java/org/apache/sedona/common/Constructors.java @@ -24,6 +24,7 @@ import javax.xml.parsers.ParserConfigurationException; import org.apache.sedona.common.enums.FileDataSplitter; import org.apache.sedona.common.enums.GeometryType; +import org.apache.sedona.common.geometryObjects.Box2D; import org.apache.sedona.common.utils.FormatUtils; import org.apache.sedona.common.utils.GeoHashDecoder; import org.locationtech.jts.geom.*; @@ -288,6 +289,26 @@ public static Geometry makeEnvelope(double minX, double minY, double maxX, doubl return makeEnvelope(minX, minY, maxX, maxY, 0); } + /** + * 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 + * supplied. NULL or empty point inputs return NULL. + */ + public static Box2D makeBox2D(Geometry lowerLeft, Geometry upperRight) { + if (lowerLeft == null || upperRight == null) { + return null; + } + if (!(lowerLeft instanceof Point) || !(upperRight instanceof Point)) { + throw new IllegalArgumentException("ST_MakeBox2D requires two POINT geometries"); + } + if (lowerLeft.isEmpty() || upperRight.isEmpty()) { + return null; + } + Point ll = (Point) lowerLeft; + Point ur = (Point) upperRight; + return new Box2D(ll.getX(), ll.getY(), ur.getX(), ur.getY()); + } + public static Geometry geomFromGeoHash(String geoHash, Integer precision) { try { return GeoHashDecoder.decode(geoHash, precision); 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 ec98e3ada3c..bab122a617e 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 @@ -61,6 +61,7 @@ object Catalog extends AbstractCatalog with Logging { function[ST_GeomFromGML](), function[ST_GeomFromKML](), function[ST_Point](), + function[ST_MakeBox2D](), function[ST_MakeEnvelope](), function[ST_MakePoint](null, null), function[ST_MakePointM](), 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 ce81764e7e7..c622833b875 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 @@ -538,6 +538,20 @@ private[apache] case class ST_PolygonFromEnvelope(inputExpressions: Seq[Expressi } } +/** + * Construct a Box2D from two corner points (lower-left, upper-right). Coordinates are taken + * verbatim; ordering is not validated. + * + * @param inputExpressions + */ +private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression]) + extends InferredExpression(Constructors.makeBox2D _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + private[apache] trait UserDataGenerator { def generateUserData( minInputLength: Integer, 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 6a4160ded2f..eb171b9272b 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 @@ -18,6 +18,7 @@ */ package org.apache.sedona.sql +import org.apache.sedona.common.geometryObjects.Box2D import org.apache.sedona.core.formatMapper.GeoJsonReader import org.apache.sedona.core.formatMapper.shapefileParser.ShapefileReader import org.apache.sedona.sql.utils.Adapter @@ -138,6 +139,52 @@ class constructorTestScala extends TestBaseScala with Matchers { assert(polygonDF.count() == 1) } + it("Passed ST_MakeBox2D") { + val df = sparkSession.sql(""" + SELECT + ST_MakeBox2D(ST_Point(1.0, 2.0), ST_Point(4.0, 5.0)) AS bbox, + ST_MakeBox2D(ST_Point(10.0, 20.0), ST_GeomFromText(NULL)) AS bbox_null, + ST_MakeBox2D(ST_GeomFromText('POINT EMPTY'), ST_Point(1.0, 1.0)) AS bbox_empty + """) + val row = df.collect()(0) + val bbox = row.getAs[Box2D]("bbox") + assert(bbox.getXMin == 1.0) + assert(bbox.getYMin == 2.0) + assert(bbox.getXMax == 4.0) + assert(bbox.getYMax == 5.0) + assert(row.isNullAt(1)) + assert(row.isNullAt(2)) + } + + it("ST_MakeBox2D preserves swapped corners") { + // No swapping or reordering; lower-left/upper-right are taken verbatim. + // This leaves xmin > xmax / ymin > ymax available for future antimeridian semantics. + val df = sparkSession.sql( + "SELECT ST_MakeBox2D(ST_Point(170.0, 10.0), ST_Point(-170.0, 20.0)) AS bbox") + val bbox = df.collect()(0).getAs[Box2D]("bbox") + assert(bbox.getXMin == 170.0) + assert(bbox.getXMax == -170.0) + } + + it("ST_MakeBox2D ignores Z on 3D point input") { + val df = sparkSession.sql( + "SELECT ST_MakeBox2D(ST_PointZ(1.0, 2.0, 99.0), ST_PointZ(4.0, 5.0, 99.0)) AS bbox") + val bbox = df.collect()(0).getAs[Box2D]("bbox") + assert(bbox.getXMin == 1.0) + assert(bbox.getYMin == 2.0) + assert(bbox.getXMax == 4.0) + assert(bbox.getYMax == 5.0) + } + + it("ST_MakeBox2D rejects non-point input") { + val ex = intercept[Exception] { + sparkSession + .sql("SELECT ST_MakeBox2D(ST_GeomFromText('LINESTRING(0 0, 1 1)'), ST_Point(2.0, 2.0))") + .collect() + } + assert(ex.getMessage.contains("ST_MakeBox2D requires two POINT geometries")) + } + it("Passed ST_PointFromText") { var pointCsvDF = sparkSession.read .format("csv")