You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/08/30 04:39:16 UTC
(sedona) branch master updated: [GH-3174] Align ST_IsPolygonCW/ST_IsPolygonCCW with PostGIS polygon orientation semantics (#3292)
This is an automated email from the ASF dual-hosted git repository.
jiayuasu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git
The following commit(s) were added to refs/heads/master by this push:
new 920aab1b92c [GH-3174] Align ST_IsPolygonCW/ST_IsPolygonCCW with PostGIS polygon orientation semantics (#3292)
920aab1b92c is described below
commit 920aab1b92c6ead66f142883a624dc39a9ca62ae
Author: Minh Pham <14...@users.noreply.github.com>
AuthorDate: Sun Aug 30 00:39:10 2026 -0400
[GH-3174] Align ST_IsPolygonCW/ST_IsPolygonCCW with PostGIS polygon orientation semantics (#3292)
Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
Co-authored-by: Jia Yu <ji...@apache.org>
---
.../java/org/apache/sedona/common/Functions.java | 83 ++++++++++++----------
.../org/apache/sedona/common/FunctionsTest.java | 50 +++++++++++--
.../flink/Geometry-Accessors/ST_IsPolygonCCW.md | 7 +-
.../api/flink/Geometry-Accessors/ST_IsPolygonCW.md | 7 +-
.../Geometry-Accessors/ST_IsPolygonCCW.md | 7 +-
.../Geometry-Accessors/ST_IsPolygonCW.md | 7 +-
docs/api/sql/Geometry-Accessors/ST_IsPolygonCCW.md | 7 +-
docs/api/sql/Geometry-Accessors/ST_IsPolygonCW.md | 7 +-
docs/setup/release-notes.md | 5 ++
.../apache/sedona/flink/expressions/Functions.java | 10 ++-
.../java/org/apache/sedona/flink/FunctionTest.java | 36 ++++++++++
.../sedona/snowflake/snowsql/TestFunctions.java | 24 +++++++
.../sedona/snowflake/snowsql/TestFunctionsV2.java | 16 ++++-
.../org/apache/sedona/sql/functionTestScala.scala | 23 ++++++
14 files changed, 233 insertions(+), 56 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 fa8a7da9e25..23acbbb68ae 100644
--- a/common/src/main/java/org/apache/sedona/common/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/Functions.java
@@ -26,6 +26,7 @@ import com.uber.h3core.exceptions.H3Exception;
import com.uber.h3core.util.LatLng;
import java.util.*;
import java.util.List;
+import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.sedona.common.S2Geography.Geography;
@@ -1635,29 +1636,22 @@ public class Functions {
}
/**
- * This function accepts Polygon and MultiPolygon, if any other type is provided then it will
- * return false. If the exterior ring is clockwise and the interior ring(s) are counter-clockwise
- * then returns true, otherwise false. Empty Polygon and MultiPolygon inputs return true because
- * they contain no rings with the opposite orientation.
+ * Recursively inspects the polygonal components of the input (Polygon, MultiPolygon, or any
+ * nested GeometryCollection), and returns true if every one of them has a clockwise exterior ring
+ * and counter-clockwise interior ring(s). Non-polygonal components (points, line strings) are
+ * ignored. Inputs with no polygonal components at all, including points, line strings, and empty
+ * geometry collections, vacuously return true, matching PostGIS semantics. Empty Polygon and
+ * MultiPolygon inputs also return true because they contain no rings with the opposite
+ * orientation.
*
- * @param geom Polygon or MultiPolygon
+ * @param geom any geometry
* @return
*/
public static boolean isPolygonCW(Geometry geom) {
- if (geom instanceof MultiPolygon) {
- MultiPolygon multiPolygon = (MultiPolygon) geom;
-
- for (int i = 0; i < multiPolygon.getNumGeometries(); i++) {
- if (!checkIfPolygonCW((Polygon) multiPolygon.getGeometryN(i))) {
- return false;
- }
- }
- return true;
- } else if (geom instanceof Polygon) {
- return checkIfPolygonCW((Polygon) geom);
+ if (geom == null) {
+ return false;
}
- // False for remaining geometry types
- return false;
+ return allPolygonalComponentsMatchOrientation(geom, Functions::checkIfPolygonCW);
}
private static boolean checkIfPolygonCW(Polygon geom) {
@@ -1681,6 +1675,30 @@ public class Functions {
return isExteriorRingCW && isInteriorRingCW;
}
+ /**
+ * Recursively walks a geometry's polygonal components (Polygon, MultiPolygon, or any nested
+ * GeometryCollection) and returns true only if every one of them satisfies {@code
+ * ringOrientationCheck}. Non-polygonal components (points, line strings) are ignored, and inputs
+ * with no polygonal components at all vacuously return true.
+ */
+ private static boolean allPolygonalComponentsMatchOrientation(
+ Geometry geom, Predicate<Polygon> ringOrientationCheck) {
+ if (geom instanceof Polygon) {
+ return ringOrientationCheck.test((Polygon) geom);
+ } else if (geom instanceof GeometryCollection) {
+ GeometryCollection collection = (GeometryCollection) geom;
+ for (int i = 0; i < collection.getNumGeometries(); i++) {
+ if (!allPolygonalComponentsMatchOrientation(
+ collection.getGeometryN(i), ringOrientationCheck)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ // No polygonal component to violate the orientation for remaining geometry types
+ return true;
+ }
+
public static Geometry triangulatePolygon(Geometry geom) {
return ConstrainedDelaunayTriangulator.triangulate(geom);
}
@@ -1792,29 +1810,22 @@ public class Functions {
}
/**
- * This function accepts Polygon and MultiPolygon, if any other type is provided then it will
- * return false. If the exterior ring is counter-clockwise and the interior ring(s) are clockwise
- * then returns true, otherwise false. Empty Polygon and MultiPolygon inputs return true because
- * they contain no rings with the opposite orientation.
+ * Recursively inspects the polygonal components of the input (Polygon, MultiPolygon, or any
+ * nested GeometryCollection), and returns true if every one of them has a counter-clockwise
+ * exterior ring and clockwise interior ring(s). Non-polygonal components (points, line strings)
+ * are ignored. Inputs with no polygonal components at all, including points, line strings, and
+ * empty geometry collections, vacuously return true, matching PostGIS semantics. Empty Polygon
+ * and MultiPolygon inputs also return true because they contain no rings with the opposite
+ * orientation.
*
- * @param geom Polygon or MultiPolygon
+ * @param geom any geometry
* @return
*/
public static boolean isPolygonCCW(Geometry geom) {
- if (geom instanceof MultiPolygon) {
- MultiPolygon multiPolygon = (MultiPolygon) geom;
-
- for (int i = 0; i < multiPolygon.getNumGeometries(); i++) {
- if (!checkIfPolygonCCW((Polygon) multiPolygon.getGeometryN(i))) {
- return false;
- }
- }
- return true;
- } else if (geom instanceof Polygon) {
- return checkIfPolygonCCW((Polygon) geom);
+ if (geom == null) {
+ return false;
}
- // False for remaining geometry types
- return false;
+ return allPolygonalComponentsMatchOrientation(geom, Functions::checkIfPolygonCCW);
}
private static boolean checkIfPolygonCCW(Polygon geom) {
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 cee08f7137c..ac1ea2c2b91 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -1926,11 +1926,13 @@ public class FunctionsTest extends TestBase {
0);
assertTrue(Functions.isPolygonCW(mPoly));
+ // Non-polygonal geometries have no polygonal component to violate the orientation, so
+ // PostGIS (and Sedona 2.0.0+) vacuously returns true for them.
Geometry point = Constructors.geomFromWKT("POINT (45 20)", 0);
- assertFalse(Functions.isPolygonCW(point));
+ assertTrue(Functions.isPolygonCW(point));
Geometry lineClosed = Constructors.geomFromWKT("LINESTRING (30 20, 20 25, 20 15, 30 20)", 0);
- assertFalse(Functions.isPolygonCW(lineClosed));
+ assertTrue(Functions.isPolygonCW(lineClosed));
}
@Test
@@ -1954,11 +1956,13 @@ public class FunctionsTest extends TestBase {
0);
assertTrue(Functions.isPolygonCCW(mPoly));
+ // Non-polygonal geometries have no polygonal component to violate the orientation, so
+ // PostGIS (and Sedona 2.0.0+) vacuously returns true for them.
Geometry point = Constructors.geomFromWKT("POINT (45 20)", 0);
- assertFalse(Functions.isPolygonCCW(point));
+ assertTrue(Functions.isPolygonCCW(point));
Geometry lineClosed = Constructors.geomFromWKT("LINESTRING (30 20, 20 25, 20 15, 30 20)", 0);
- assertFalse(Functions.isPolygonCCW(lineClosed));
+ assertTrue(Functions.isPolygonCCW(lineClosed));
}
@Test
@@ -1985,6 +1989,44 @@ public class FunctionsTest extends TestBase {
assertTrue(Functions.isPolygonCCW(counterClockwiseWithEmpty));
}
+ @Test
+ public void testIsPolygonOrientationRecursesIntoGeometryCollections() throws ParseException {
+ // Empty geometry collections have no polygonal components: vacuously true for both.
+ Geometry emptyCollection = Constructors.geomFromWKT("GEOMETRYCOLLECTION EMPTY", 0);
+ assertTrue(Functions.isPolygonCW(emptyCollection));
+ assertTrue(Functions.isPolygonCCW(emptyCollection));
+
+ // A point paired with a nested collection containing a clockwise polygon: the point is
+ // ignored, and the polygon is found by recursing into the nested collection.
+ Geometry nestedCW =
+ Constructors.geomFromWKT(
+ "GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))))",
+ 0);
+ assertTrue(Functions.isPolygonCW(nestedCW));
+ assertFalse(Functions.isPolygonCCW(nestedCW));
+
+ Geometry nestedCCW =
+ Constructors.geomFromWKT(
+ "GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))))",
+ 0);
+ assertFalse(Functions.isPolygonCW(nestedCCW));
+ assertTrue(Functions.isPolygonCCW(nestedCCW));
+
+ // A collection mixing a clockwise and a counter-clockwise polygon matches neither predicate.
+ Geometry mixedOrientation =
+ Constructors.geomFromWKT(
+ "GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)), POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10)))",
+ 0);
+ assertFalse(Functions.isPolygonCW(mixedOrientation));
+ assertFalse(Functions.isPolygonCCW(mixedOrientation));
+ }
+
+ @Test
+ public void testIsPolygonOrientationHandlesNullGeometry() {
+ assertFalse(Functions.isPolygonCW(null));
+ assertFalse(Functions.isPolygonCCW(null));
+ }
+
@Test
public void testTriangulatePolygon() throws ParseException {
Geometry geom =
diff --git a/docs/api/flink/Geometry-Accessors/ST_IsPolygonCCW.md b/docs/api/flink/Geometry-Accessors/ST_IsPolygonCCW.md
index 13f44ba9aa8..90f4ad7eda4 100644
--- a/docs/api/flink/Geometry-Accessors/ST_IsPolygonCCW.md
+++ b/docs/api/flink/Geometry-Accessors/ST_IsPolygonCCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs, and its Flink wrapper's return type changed from a primitive `boolean` to a nullable `Boolean` so `NULL` inputs propagate correctly. Earlier versions returned `false` for non-polygonal/collection inputs and for `NULL`. See the [release notes](../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/api/flink/Geometry-Accessors/ST_IsPolygonCW.md b/docs/api/flink/Geometry-Accessors/ST_IsPolygonCW.md
index 5d11118b159..51a3d41c47c 100644
--- a/docs/api/flink/Geometry-Accessors/ST_IsPolygonCW.md
+++ b/docs/api/flink/Geometry-Accessors/ST_IsPolygonCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs, and its Flink wrapper's return type changed from a primitive `boolean` to a nullable `Boolean` so `NULL` inputs propagate correctly. Earlier versions returned `false` for non-polygonal/collection inputs and for `NULL`. See the [release notes](../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCCW.md b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCCW.md
index 6f74c65360f..6d9c8f0df85 100644
--- a/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCCW.md
+++ b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs. Earlier versions returned `false` for such inputs instead of recursing into them; see the [release notes](../../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCW.md b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCW.md
index b3a8abdccbc..e38ddb233d0 100644
--- a/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCW.md
+++ b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsPolygonCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs. Earlier versions returned `false` for such inputs instead of recursing into them; see the [release notes](../../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/api/sql/Geometry-Accessors/ST_IsPolygonCCW.md b/docs/api/sql/Geometry-Accessors/ST_IsPolygonCCW.md
index 13f44ba9aa8..a36f917172f 100644
--- a/docs/api/sql/Geometry-Accessors/ST_IsPolygonCCW.md
+++ b/docs/api/sql/Geometry-Accessors/ST_IsPolygonCCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs. Earlier versions returned `false` for such inputs instead of recursing into them; see the [release notes](../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/api/sql/Geometry-Accessors/ST_IsPolygonCW.md b/docs/api/sql/Geometry-Accessors/ST_IsPolygonCW.md
index 5d11118b159..9fa76cbd663 100644
--- a/docs/api/sql/Geometry-Accessors/ST_IsPolygonCW.md
+++ b/docs/api/sql/Geometry-Accessors/ST_IsPolygonCW.md
@@ -19,9 +19,12 @@
# ST_IsPolygonCW
-Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise.
+Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. Polygonal components nested inside a `GEOMETRYCOLLECTION`, at any depth, are recursively inspected. Non-polygonal components (points, line strings, etc.) are ignored.
-`POLYGON EMPTY` and `MULTIPOLYGON EMPTY` return `true` because they contain no rings with the opposite orientation.
+An input with no polygonal components at all - points, line strings, and empty geometry collections included - vacuously returns `true`, since there is no ring to violate the requested orientation. This also covers `POLYGON EMPTY` and `MULTIPOLYGON EMPTY`. A SQL `NULL` input returns `NULL`.
+
+!!!note
+ Starting with Sedona 2.0.0, `ST_IsPolygonCW` matches PostGIS behavior for non-polygonal and `GEOMETRYCOLLECTION` inputs. Earlier versions returned `false` for such inputs instead of recursing into them; see the [release notes](../../../setup/release-notes.md#sedona-200) for details.

diff --git a/docs/setup/release-notes.md b/docs/setup/release-notes.md
index f3f3646f1d4..49e1a4857ff 100644
--- a/docs/setup/release-notes.md
+++ b/docs/setup/release-notes.md
@@ -30,6 +30,11 @@
whose state contains a serialized `ST_MinimumBoundingRadius` result cannot restore from a
checkpoint or savepoint taken with the previous serializer** — such jobs need to be restarted
without state, or migrated before upgrading.
+* **`ST_IsPolygonCW` and `ST_IsPolygonCCW`**: these predicates now match PostGIS by
+ recursively inspecting polygonal components in nested `GeometryCollection` values and returning
+ `true` when an input has no polygonal components; earlier releases returned `false` for those
+ inputs. Flink SQL `NULL` now returns `NULL`, matching Spark and Snowflake, and its wrappers return
+ nullable `Boolean` instead of primitive `boolean`.
* **GeoPandas `set_crs`**: `GeoSeries.set_crs` and `GeoDataFrame.set_crs` now default to
`allow_override=False`, matching GeoPandas. Replacing or removing an existing CRS now requires
`allow_override=True`; direct `.crs` assignment remains an explicit override. Calls using the
diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
index fe767355a99..fff058f196c 100644
--- a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
+++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
@@ -2051,12 +2051,15 @@ public class Functions {
public static class ST_IsPolygonCW extends ScalarFunction {
@DataTypeHint("Boolean")
- public boolean eval(
+ public Boolean eval(
@DataTypeHint(
value = "RAW",
rawSerializer = GeometryTypeSerializer.class,
bridgedTo = Geometry.class)
Object o) {
+ if (o == null) {
+ return null;
+ }
Geometry geom = (Geometry) o;
return org.apache.sedona.common.Functions.isPolygonCW(geom);
}
@@ -3544,12 +3547,15 @@ public class Functions {
public static class ST_IsPolygonCCW extends ScalarFunction {
@DataTypeHint("Boolean")
- public boolean eval(
+ public Boolean eval(
@DataTypeHint(
value = "RAW",
rawSerializer = GeometryTypeSerializer.class,
bridgedTo = Geometry.class)
Object o) {
+ if (o == null) {
+ return null;
+ }
Geometry geom = (Geometry) o;
return org.apache.sedona.common.Functions.isPolygonCCW(geom);
}
diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
index 50f3b360933..75a1263ba93 100644
--- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
+++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
@@ -2749,6 +2749,42 @@ public class FunctionTest extends TestBase {
}
}
+ @Test
+ public void testIsPolygonOrientationForNonPolygonalAndCollectionGeometries() {
+ // Non-polygonal geometries and geometry collections with no polygonal components have
+ // nothing to violate the orientation, so PostGIS parity requires true here. A collection
+ // is also recursed into to find a nested polygonal component.
+ Table result =
+ tableEnv.sqlQuery(
+ "SELECT "
+ + "ST_IsPolygonCW(ST_GeomFromWKT('POINT (0 0)')), "
+ + "ST_IsPolygonCCW(ST_GeomFromWKT('POINT (0 0)')), "
+ + "ST_IsPolygonCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)')), "
+ + "ST_IsPolygonCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)')), "
+ + "ST_IsPolygonCW(ST_GeomFromWKT('GEOMETRYCOLLECTION EMPTY')), "
+ + "ST_IsPolygonCCW(ST_GeomFromWKT('GEOMETRYCOLLECTION EMPTY')), "
+ + "ST_IsPolygonCW(ST_GeomFromWKT("
+ + "'GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))))'))");
+
+ Row row = first(result);
+ for (int i = 0; i < row.getArity(); i++) {
+ assertTrue((boolean) row.getField(i));
+ }
+ }
+
+ @Test
+ public void testIsPolygonOrientationNullPropagation() {
+ Table result =
+ tableEnv.sqlQuery(
+ "SELECT "
+ + "ST_IsPolygonCW(ST_GeomFromWKT(CAST(NULL AS STRING))), "
+ + "ST_IsPolygonCCW(ST_GeomFromWKT(CAST(NULL AS STRING)))");
+
+ Row row = first(result);
+ assertNull(row.getField(0));
+ assertNull(row.getField(1));
+ }
+
@Test
public void testTranslate() {
Table polyTable =
diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
index d0ba12a3ad5..47a8d8d0f68 100644
--- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
+++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
@@ -1297,6 +1297,18 @@ public class TestFunctions extends TestBase {
"SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT('POLYGON EMPTY'))", true);
verifySqlSingleRes(
"SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT('MULTIPOLYGON EMPTY'))", true);
+ // Non-polygonal geometries and geometry collections with no polygonal components have
+ // nothing to violate the orientation, so PostGIS parity requires true here.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT('POINT (0 0)'))", true);
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)'))", true);
+ // Recurses into nested geometry collections to find the polygonal component.
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT("
+ + "'GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))))'))",
+ true);
+ // Snowflake short-circuits SQL NULL input before invoking the handler.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT(NULL))", null);
}
@Test
@@ -1325,6 +1337,18 @@ public class TestFunctions extends TestBase {
"SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT('POLYGON EMPTY'))", true);
verifySqlSingleRes(
"SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT('MULTIPOLYGON EMPTY'))", true);
+ // Non-polygonal geometries and geometry collections with no polygonal components have
+ // nothing to violate the orientation, so PostGIS parity requires true here.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT('POINT (0 0)'))", true);
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)'))", true);
+ // Recurses into nested geometry collections to find the polygonal component.
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT("
+ + "'GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))))'))",
+ true);
+ // Snowflake short-circuits SQL NULL input before invoking the handler.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT(NULL))", null);
}
@Test
diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
index 24c8e664fea..3a94b873aca 100644
--- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
+++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
@@ -1239,9 +1239,15 @@ public class TestFunctionsV2 extends TestBase {
verifySqlSingleRes(
"SELECT sedona.ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))",
true);
+ // Non-polygonal geometries have no polygonal component to violate the orientation, so
+ // PostGIS parity requires true here.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCW(ST_GeomFromWKT('POINT (0 0)'))", true);
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)'))", true);
// Empty geometries cannot be tested here: Snowflake's native GEOMETRY parses
// 'POLYGON EMPTY' to NULL, so the UDF never receives an empty geometry.
- // Empty inputs are covered by the WKB-based tests in TestFunctions.
+ // Empty inputs, and geometry-collection recursion, are covered by the WKB-based tests
+ // in TestFunctions.
}
@Test
@@ -1273,9 +1279,15 @@ public class TestFunctionsV2 extends TestBase {
verifySqlSingleRes(
"SELECT sedona.ST_IsPolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))",
true);
+ // Non-polygonal geometries have no polygonal component to violate the orientation, so
+ // PostGIS parity requires true here.
+ verifySqlSingleRes("SELECT sedona.ST_IsPolygonCCW(ST_GeomFromWKT('POINT (0 0)'))", true);
+ verifySqlSingleRes(
+ "SELECT sedona.ST_IsPolygonCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)'))", true);
// Empty geometries cannot be tested here: Snowflake's native GEOMETRY parses
// 'POLYGON EMPTY' to NULL, so the UDF never receives an empty geometry.
- // Empty inputs are covered by the WKB-based tests in TestFunctions.
+ // Empty inputs, and geometry-collection recursion, are covered by the WKB-based tests
+ // in TestFunctions.
}
@Test
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 4870eb5e4b0..6e0bbd5c69b 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
@@ -1577,6 +1577,29 @@ class functionTestScala
(0 until 4).foreach(index => assertTrue(actual.getBoolean(index)))
}
+ it(
+ "Should align ST_IsPolygonCW/ST_IsPolygonCCW with PostGIS for non-polygonal and collection inputs") {
+ val actual = sparkSession
+ .sql("""
+ |SELECT
+ | ST_IsPolygonCW(ST_GeomFromWKT('POINT (0 0)')),
+ | ST_IsPolygonCCW(ST_GeomFromWKT('POINT (0 0)')),
+ | ST_IsPolygonCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)')),
+ | ST_IsPolygonCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 0 0)')),
+ | ST_IsPolygonCW(ST_GeomFromWKT('GEOMETRYCOLLECTION EMPTY')),
+ | ST_IsPolygonCCW(ST_GeomFromWKT('GEOMETRYCOLLECTION EMPTY')),
+ | ST_IsPolygonCW(ST_GeomFromWKT(
+ | 'GEOMETRYCOLLECTION (POINT (2 2), GEOMETRYCOLLECTION (POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))))')),
+ | ST_IsPolygonCW(null),
+ | ST_IsPolygonCCW(null)
+ |""".stripMargin)
+ .first()
+
+ (0 until 7).foreach(index => assertTrue(actual.getBoolean(index)))
+ assertTrue(actual.isNullAt(7))
+ assertTrue(actual.isNullAt(8))
+ }
+
it("Should pass ST_IsLineStringCCW") {
val actual = sparkSession
.sql("""