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 boolean isPoint() { 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 static String asText(Geography g) { 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 void nPoints_polygon() throws ParseException { 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 void fromWKB_point_lazyParse() throws ParseException { 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 void serialize_deserialize_emptyPoint() throws IOException, ParseExceptio 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 void ewkbPoint_withSRIDFlag_decodesCorrectly() throws ParseException { // 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 void isoPointZ_throwsUnsupported() { 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 @@ + + +# 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 @@ + + +# 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 ──────────────────────────