From 36ca7286ee30121442eb53339307b6cef25992cd Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 12 Sep 2026 23:52:01 -0700 Subject: [PATCH] [GH-3359] Return 2D results from ST_NDims, ST_CoordDim and ST_Zmflag for empty geometries Functions.nDims and Functions.zmFlag dereferenced Geometry.getCoordinate(), which JTS returns as null for empty geometries, so any empty input failed the whole query with a NullPointerException. Guard the null coordinate and report empties as 2D (nDims = 2, zmFlag = 0), matching PostGIS and the existing ST_HasZ / ST_HasM behavior. Spark, Flink and Snowflake all delegate to the common implementation. --- .../org/apache/sedona/common/Functions.java | 8 ++++ .../apache/sedona/common/FunctionsTest.java | 46 +++++++++++++++++++ .../api/sql/Geometry-Accessors/ST_CoordDim.md | 14 +++++- docs/api/sql/Geometry-Accessors/ST_NDims.md | 14 +++++- docs/api/sql/Geometry-Accessors/ST_Zmflag.md | 16 ++++++- docs/setup/release-notes.md | 4 ++ .../apache/sedona/sql/functionTestScala.scala | 19 ++++++++ 7 files changed, 118 insertions(+), 3 deletions(-) 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 011c0bf4e09..e3057e5ae12 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -1048,6 +1048,10 @@ public static int nPoints(Geometry geometry) { public static int nDims(Geometry geometry) { int count_dimension = 0; Coordinate geom = geometry.getCoordinate(); + if (geom == null) { + // Empty geometries have no coordinate to inspect; report them as 2D like PostGIS does. + return 2; + } Double x_cord = geom.getX(); Double y_cord = geom.getY(); Double z_cord = geom.getZ(); @@ -1300,6 +1304,10 @@ public static Geometry delaunayTriangle(Geometry geometry, double tolerance, int public static int zmFlag(Geometry geom) { Coordinate coords = geom.getCoordinate(); + if (coords == null) { + // Empty geometries have no coordinate to inspect; report them as 2D like PostGIS does. + return 0; + } boolean hasZ = !Double.isNaN(coords.getZ()); boolean hasM = !Double.isNaN(coords.getM()); if (hasM && hasZ) { 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 9e3a7d6b005..0bd0c833090 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -2819,6 +2819,34 @@ public void simplifyPolygonHull() throws ParseException { }); } + @Test + public void nDims() throws ParseException { + assertEquals(2, Functions.nDims(Constructors.geomFromWKT("POINT (1 2)", 0))); + assertEquals(3, Functions.nDims(Constructors.geomFromWKT("LINESTRING (1 2 3, 4 5 6)", 0))); + assertEquals( + 3, + Functions.nDims(Constructors.geomFromWKT("POLYGON M ((1 2 3, 3 4 3, 5 6 3, 1 2 3))", 0))); + assertEquals(4, Functions.nDims(Constructors.geomFromWKT("POINT ZM (1 2 3 4)", 0))); + } + + @Test + public void nDimsEmptyGeometries() throws ParseException { + String[] emptyWkts = { + "POINT EMPTY", + "LINESTRING EMPTY", + "POLYGON EMPTY", + "MULTIPOINT EMPTY", + "MULTILINESTRING EMPTY", + "MULTIPOLYGON EMPTY", + "GEOMETRYCOLLECTION EMPTY" + }; + for (String wkt : emptyWkts) { + assertEquals(wkt, 2, Functions.nDims(Constructors.geomFromWKT(wkt, 0))); + } + assertEquals(2, Functions.nDims(GEOMETRY_FACTORY.createPoint())); + assertEquals(2, Functions.nDims(GEOMETRY_FACTORY.createGeometryCollection())); + } + @Test public void force3DObject2D() { int expectedDims = 3; @@ -4852,6 +4880,24 @@ public void testZmFlag() throws ParseException { assertEquals(_4D, Functions.zmFlag(geom)); } + @Test + public void testZmFlagEmptyGeometries() throws ParseException { + String[] emptyWkts = { + "POINT EMPTY", + "LINESTRING EMPTY", + "POLYGON EMPTY", + "MULTIPOINT EMPTY", + "MULTILINESTRING EMPTY", + "MULTIPOLYGON EMPTY", + "GEOMETRYCOLLECTION EMPTY" + }; + for (String wkt : emptyWkts) { + assertEquals(wkt, 0, Functions.zmFlag(Constructors.geomFromWKT(wkt, 0))); + } + assertEquals(0, Functions.zmFlag(GEOMETRY_FACTORY.createPoint())); + assertEquals(0, Functions.zmFlag(GEOMETRY_FACTORY.createGeometryCollection())); + } + @Test public void hausdorffDistanceDefaultGeom2D() throws Exception { Polygon polygon1 = diff --git a/docs/api/sql/Geometry-Accessors/ST_CoordDim.md b/docs/api/sql/Geometry-Accessors/ST_CoordDim.md index 64e6bc61af4..48ca773fa69 100644 --- a/docs/api/sql/Geometry-Accessors/ST_CoordDim.md +++ b/docs/api/sql/Geometry-Accessors/ST_CoordDim.md @@ -19,7 +19,7 @@ # ST_CoordDim -Introduction: Returns the coordinate dimensions of the geometry. It is an alias of `ST_NDims`. +Introduction: Returns the coordinate dimensions of the geometry. It is an alias of `ST_NDims`. Empty geometries return 2. ![ST_CoordDim](../../../image/ST_CoordDim/ST_CoordDim.svg "ST_CoordDim") @@ -52,3 +52,15 @@ Output: ``` 2 ``` + +Spark SQL Example with an empty geometry: + +```sql +SELECT ST_CoordDim(ST_GeomFromWKT('LINESTRING EMPTY')) +``` + +Output: + +``` +2 +``` diff --git a/docs/api/sql/Geometry-Accessors/ST_NDims.md b/docs/api/sql/Geometry-Accessors/ST_NDims.md index 8a678c79fa3..a58d459c8f0 100644 --- a/docs/api/sql/Geometry-Accessors/ST_NDims.md +++ b/docs/api/sql/Geometry-Accessors/ST_NDims.md @@ -19,7 +19,7 @@ # ST_NDims -Introduction: Returns the coordinate dimension of the geometry. +Introduction: Returns the coordinate dimension of the geometry. Empty geometries return 2. ![ST_NDims](../../../image/ST_NDims/ST_NDims.svg "ST_NDims") @@ -52,3 +52,15 @@ Output: ``` 2 ``` + +Spark SQL example with an empty geometry: + +```sql +SELECT ST_NDims(ST_GeomFromText('POINT EMPTY')) +``` + +Output: + +``` +2 +``` diff --git a/docs/api/sql/Geometry-Accessors/ST_Zmflag.md b/docs/api/sql/Geometry-Accessors/ST_Zmflag.md index 91474c1b944..05096c8a1d9 100644 --- a/docs/api/sql/Geometry-Accessors/ST_Zmflag.md +++ b/docs/api/sql/Geometry-Accessors/ST_Zmflag.md @@ -21,7 +21,7 @@ Introduction: Returns a code indicating the Z and M coordinate dimensions present in the input geometry. -Values are: 0 = 2D, 1 = 3D-M, 2 = 3D-Z, 3 = 4D. +Values are: 0 = 2D, 1 = 3D-M, 2 = 3D-Z, 3 = 4D. Empty geometries return 0. ![ST_Zmflag](../../../image/ST_Zmflag/ST_Zmflag.svg "ST_Zmflag") @@ -58,3 +58,17 @@ Output: ``` 3 ``` + +SQL Example + +```sql +SELECT ST_Zmflag( + ST_GeomFromWKT('POLYGON EMPTY') +) +``` + +Output: + +``` +0 +``` diff --git a/docs/setup/release-notes.md b/docs/setup/release-notes.md index c1d7641ab26..d87259d9f29 100644 --- a/docs/setup/release-notes.md +++ b/docs/setup/release-notes.md @@ -69,6 +69,10 @@ ### Bug Fixes +#### Sedona SQL + +* [GH-3359] - Return 2D results from `ST_NDims`, `ST_CoordDim` and `ST_Zmflag` for empty geometries instead of throwing + #### GeoPandas API * [GH-3269] - Warn when `geom_equals` or `geom_equals_exact` compares geometry operands with mismatched CRSs 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 6e0bbd5c69b..1bf7154b4a9 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 @@ -1124,6 +1124,16 @@ class functionTestScala assert(test.take(1)(0).get(0).asInstanceOf[Int] == 4) } + it("Passed ST_NDims and ST_CoordDim with empty geometries") { + Seq("POINT EMPTY", "LINESTRING EMPTY", "POLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY") + .foreach { wkt => + val test = sparkSession.sql( + s"SELECT ST_NDims(ST_GeomFromWKT('$wkt')), ST_CoordDim(ST_GeomFromWKT('$wkt'))") + assert(test.first().getInt(0) == 2) + assert(test.first().getInt(1) == 2) + } + } + it("Passed ST_GeometryType") { var test = sparkSession.sql( "SELECT ST_GeometryType(ST_GeomFromText('LINESTRING(77.29 29.07,77.42 29.26,77.27 29.31,77.29 29.07)'))") @@ -1506,6 +1516,15 @@ class functionTestScala assert(actual == 3) } + it("Passed ST_Zmflag with empty geometries") { + Seq("POINT EMPTY", "LINESTRING EMPTY", "POLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY") + .foreach { wkt => + val actual = + sparkSession.sql(s"SELECT ST_Zmflag(ST_GeomFromWKT('$wkt'))").first().get(0) + assert(actual == 0) + } + } + it("Should pass ST_StartPoint function") { Given("Polygon Data Frame, Point DataFrame, LineString Data Frame")