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/07/28 03:36:27 UTC

(sedona) branch master updated: [GH-3179] Add Geography support for ST_X and ST_Y (#3188)

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 af9acce09a8 [GH-3179] Add Geography support for ST_X and ST_Y (#3188)
af9acce09a8 is described below

commit af9acce09a8937b7f0a1987adfb367a6f6433891
Author: Jia Yu <ji...@apache.org>
AuthorDate: Mon Jul 27 20:36:22 2026 -0700

    [GH-3179] Add Geography support for ST_X and ST_Y (#3188)
---
 .../sedona/common/S2Geography/WKBGeography.java    | 28 +++++++++++++++
 .../apache/sedona/common/geography/Functions.java  | 14 ++++++++
 .../sedona/common/Geography/FunctionTest.java      | 19 ++++++++++
 .../common/S2Geography/WKBGeographyTest.java       |  8 +++++
 docs/api/sql/geography/Geography-Functions.md      |  2 ++
 docs/api/sql/geography/Geography-Functions/ST_X.md | 42 ++++++++++++++++++++++
 docs/api/sql/geography/Geography-Functions/ST_Y.md | 42 ++++++++++++++++++++++
 .../sql/sedona_sql/expressions/Functions.scala     |  8 +++--
 .../sql/geography/GeographyFunctionTest.scala      | 24 +++++++++++++
 9 files changed, 185 insertions(+), 2 deletions(-)

diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java
index 2b4adec8a23..b730ba52f69 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java
@@ -245,6 +245,34 @@ public class WKBGeography extends Geography {
     return wkbBaseType() == 1;
   }
 
+  /** Returns the X coordinate directly from Point WKB, or null for non-points and empty points. */
+  public Double getPointX() {
+    return getPointOrdinate(0);
+  }
+
+  /** Returns the Y coordinate directly from Point WKB, or null for non-points and empty points. */
+  public Double getPointY() {
+    return getPointOrdinate(1);
+  }
+
+  /**
+   * Reads X or Y directly from the WKB payload. Z/M ordinates are intentionally tolerated and
+   * ignored so these accessors match Geometry ST_X/ST_Y behavior for higher-dimensional points.
+   */
+  private Double getPointOrdinate(int ordinate) {
+    if (!isPoint()) return null;
+
+    boolean le = (wkbBytes[0] == 0x01);
+    int coordOffset = wkbPayloadOffset();
+    ByteBuffer bb =
+        ByteBuffer.wrap(wkbBytes).order(le ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN);
+    double x = bb.getDouble(coordOffset);
+    double y = bb.getDouble(coordOffset + Double.BYTES);
+    // JTS treats a point as empty when either X or Y is NaN.
+    if (Double.isNaN(x) || Double.isNaN(y)) return null;
+    return ordinate == 0 ? x : y;
+  }
+
   /** Extract the S2Point from a Point WKB without full S2 parse. */
   public S2Point extractPoint() {
     requireXYOnly();
diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
index 5d484afc4b6..93a88bc7ae8 100644
--- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
@@ -183,6 +183,20 @@ public class Functions {
     return toJTS(g).toText();
   }
 
+  /** Return the longitude (X coordinate) of a point geography, or null for non-point inputs. */
+  public static Double x(Geography g) {
+    if (g == null) return null;
+    if (g instanceof WKBGeography) return ((WKBGeography) g).getPointX();
+    return org.apache.sedona.common.Functions.x(toJTS(g));
+  }
+
+  /** Return the latitude (Y coordinate) of a point geography, or null for non-point inputs. */
+  public static Double y(Geography g) {
+    if (g == null) return null;
+    if (g instanceof WKBGeography) return ((WKBGeography) g).getPointY();
+    return org.apache.sedona.common.Functions.y(toJTS(g));
+  }
+
   // ─── Level 2: Geodesic metrics ───────────────────────────────────────────
 
   /**
diff --git a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
index b70bb6f814e..3f0432b5758 100644
--- a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
+++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
@@ -148,6 +148,25 @@ public class FunctionTest {
     assertEquals(5, Functions.nPoints(g));
   }
 
+  @Test
+  public void xY_point() throws ParseException {
+    Geography g = Constructors.geogFromWKT("POINT (-73.9857 40.7484)", 4326);
+    assertEquals(-73.9857, Functions.x(g), 1e-12);
+    assertEquals(40.7484, Functions.y(g), 1e-12);
+  }
+
+  @Test
+  public void xY_nonPointAndNull() throws ParseException {
+    Geography line = Constructors.geogFromWKT("LINESTRING (0 0, 1 1)", 4326);
+    Geography emptyPoint = Constructors.geogFromWKT("POINT EMPTY", 4326);
+    assertNull(Functions.x(line));
+    assertNull(Functions.y(line));
+    assertNull(Functions.x(emptyPoint));
+    assertNull(Functions.y(emptyPoint));
+    assertNull(Functions.x(null));
+    assertNull(Functions.y(null));
+  }
+
   // S2 area-weighted centroids on small polygons differ from planar by an O(d^2/R^2)
   // spherical correction. 5e-3 deg (~500 m) is wide enough to absorb that drift on
   // 1°-scale shapes near origin, while still catching any real bug (a planar/JTS centroid
diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java
index dd5ededd87e..165cdecd9bb 100644
--- a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java
@@ -49,6 +49,8 @@ public class WKBGeographyTest {
     WKBGeography geog = WKBGeography.fromWKB(wkb, 4326);
     assertEquals(4326, geog.getSRID());
     assertSame(wkb, geog.getWKBBytes());
+    assertEquals(30.0, geog.getPointX(), EPS);
+    assertEquals(10.0, geog.getPointY(), EPS);
 
     // Accessing JTS should parse lazily
     Geometry jts = geog.getJTSGeometry();
@@ -245,6 +247,8 @@ public class WKBGeographyTest {
     byte[] bytes = GeographyWKBSerializer.serialize(original);
     Geography deserialized = GeographyWKBSerializer.deserialize(bytes);
     assertTrue(deserialized instanceof WKBGeography);
+    assertNull(((WKBGeography) deserialized).getPointX());
+    assertNull(((WKBGeography) deserialized).getPointY());
   }
 
   // ─── Serialize S2 Geography via new serializer ────────────────────────────
@@ -418,6 +422,8 @@ public class WKBGeographyTest {
     // isPoint() must recognize the base type after stripping the EWKB SRID flag.
     assertTrue(geog.isPoint());
     assertEquals(0, geog.dimension());
+    assertEquals(30.0, geog.getPointX(), EPS);
+    assertEquals(10.0, geog.getPointY(), EPS);
 
     // extractPoint() must skip the 4 SRID bytes; lon/lat should be the original values.
     S2Point p = geog.extractPoint();
@@ -432,6 +438,8 @@ public class WKBGeographyTest {
     WKBGeography geog = WKBGeography.fromWKB(wkbZ, 0);
     // isPoint() is safe — just tests base type — but extractPoint/shape must refuse Z/M.
     assertTrue(geog.isPoint());
+    assertEquals(30.0, geog.getPointX(), EPS);
+    assertEquals(10.0, geog.getPointY(), EPS);
     assertThrows(UnsupportedOperationException.class, geog::extractPoint);
     assertThrows(UnsupportedOperationException.class, () -> new WkbS2Shape(wkbZ));
   }
diff --git a/docs/api/sql/geography/Geography-Functions.md b/docs/api/sql/geography/Geography-Functions.md
index 122e596e7c4..b07ecf5d6ce 100644
--- a/docs/api/sql/geography/Geography-Functions.md
+++ b/docs/api/sql/geography/Geography-Functions.md
@@ -57,3 +57,5 @@ These functions operate on geography type objects.
 | [ST_DWithin](Geography-Functions/ST_DWithin.md) | Boolean | Test whether two geographies are within a given geodesic distance (in meters) of each other. | v1.9.1 |
 | [ST_Within](Geography-Functions/ST_Within.md) | Boolean | Test whether geography A is fully within geography B. | v1.9.1 |
 | [ST_Equals](Geography-Functions/ST_Equals.md) | Boolean | Test whether two geographies are spatially equal. | v1.9.1 |
+| [ST_X](Geography-Functions/ST_X.md) | Double | Return the longitude (X coordinate) of a point geography. | v1.9.1 |
+| [ST_Y](Geography-Functions/ST_Y.md) | Double | Return the latitude (Y coordinate) of a point geography. | v1.9.1 |
diff --git a/docs/api/sql/geography/Geography-Functions/ST_X.md b/docs/api/sql/geography/Geography-Functions/ST_X.md
new file mode 100644
index 00000000000..a1793308396
--- /dev/null
+++ b/docs/api/sql/geography/Geography-Functions/ST_X.md
@@ -0,0 +1,42 @@
+<!--
+ 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.
+ -->
+
+# ST_X
+
+Introduction: Returns the longitude (X coordinate) of a point geography. Returns `NULL` for non-point geographies and empty points.
+
+Format:
+
+`ST_X (A: Geography)`
+
+Return type: `Double`
+
+Since: `v1.9.1`
+
+SQL Example
+
+```sql
+SELECT ST_X(ST_GeogFromWKT('POINT (-73.9857 40.7484)'));
+```
+
+Output:
+
+```
+-73.9857
+```
diff --git a/docs/api/sql/geography/Geography-Functions/ST_Y.md b/docs/api/sql/geography/Geography-Functions/ST_Y.md
new file mode 100644
index 00000000000..b4922c0f078
--- /dev/null
+++ b/docs/api/sql/geography/Geography-Functions/ST_Y.md
@@ -0,0 +1,42 @@
+<!--
+ 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.
+ -->
+
+# ST_Y
+
+Introduction: Returns the latitude (Y coordinate) of a point geography. Returns `NULL` for non-point geographies and empty points.
+
+Format:
+
+`ST_Y (A: Geography)`
+
+Return type: `Double`
+
+Since: `v1.9.1`
+
+SQL Example
+
+```sql
+SELECT ST_Y(ST_GeogFromWKT('POINT (-73.9857 40.7484)'));
+```
+
+Output:
+
+```
+40.7484
+```
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 a80e2eada81..83cd65ec3c4 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
@@ -736,7 +736,9 @@ private[apache] case class ST_Azimuth(inputExpressions: Seq[Expression])
 }
 
 private[apache] case class ST_X(inputExpressions: Seq[Expression])
-    extends InferredExpression(Functions.x _) {
+    extends InferredExpression(
+      inferrableFunction1(Functions.x),
+      inferrableFunction1(org.apache.sedona.common.geography.Functions.x)) {
 
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
     copy(inputExpressions = newChildren)
@@ -744,7 +746,9 @@ private[apache] case class ST_X(inputExpressions: Seq[Expression])
 }
 
 private[apache] case class ST_Y(inputExpressions: Seq[Expression])
-    extends InferredExpression(Functions.y _) {
+    extends InferredExpression(
+      inferrableFunction1(Functions.y),
+      inferrableFunction1(org.apache.sedona.common.geography.Functions.y)) {
 
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
     copy(inputExpressions = newChildren)
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
index 18e89d7c2a7..8f5ea19a288 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala
@@ -141,6 +141,30 @@ class GeographyFunctionTest extends TestBaseScala {
       assertEquals(1.0, point.getX, 1e-9)
       assertEquals(2.0, point.getY, 1e-9)
     }
+
+    it("ST_X and ST_Y") {
+      val row = sparkSession
+        .sql("""
+          SELECT
+            ST_X(ST_GeogFromWKT('POINT (-73.9857 40.7484)', 4326)) AS x,
+            ST_Y(ST_GeogFromWKT('POINT (-73.9857 40.7484)', 4326)) AS y
+        """)
+        .first()
+      assertEquals(-73.9857, row.getDouble(0), 1e-12)
+      assertEquals(40.7484, row.getDouble(1), 1e-12)
+    }
+
+    it("ST_X and ST_Y return null for non-point geography") {
+      val row = sparkSession
+        .sql("""
+          SELECT
+            ST_X(ST_GeogFromWKT('LINESTRING (0 0, 1 1)', 4326)) AS x,
+            ST_Y(ST_GeogFromWKT('LINESTRING (0 0, 1 1)', 4326)) AS y
+        """)
+        .first()
+      assertTrue(row.isNullAt(0))
+      assertTrue(row.isNullAt(1))
+    }
   }
 
   // ─── Level 2: ST_Length, ST_Area, ST_Distance ──────────────────────────