Skip to content

Commit 1361fed

Browse files
authored
[GH-2883] Add ST_MakeBox2D(p1, p2) scalar constructor (#2897)
1 parent 1afd1b1 commit 1361fed

4 files changed

Lines changed: 83 additions & 0 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import javax.xml.parsers.ParserConfigurationException;
2525
import org.apache.sedona.common.enums.FileDataSplitter;
2626
import org.apache.sedona.common.enums.GeometryType;
27+
import org.apache.sedona.common.geometryObjects.Box2D;
2728
import org.apache.sedona.common.utils.FormatUtils;
2829
import org.apache.sedona.common.utils.GeoHashDecoder;
2930
import org.locationtech.jts.geom.*;
@@ -288,6 +289,26 @@ public static Geometry makeEnvelope(double minX, double minY, double maxX, doubl
288289
return makeEnvelope(minX, minY, maxX, maxY, 0);
289290
}
290291

292+
/**
293+
* Build a {@link Box2D} from two corner points. The corners are taken verbatim — no swapping or
294+
* validation of ordering — so {@code xmin > xmax} or {@code ymin > ymax} are preserved as
295+
* supplied. NULL or empty point inputs return NULL.
296+
*/
297+
public static Box2D makeBox2D(Geometry lowerLeft, Geometry upperRight) {
298+
if (lowerLeft == null || upperRight == null) {
299+
return null;
300+
}
301+
if (!(lowerLeft instanceof Point) || !(upperRight instanceof Point)) {
302+
throw new IllegalArgumentException("ST_MakeBox2D requires two POINT geometries");
303+
}
304+
if (lowerLeft.isEmpty() || upperRight.isEmpty()) {
305+
return null;
306+
}
307+
Point ll = (Point) lowerLeft;
308+
Point ur = (Point) upperRight;
309+
return new Box2D(ll.getX(), ll.getY(), ur.getX(), ur.getY());
310+
}
311+
291312
public static Geometry geomFromGeoHash(String geoHash, Integer precision) {
292313
try {
293314
return GeoHashDecoder.decode(geoHash, precision);

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
@@ -61,6 +61,7 @@ object Catalog extends AbstractCatalog with Logging {
6161
function[ST_GeomFromGML](),
6262
function[ST_GeomFromKML](),
6363
function[ST_Point](),
64+
function[ST_MakeBox2D](),
6465
function[ST_MakeEnvelope](),
6566
function[ST_MakePoint](null, null),
6667
function[ST_MakePointM](),

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,20 @@ private[apache] case class ST_PolygonFromEnvelope(inputExpressions: Seq[Expressi
538538
}
539539
}
540540

541+
/**
542+
* Construct a Box2D from two corner points (lower-left, upper-right). Coordinates are taken
543+
* verbatim; ordering is not validated.
544+
*
545+
* @param inputExpressions
546+
*/
547+
private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression])
548+
extends InferredExpression(Constructors.makeBox2D _) {
549+
550+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
551+
copy(inputExpressions = newChildren)
552+
}
553+
}
554+
541555
private[apache] trait UserDataGenerator {
542556
def generateUserData(
543557
minInputLength: Integer,

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package org.apache.sedona.sql
2020

21+
import org.apache.sedona.common.geometryObjects.Box2D
2122
import org.apache.sedona.core.formatMapper.GeoJsonReader
2223
import org.apache.sedona.core.formatMapper.shapefileParser.ShapefileReader
2324
import org.apache.sedona.sql.utils.Adapter
@@ -138,6 +139,52 @@ class constructorTestScala extends TestBaseScala with Matchers {
138139
assert(polygonDF.count() == 1)
139140
}
140141

142+
it("Passed ST_MakeBox2D") {
143+
val df = sparkSession.sql("""
144+
SELECT
145+
ST_MakeBox2D(ST_Point(1.0, 2.0), ST_Point(4.0, 5.0)) AS bbox,
146+
ST_MakeBox2D(ST_Point(10.0, 20.0), ST_GeomFromText(NULL)) AS bbox_null,
147+
ST_MakeBox2D(ST_GeomFromText('POINT EMPTY'), ST_Point(1.0, 1.0)) AS bbox_empty
148+
""")
149+
val row = df.collect()(0)
150+
val bbox = row.getAs[Box2D]("bbox")
151+
assert(bbox.getXMin == 1.0)
152+
assert(bbox.getYMin == 2.0)
153+
assert(bbox.getXMax == 4.0)
154+
assert(bbox.getYMax == 5.0)
155+
assert(row.isNullAt(1))
156+
assert(row.isNullAt(2))
157+
}
158+
159+
it("ST_MakeBox2D preserves swapped corners") {
160+
// No swapping or reordering; lower-left/upper-right are taken verbatim.
161+
// This leaves xmin > xmax / ymin > ymax available for future antimeridian semantics.
162+
val df = sparkSession.sql(
163+
"SELECT ST_MakeBox2D(ST_Point(170.0, 10.0), ST_Point(-170.0, 20.0)) AS bbox")
164+
val bbox = df.collect()(0).getAs[Box2D]("bbox")
165+
assert(bbox.getXMin == 170.0)
166+
assert(bbox.getXMax == -170.0)
167+
}
168+
169+
it("ST_MakeBox2D ignores Z on 3D point input") {
170+
val df = sparkSession.sql(
171+
"SELECT ST_MakeBox2D(ST_PointZ(1.0, 2.0, 99.0), ST_PointZ(4.0, 5.0, 99.0)) AS bbox")
172+
val bbox = df.collect()(0).getAs[Box2D]("bbox")
173+
assert(bbox.getXMin == 1.0)
174+
assert(bbox.getYMin == 2.0)
175+
assert(bbox.getXMax == 4.0)
176+
assert(bbox.getYMax == 5.0)
177+
}
178+
179+
it("ST_MakeBox2D rejects non-point input") {
180+
val ex = intercept[Exception] {
181+
sparkSession
182+
.sql("SELECT ST_MakeBox2D(ST_GeomFromText('LINESTRING(0 0, 1 1)'), ST_Point(2.0, 2.0))")
183+
.collect()
184+
}
185+
assert(ex.getMessage.contains("ST_MakeBox2D requires two POINT geometries"))
186+
}
187+
141188
it("Passed ST_PointFromText") {
142189
var pointCsvDF = sparkSession.read
143190
.format("csv")

0 commit comments

Comments
 (0)