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 50129cfccbc..b1200de111b 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -730,6 +730,10 @@ public static Double xMin(Box2D box) { return box == null ? null : box.getXMin(); } + public static Double xMin(Box3D box) { + return box == null ? null : box.getXMin(); + } + public static Double xMax(Geometry geometry) { Coordinate[] points = geometry.getCoordinates(); double max = -Double.MAX_VALUE; @@ -743,6 +747,10 @@ public static Double xMax(Box2D box) { return box == null ? null : box.getXMax(); } + public static Double xMax(Box3D box) { + return box == null ? null : box.getXMax(); + } + public static Double yMin(Geometry geometry) { Coordinate[] points = geometry.getCoordinates(); double min = Double.MAX_VALUE; @@ -756,6 +764,10 @@ public static Double yMin(Box2D box) { return box == null ? null : box.getYMin(); } + public static Double yMin(Box3D box) { + return box == null ? null : box.getYMin(); + } + public static Double yMax(Geometry geometry) { Coordinate[] points = geometry.getCoordinates(); double max = -Double.MAX_VALUE; @@ -769,6 +781,10 @@ public static Double yMax(Box2D box) { return box == null ? null : box.getYMax(); } + public static Double yMax(Box3D box) { + return box == null ? null : box.getYMax(); + } + public static Double zMax(Geometry geometry) { Coordinate[] points = geometry.getCoordinates(); double max = -Double.MAX_VALUE; @@ -779,6 +795,10 @@ public static Double zMax(Geometry geometry) { return max == -Double.MAX_VALUE ? null : max; } + public static Double zMax(Box3D box) { + return box == null ? null : box.getZMax(); + } + public static Double zMin(Geometry geometry) { Coordinate[] points = geometry.getCoordinates(); double min = Double.MAX_VALUE; @@ -789,6 +809,10 @@ public static Double zMin(Geometry geometry) { return min == Double.MAX_VALUE ? null : min; } + public static Double zMin(Box3D box) { + return box == null ? null : box.getZMin(); + } + public static boolean hasM(Geometry geom) { Coordinate coord = geom.getCoordinate(); return coord != null && !Double.isNaN(coord.getM()); @@ -922,6 +946,38 @@ public static String box2dAsText(Box2D box) { + ")"; } + /** + * PostGIS-format text for a Box3D: {@code BOX3D(xmin ymin zmin, xmax ymax zmax)}. NULL on null + * input. + * + *

Values are emitted exactly as stored on the Box3D — this method does not normalize the + * corners. Sedona's Box3D allows inverted bounds (e.g. {@code xmin > xmax}); that ordering is + * reserved for the same future antimeridian-wraparound semantics noted on {@link + * #box2dAsText(Box2D)}. The text faithfully reflects what {@code ST_XMin} / {@code ST_XMax} / + * etc. would return. + * + *

Not WKT (WKT has no {@code BOX3D} type), so like {@link #box2dAsText(Box2D)} this lives + * outside the {@code asWKT} family to keep that API a true geometry serializer. + */ + public static String box3dAsText(Box3D box) { + if (box == null) { + return null; + } + return "BOX3D(" + + box.getXMin() + + " " + + box.getYMin() + + " " + + box.getZMin() + + ", " + + box.getXMax() + + " " + + box.getYMax() + + " " + + box.getZMax() + + ")"; + } + public static byte[] asEWKB(Geometry geometry) { return GeomUtils.getEWKB(geometry); } 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 78dede700ed..1a00dbcadc6 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 @@ -72,7 +72,8 @@ private[apache] case class ST_Distance(inputExpressions: Seq[Expression]) private[apache] case class ST_YMax(inputExpressions: Seq[Expression]) extends InferredExpression( inferrableFunction1((g: Geometry) => Functions.yMax(g)), - inferrableFunction1((b: Box2D) => Functions.yMax(b))) { + inferrableFunction1((b: Box2D) => Functions.yMax(b)), + inferrableFunction1((b: Box3D) => Functions.yMax(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -82,7 +83,8 @@ private[apache] case class ST_YMax(inputExpressions: Seq[Expression]) private[apache] case class ST_YMin(inputExpressions: Seq[Expression]) extends InferredExpression( inferrableFunction1((g: Geometry) => Functions.yMin(g)), - inferrableFunction1((b: Box2D) => Functions.yMin(b))) { + inferrableFunction1((b: Box2D) => Functions.yMin(b)), + inferrableFunction1((b: Box3D) => Functions.yMin(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -90,13 +92,16 @@ private[apache] case class ST_YMin(inputExpressions: Seq[Expression]) } /** - * Return the Z maxima of the geometry. + * Return the Z maxima of a Geometry or a Box3D. * * @param inputExpressions - * This function takes a geometry and returns the maximum of all Z-coordinate values. + * For a Geometry, returns the maximum of all Z-coordinate values. For a Box3D, returns the + * stored {@code zmax} bound verbatim (no corner normalization). */ private[apache] case class ST_ZMax(inputExpressions: Seq[Expression]) - extends InferredExpression(Functions.zMax _) { + extends InferredExpression( + inferrableFunction1((g: Geometry) => Functions.zMax(g)), + inferrableFunction1((b: Box3D) => Functions.zMax(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -104,13 +109,16 @@ private[apache] case class ST_ZMax(inputExpressions: Seq[Expression]) } /** - * Return the Z minima of the geometry. + * Return the Z minima of a Geometry or a Box3D. * * @param inputExpressions - * This function takes a geometry and returns the minimum of all Z-coordinate values. + * For a Geometry, returns the minimum of all Z-coordinate values. For a Box3D, returns the + * stored {@code zmin} bound verbatim (no corner normalization). */ private[apache] case class ST_ZMin(inputExpressions: Seq[Expression]) - extends InferredExpression(Functions.zMin _) { + extends InferredExpression( + inferrableFunction1((g: Geometry) => Functions.zMin(g)), + inferrableFunction1((b: Box3D) => Functions.zMin(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -633,7 +641,8 @@ private[apache] case class ST_AsText(inputExpressions: Seq[Expression]) inferrableFunction1((g: Geometry) => Functions.asWKT(g)), inferrableFunction1((g: Geography) => org.apache.sedona.common.geography.Functions.asText(g)), - inferrableFunction1((b: Box2D) => Functions.box2dAsText(b))) { + inferrableFunction1((b: Box2D) => Functions.box2dAsText(b)), + inferrableFunction1((b: Box3D) => Functions.box3dAsText(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -1516,7 +1525,8 @@ private[apache] case class ST_IsEmpty(inputExpressions: Seq[Expression]) private[apache] case class ST_XMax(inputExpressions: Seq[Expression]) extends InferredExpression( inferrableFunction1((g: Geometry) => Functions.xMax(g)), - inferrableFunction1((b: Box2D) => Functions.xMax(b))) { + inferrableFunction1((b: Box2D) => Functions.xMax(b)), + inferrableFunction1((b: Box3D) => Functions.xMax(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) @@ -1531,7 +1541,8 @@ private[apache] case class ST_XMax(inputExpressions: Seq[Expression]) private[apache] case class ST_XMin(inputExpressions: Seq[Expression]) extends InferredExpression( inferrableFunction1((g: Geometry) => Functions.xMin(g)), - inferrableFunction1((b: Box2D) => Functions.xMin(b))) { + inferrableFunction1((b: Box2D) => Functions.xMin(b)), + inferrableFunction1((b: Box3D) => Functions.xMin(b))) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { copy(inputExpressions = newChildren) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/Box3DAccessorSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DAccessorSuite.scala new file mode 100644 index 00000000000..0c86a465c65 --- /dev/null +++ b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DAccessorSuite.scala @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sedona.sql + +class Box3DAccessorSuite extends TestBaseScala { + + describe("Box3D accessors + ST_AsText") { + + it("ST_XMin/YMin/ZMin/XMax/YMax/ZMax accept Box3D") { + val row = sparkSession + .sql(""" + WITH t AS ( + SELECT ST_3DMakeBox(ST_PointZ(1, 2, 3), ST_PointZ(4, 5, 6)) AS b + ) + SELECT + ST_XMin(b), ST_YMin(b), ST_ZMin(b), + ST_XMax(b), ST_YMax(b), ST_ZMax(b) + FROM t + """) + .collect()(0) + assert(row.getDouble(0) == 1.0) + assert(row.getDouble(1) == 2.0) + assert(row.getDouble(2) == 3.0) + assert(row.getDouble(3) == 4.0) + assert(row.getDouble(4) == 5.0) + assert(row.getDouble(5) == 6.0) + } + + it("Accessors propagate NULL on a NULL Box3D input") { + val row = sparkSession + .sql( + "SELECT ST_XMin(b), ST_YMin(b), ST_ZMin(b), " + + "ST_XMax(b), ST_YMax(b), ST_ZMax(b) " + + "FROM (SELECT ST_3DMakeBox(ST_GeomFromText(NULL), ST_PointZ(1,1,1)) AS b)") + .collect()(0) + (0 until 6).foreach(i => assert(row.isNullAt(i), s"column $i should be NULL")) + } + + it("ST_AsText(box3d) returns BOX3D(...) text") { + val str = sparkSession + .sql("SELECT ST_AsText(ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(10, 20, 30))) AS s") + .collect()(0) + .getString(0) + assert(str == "BOX3D(0.0 0.0 0.0, 10.0 20.0 30.0)") + } + + it("ST_AsText(box3d) returns NULL for NULL input") { + val row = sparkSession + .sql("SELECT ST_AsText(ST_3DMakeBox(ST_GeomFromText(NULL), ST_PointZ(1,1,1))) AS s") + .collect()(0) + assert(row.isNullAt(0)) + } + } +}