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.
 
 ![ST_IsPolygonCCW](../../../image/ST_IsPolygonCCW/ST_IsPolygonCCW.svg "ST_IsPolygonCCW")
 
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.
 
 ![ST_IsPolygonCW](../../../image/ST_IsPolygonCW/ST_IsPolygonCW.svg "ST_IsPolygonCW")
 
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.
 
 ![ST_IsPolygonCCW](../../../../image/ST_IsPolygonCCW/ST_IsPolygonCCW.svg "ST_IsPolygonCCW")
 
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.
 
 ![ST_IsPolygonCW](../../../../image/ST_IsPolygonCW/ST_IsPolygonCW.svg "ST_IsPolygonCW")
 
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.
 
 ![ST_IsPolygonCCW](../../../image/ST_IsPolygonCCW/ST_IsPolygonCCW.svg "ST_IsPolygonCCW")
 
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.
 
 ![ST_IsPolygonCW](../../../image/ST_IsPolygonCW/ST_IsPolygonCW.svg "ST_IsPolygonCW")
 
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("""