This is an automated email from the ASF dual-hosted git repository.
jiayu 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 18f30cafca [GH-908] Add ST_GeoHashNeighbors and ST_GeoHashNeighbor
functions (#2628)
18f30cafca is described below
commit 18f30cafca6b38208762511a322181203557980a
Author: Jia Yu <[email protected]>
AuthorDate: Mon Feb 9 02:40:33 2026 -0700
[GH-908] Add ST_GeoHashNeighbors and ST_GeoHashNeighbor functions (#2628)
---
.gitignore | 1 +
.../java/org/apache/sedona/common/Functions.java | 27 ++
.../apache/sedona/common/utils/GeoHashDecoder.java | 4 +-
.../sedona/common/utils/GeoHashNeighbor.java | 274 +++++++++++++++++++++
.../apache/sedona/common/utils/GeoHashUtils.java | 75 ++++++
.../sedona/common/utils/PointGeoHashEncoder.java | 4 +-
.../org/apache/sedona/common/FunctionsTest.java | 113 +++++++++
docs/api/flink/Function.md | 40 +++
docs/api/snowflake/vector-data/Function.md | 48 ++++
docs/api/sql/Function.md | 40 +++
.../main/java/org/apache/sedona/flink/Catalog.java | 2 +
.../apache/sedona/flink/expressions/Functions.java | 14 ++
.../java/org/apache/sedona/flink/FunctionTest.java | 35 +++
python/pyproject.toml | 1 +
python/sedona/spark/sql/st_functions.py | 30 +++
python/tests/sql/test_dataframe_api.py | 26 ++
python/tests/sql/test_function.py | 27 ++
.../sedona/snowflake/snowsql/TestFunctions.java | 13 +
.../sedona/snowflake/snowsql/TestFunctionsV2.java | 13 +
.../org/apache/sedona/snowflake/snowsql/UDFs.java | 10 +
.../apache/sedona/snowflake/snowsql/UDFsV2.java | 14 ++
.../scala/org/apache/sedona/sql/UDF/Catalog.scala | 2 +
.../sql/sedona_sql/expressions/Functions.scala | 16 ++
.../expressions/InferredExpression.scala | 28 +++
.../sql/sedona_sql/expressions/st_functions.scala | 14 ++
.../apache/sedona/sql/dataFrameAPITestScala.scala | 37 +++
.../org/apache/sedona/sql/functionTestScala.scala | 33 +++
27 files changed, 937 insertions(+), 4 deletions(-)
diff --git a/.gitignore b/.gitignore
index 6f5f1e8d08..cd609e393b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,4 @@ target
docs-overrides/node_modules/
uv.lock
+.env
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 7af2bc5224..62a42e5384 100644
--- a/common/src/main/java/org/apache/sedona/common/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/Functions.java
@@ -746,6 +746,33 @@ public class Functions {
return GeometryGeoHashEncoder.calculate(geometry, precision);
}
+ /**
+ * Returns the 8 neighbors of the given geohash in the order: [N, NE, E, SE,
S, SW, W, NW].
+ *
+ * @param geohash a geohash string (1-12 characters, lowercase base32)
+ * @return an array of 8 neighbor geohash strings, or null if the input is
null or empty
+ * @throws IllegalArgumentException if the geohash exceeds 12 characters
+ */
+ public static String[] geohashNeighbors(String geohash) {
+ return GeoHashNeighbor.getNeighbors(geohash);
+ }
+
+ /**
+ * Returns a single neighbor of the given geohash in the specified direction.
+ *
+ * <p>Accepted direction values (case-insensitive): {@code "N"}, {@code
"NE"}, {@code "E"}, {@code
+ * "SE"}, {@code "S"}, {@code "SW"}, {@code "W"}, {@code "NW"}.
+ *
+ * @param geohash a geohash string (1-12 characters, lowercase base32)
+ * @param direction the compass direction of the desired neighbor
+ * @return the neighbor geohash string, or null if either input is null or
empty
+ * @throws IllegalArgumentException if the geohash exceeds 12 characters or
the direction is
+ * invalid
+ */
+ public static String geohashNeighbor(String geohash, String direction) {
+ return GeoHashNeighbor.getNeighbor(geohash, direction);
+ }
+
public static Geometry pointOnSurface(Geometry geometry) {
return GeomUtils.getInteriorPoint(geometry);
}
diff --git
a/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java
b/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java
index b28070310c..9842038c31 100644
--- a/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java
+++ b/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java
@@ -26,8 +26,8 @@ import org.apache.sedona.common.S2Geography.PolygonGeography;
import org.locationtech.jts.geom.Geometry;
public class GeoHashDecoder {
- private static final int[] bits = new int[] {16, 8, 4, 2, 1};
- private static final String base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
+ private static final int[] bits = GeoHashUtils.BITS;
+ private static final String base32 = GeoHashUtils.BASE32;
public static class InvalidGeoHashException extends Exception {
public InvalidGeoHashException(String message) {
diff --git
a/common/src/main/java/org/apache/sedona/common/utils/GeoHashNeighbor.java
b/common/src/main/java/org/apache/sedona/common/utils/GeoHashNeighbor.java
new file mode 100644
index 0000000000..75d1266f2d
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/utils/GeoHashNeighbor.java
@@ -0,0 +1,274 @@
+/*
+ * 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.
+ */
+package org.apache.sedona.common.utils;
+
+/**
+ * Utility class for computing geohash neighbors.
+ *
+ * <p>The neighbor algorithm is ported from the geohash-java library by Silvio
Heuberger (Apache 2.0
+ * License). It works by de-interleaving the geohash bits into separate
latitude/longitude
+ * components, incrementing/decrementing the appropriate component, masking
for wrap-around, and
+ * re-interleaving back to a geohash string.
+ *
+ * @see <a href="https://github.com/kungfoo/geohash-java">geohash-java</a>
+ */
+public class GeoHashNeighbor {
+
+ private static final int MAX_BIT_PRECISION = 64;
+ private static final long FIRST_BIT_FLAGGED = 0x8000000000000000L;
+
+ // Shared base32 constants from GeoHashUtils
+ private static final char[] base32 = GeoHashUtils.BASE32_CHARS;
+ private static final int[] decodeArray = GeoHashUtils.DECODE_ARRAY;
+
+ /**
+ * Returns all 8 neighbors of the given geohash in the order: [N, NE, E, SE,
S, SW, W, NW].
+ *
+ * @param geohash the geohash string
+ * @return array of 8 neighbor geohash strings, or null if input is null
+ */
+ public static String[] getNeighbors(String geohash) {
+ if (geohash == null) {
+ return null;
+ }
+
+ long[] parsed = parseGeohash(geohash);
+ long bits = parsed[0];
+ int significantBits = (int) parsed[1];
+
+ // Ported from geohash-java: GeoHash.getAdjacent()
+ String northern = neighborLat(bits, significantBits, 1);
+ String eastern = neighborLon(bits, significantBits, 1);
+ String southern = neighborLat(bits, significantBits, -1);
+ String western = neighborLon(bits, significantBits, -1);
+
+ // Diagonal neighbors: compose two cardinal moves
+ long[] northBits = parseGeohash(northern);
+ long[] southBits = parseGeohash(southern);
+
+ return new String[] {
+ northern,
+ neighborLon(northBits[0], significantBits, 1), // NE
+ eastern,
+ neighborLon(southBits[0], significantBits, 1), // SE
+ southern,
+ neighborLon(southBits[0], significantBits, -1), // SW
+ western,
+ neighborLon(northBits[0], significantBits, -1) // NW
+ };
+ }
+
+ /**
+ * Returns the neighbor of the given geohash in the specified direction.
+ *
+ * @param geohash the geohash string
+ * @param direction compass direction: "n", "ne", "e", "se", "s", "sw", "w",
"nw"
+ * (case-insensitive)
+ * @return the neighbor geohash string, or null if input geohash is null
+ */
+ public static String getNeighbor(String geohash, String direction) {
+ if (geohash == null) {
+ return null;
+ }
+ if (direction == null) {
+ throw new IllegalArgumentException(
+ "Direction cannot be null. Valid values: n, ne, e, se, s, sw, w,
nw");
+ }
+
+ long[] parsed = parseGeohash(geohash);
+ long bits = parsed[0];
+ int significantBits = (int) parsed[1];
+
+ switch (direction.toLowerCase()) {
+ case "n":
+ return neighborLat(bits, significantBits, 1);
+ case "s":
+ return neighborLat(bits, significantBits, -1);
+ case "e":
+ return neighborLon(bits, significantBits, 1);
+ case "w":
+ return neighborLon(bits, significantBits, -1);
+ case "ne":
+ {
+ long[] n = parseGeohash(neighborLat(bits, significantBits, 1));
+ return neighborLon(n[0], significantBits, 1);
+ }
+ case "se":
+ {
+ long[] s = parseGeohash(neighborLat(bits, significantBits, -1));
+ return neighborLon(s[0], significantBits, 1);
+ }
+ case "sw":
+ {
+ long[] s = parseGeohash(neighborLat(bits, significantBits, -1));
+ return neighborLon(s[0], significantBits, -1);
+ }
+ case "nw":
+ {
+ long[] n = parseGeohash(neighborLat(bits, significantBits, 1));
+ return neighborLon(n[0], significantBits, -1);
+ }
+ default:
+ throw new IllegalArgumentException(
+ "Invalid direction: '" + direction + "'. Valid values: n, ne, e,
se, s, sw, w, nw");
+ }
+ }
+
+ /**
+ * Parses a geohash string into [bits, significantBits]. Based on
geohash-java
+ * GeoHash.fromGeohashString().
+ */
+ private static long[] parseGeohash(String geohash) {
+ if (geohash.isEmpty()) {
+ throw new IllegalArgumentException("Geohash string cannot be empty");
+ }
+ if (geohash.length() * GeoHashUtils.BITS_PER_CHAR > MAX_BIT_PRECISION) {
+ throw new IllegalArgumentException(
+ "Geohash '"
+ + geohash
+ + "' is too long (max "
+ + (MAX_BIT_PRECISION / GeoHashUtils.BITS_PER_CHAR)
+ + " characters)");
+ }
+ long bits = 0;
+ int significantBits = 0;
+ for (int i = 0; i < geohash.length(); i++) {
+ char c = geohash.charAt(i);
+ int cd;
+ if (c >= decodeArray.length || (cd = decodeArray[c]) < 0) {
+ throw new IllegalArgumentException(
+ "Invalid character '" + c + "' in geohash '" + geohash + "'");
+ }
+ for (int j = 0; j < GeoHashUtils.BITS_PER_CHAR; j++) {
+ significantBits++;
+ bits <<= 1;
+ if ((cd & (16 >> j)) != 0) {
+ bits |= 0x1;
+ }
+ }
+ }
+ bits <<= (MAX_BIT_PRECISION - significantBits);
+ return new long[] {bits, significantBits};
+ }
+
+ // Ported from geohash-java: GeoHash.getNorthernNeighbour() /
getSouthernNeighbour()
+ private static String neighborLat(long bits, int significantBits, int delta)
{
+ long[] latBits = getRightAlignedLatitudeBits(bits, significantBits);
+ long[] lonBits = getRightAlignedLongitudeBits(bits, significantBits);
+ latBits[0] += delta;
+ latBits[0] = maskLastNBits(latBits[0], latBits[1]);
+ return recombineLatLonBitsToBase32(latBits, lonBits);
+ }
+
+ // Ported from geohash-java: GeoHash.getEasternNeighbour() /
getWesternNeighbour()
+ private static String neighborLon(long bits, int significantBits, int delta)
{
+ long[] latBits = getRightAlignedLatitudeBits(bits, significantBits);
+ long[] lonBits = getRightAlignedLongitudeBits(bits, significantBits);
+ lonBits[0] += delta;
+ lonBits[0] = maskLastNBits(lonBits[0], lonBits[1]);
+ return recombineLatLonBitsToBase32(latBits, lonBits);
+ }
+
+ // Ported from geohash-java: GeoHash.getRightAlignedLatitudeBits()
+ private static long[] getRightAlignedLatitudeBits(long bits, int
significantBits) {
+ long copyOfBits = bits << 1;
+ int[] numBits = getNumberOfLatLonBits(significantBits);
+ long value = extractEverySecondBit(copyOfBits, numBits[0]);
+ return new long[] {value, numBits[0]};
+ }
+
+ // Ported from geohash-java: GeoHash.getRightAlignedLongitudeBits()
+ private static long[] getRightAlignedLongitudeBits(long bits, int
significantBits) {
+ int[] numBits = getNumberOfLatLonBits(significantBits);
+ long value = extractEverySecondBit(bits, numBits[1]);
+ return new long[] {value, numBits[1]};
+ }
+
+ // Copied from geohash-java: GeoHash.extractEverySecondBit()
+ private static long extractEverySecondBit(long copyOfBits, int numberOfBits)
{
+ long value = 0;
+ for (int i = 0; i < numberOfBits; i++) {
+ if ((copyOfBits & FIRST_BIT_FLAGGED) == FIRST_BIT_FLAGGED) {
+ value |= 0x1;
+ }
+ value <<= 1;
+ copyOfBits <<= 2;
+ }
+ value >>>= 1;
+ return value;
+ }
+
+ // Copied from geohash-java: GeoHash.getNumberOfLatLonBits()
+ private static int[] getNumberOfLatLonBits(int significantBits) {
+ if (significantBits % 2 == 0) {
+ return new int[] {significantBits / 2, significantBits / 2};
+ } else {
+ return new int[] {significantBits / 2, significantBits / 2 + 1};
+ }
+ }
+
+ // Copied from geohash-java: GeoHash.maskLastNBits()
+ private static long maskLastNBits(long value, long n) {
+ long mask = 0xFFFFFFFFFFFFFFFFL;
+ mask >>>= (MAX_BIT_PRECISION - n);
+ return value & mask;
+ }
+
+ /**
+ * Re-interleaves lat/lon bits and converts to base32 string. Simplified
from geohash-java's
+ * GeoHash.recombineLatLonBitsToHash() — we only need the base32 output, not
the full GeoHash
+ * object with bounding box.
+ */
+ private static String recombineLatLonBitsToBase32(long[] latBits, long[]
lonBits) {
+ int significantBits = (int) (latBits[1] + lonBits[1]);
+ long lat = latBits[0] << (MAX_BIT_PRECISION - latBits[1]);
+ long lon = lonBits[0] << (MAX_BIT_PRECISION - lonBits[1]);
+
+ long bits = 0;
+ boolean isEvenBit = false;
+ for (int i = 0; i < significantBits; i++) {
+ bits <<= 1;
+ if (isEvenBit) {
+ if ((lat & FIRST_BIT_FLAGGED) == FIRST_BIT_FLAGGED) {
+ bits |= 0x1;
+ }
+ lat <<= 1;
+ } else {
+ if ((lon & FIRST_BIT_FLAGGED) == FIRST_BIT_FLAGGED) {
+ bits |= 0x1;
+ }
+ lon <<= 1;
+ }
+ isEvenBit = !isEvenBit;
+ }
+ bits <<= (MAX_BIT_PRECISION - significantBits);
+
+ // Ported from geohash-java: GeoHash.toBase32()
+ StringBuilder buf = new StringBuilder();
+ long firstFiveBitsMask = 0xF800000000000000L;
+ long bitsCopy = bits;
+ int numChars = significantBits / GeoHashUtils.BITS_PER_CHAR;
+ for (int i = 0; i < numChars; i++) {
+ int pointer = (int) ((bitsCopy & firstFiveBitsMask) >>> 59);
+ buf.append(base32[pointer]);
+ bitsCopy <<= 5;
+ }
+ return buf.toString();
+ }
+}
diff --git
a/common/src/main/java/org/apache/sedona/common/utils/GeoHashUtils.java
b/common/src/main/java/org/apache/sedona/common/utils/GeoHashUtils.java
new file mode 100644
index 0000000000..267d6f6ec9
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/utils/GeoHashUtils.java
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+package org.apache.sedona.common.utils;
+
+import java.util.Arrays;
+
+/**
+ * Shared constants and utilities for geohash encoding/decoding.
+ *
+ * <p>Centralizes the base32 alphabet and decode lookup table previously
duplicated across {@link
+ * GeoHashDecoder}, {@link PointGeoHashEncoder}, and {@link GeoHashNeighbor}.
+ */
+public class GeoHashUtils {
+
+ /** The base32 alphabet used by geohash encoding (Gustavo Niemeyer's
specification). */
+ public static final String BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
+
+ /**
+ * Base32 character array for index-based lookup. Package-private to prevent
external mutation.
+ */
+ static final char[] BASE32_CHARS = BASE32.toCharArray();
+
+ /**
+ * Bit masks for extracting 5-bit groups: {16, 8, 4, 2, 1}. Package-private
to prevent external
+ * mutation.
+ */
+ static final int[] BITS = new int[] {16, 8, 4, 2, 1};
+
+ /** Number of bits per base32 character. */
+ public static final int BITS_PER_CHAR = 5;
+
+ /**
+ * Reverse lookup array: maps a character (as int) to its base32 index.
Invalid characters map to
+ * -1. Package-private to prevent external mutation.
+ */
+ static final int[] DECODE_ARRAY = new int['z' + 1];
+
+ static {
+ Arrays.fill(DECODE_ARRAY, -1);
+ for (int i = 0; i < BASE32_CHARS.length; i++) {
+ DECODE_ARRAY[BASE32_CHARS[i]] = i;
+ }
+ }
+
+ /**
+ * Decodes a single base32 character to its integer value (0-31).
+ *
+ * @param c the character to decode
+ * @return the integer value, or -1 if the character is not a valid base32
character
+ */
+ public static int decodeChar(char c) {
+ if (c >= DECODE_ARRAY.length) {
+ return -1;
+ }
+ return DECODE_ARRAY[c];
+ }
+
+ private GeoHashUtils() {}
+}
diff --git
a/common/src/main/java/org/apache/sedona/common/utils/PointGeoHashEncoder.java
b/common/src/main/java/org/apache/sedona/common/utils/PointGeoHashEncoder.java
index e8be8249ed..8ad7113a5a 100644
---
a/common/src/main/java/org/apache/sedona/common/utils/PointGeoHashEncoder.java
+++
b/common/src/main/java/org/apache/sedona/common/utils/PointGeoHashEncoder.java
@@ -21,8 +21,8 @@ package org.apache.sedona.common.utils;
import org.locationtech.jts.geom.Point;
public class PointGeoHashEncoder {
- private static String base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
- private static int[] bits = new int[] {16, 8, 4, 2, 1};
+ private static final String base32 = GeoHashUtils.BASE32;
+ private static final int[] bits = GeoHashUtils.BITS;
public static String calculateGeoHash(Point geom, long precision) {
BBox bbox = new BBox(-180, 180, -90, 90);
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 895d24ead6..61fd47ae34 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -5274,4 +5274,117 @@ public class FunctionsTest extends TestBase {
+ straightSkel.getNumGeometries(),
result.getNumGeometries() <= straightSkel.getNumGeometries());
}
+
+ @Test
+ public void testGeoHashNeighborsKnownValues() {
+ // Known values from geohash-java:
GeoHashTest.testKnownNeighbouringHashes()
+ // Center: "u1pb"
+ assertEquals("u1pc", Functions.geohashNeighbor("u1pb", "n"));
+ assertEquals("u0zz", Functions.geohashNeighbor("u1pb", "s"));
+ assertEquals("u300", Functions.geohashNeighbor("u1pb", "e"));
+ assertEquals("u1p8", Functions.geohashNeighbor("u1pb", "w"));
+
+ // Double east: u1pb -> e -> u300 -> e -> u302
+ assertEquals("u302",
Functions.geohashNeighbor(Functions.geohashNeighbor("u1pb", "e"), "e"));
+ }
+
+ @Test
+ public void testGeoHashNeighborsAllEight() {
+ // Known values from geohash-java:
GeoHashTest.testKnownAdjacentNeighbours()
+ // Center: "dqcjqc"
+ String[] neighbors = Functions.geohashNeighbors("dqcjqc");
+ assertNotNull(neighbors);
+ assertEquals(8, neighbors.length);
+ // Verify all 8 expected values are present
+ java.util.Set<String> expected =
+ new java.util.HashSet<>(
+ java.util.Arrays.asList(
+ "dqcjqf", "dqcjr4", "dqcjr1", "dqcjr0", "dqcjq9", "dqcjq8",
"dqcjqb", "dqcjqd"));
+ java.util.Set<String> actual = new
java.util.HashSet<>(java.util.Arrays.asList(neighbors));
+ assertEquals(expected, actual);
+
+ // Center: "u1x0dfg" (7-char precision)
+ neighbors = Functions.geohashNeighbors("u1x0dfg");
+ assertNotNull(neighbors);
+ assertEquals(8, neighbors.length);
+ expected =
+ new java.util.HashSet<>(
+ java.util.Arrays.asList(
+ "u1x0dg4", "u1x0dg5", "u1x0dgh", "u1x0dfu", "u1x0dfs",
"u1x0dfe", "u1x0dfd",
+ "u1x0dff"));
+ actual = new java.util.HashSet<>(java.util.Arrays.asList(neighbors));
+ assertEquals(expected, actual);
+
+ // Center: "sp2j" (near prime meridian — neighbors cross into "ezr*"
prefix)
+ neighbors = Functions.geohashNeighbors("sp2j");
+ assertNotNull(neighbors);
+ assertEquals(8, neighbors.length);
+ expected =
+ new java.util.HashSet<>(
+ java.util.Arrays.asList(
+ "ezry", "sp2n", "sp2q", "sp2m", "sp2k", "sp2h", "ezru",
"ezrv"));
+ actual = new java.util.HashSet<>(java.util.Arrays.asList(neighbors));
+ assertEquals(expected, actual);
+ }
+
+ @Test
+ public void testGeoHashNeighborNearMeridian() {
+ // From geohash-java: GeoHashTest.testNeibouringHashesNearMeridian()
+ // "sp2j" is near the prime meridian; western neighbors cross into "ezr*"
prefix
+ assertEquals("ezrv", Functions.geohashNeighbor("sp2j", "w"));
+ assertEquals("ezrt",
Functions.geohashNeighbor(Functions.geohashNeighbor("sp2j", "w"), "w"));
+ }
+
+ @Test
+ public void testGeoHashNeighborCircleMovement() {
+ // Moving E -> S -> W -> N should return to the starting cell
+ // From geohash-java: GeoHashTest.testMovingInCircle()
+ String[] testHashes = {"u1pb", "sp2j", "ezrv", "dqcjqc", "u1x0dfg",
"pbpbpbpbpbpb"};
+ for (String start : testHashes) {
+ String result = Functions.geohashNeighbor(start, "e");
+ result = Functions.geohashNeighbor(result, "s");
+ result = Functions.geohashNeighbor(result, "w");
+ result = Functions.geohashNeighbor(result, "n");
+ assertEquals("Circle movement failed for " + start, start, result);
+ }
+ }
+
+ @Test
+ public void testGeoHashNeighborCaseInsensitive() {
+ assertEquals(Functions.geohashNeighbor("u1pb", "n"),
Functions.geohashNeighbor("u1pb", "N"));
+ assertEquals(Functions.geohashNeighbor("u1pb", "ne"),
Functions.geohashNeighbor("u1pb", "NE"));
+ assertEquals(Functions.geohashNeighbor("u1pb", "sw"),
Functions.geohashNeighbor("u1pb", "SW"));
+ }
+
+ @Test
+ public void testGeoHashNeighborsNullInput() {
+ assertNull(Functions.geohashNeighbors(null));
+ assertNull(Functions.geohashNeighbor(null, "n"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGeoHashNeighborsEmptyInput() {
+ Functions.geohashNeighbors("");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGeoHashNeighborInvalidDirection() {
+ Functions.geohashNeighbor("u1pb", "north");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGeoHashNeighborNullDirection() {
+ Functions.geohashNeighbor("u1pb", null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGeoHashNeighborsInvalidCharacter() {
+ Functions.geohashNeighbors("u1pb!");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGeoHashNeighborsTooLongInput() {
+ // 13 chars * 5 bits = 65 > 64 (MAX_BIT_PRECISION)
+ Functions.geohashNeighbors("0123456789abc");
+ }
}
diff --git a/docs/api/flink/Function.md b/docs/api/flink/Function.md
index a3f9887757..a19ff51599 100644
--- a/docs/api/flink/Function.md
+++ b/docs/api/flink/Function.md
@@ -1779,6 +1779,46 @@ Output:
u3r0p
```
+## ST_GeoHashNeighbors
+
+Introduction: Returns the 8 neighboring geohash cells of a given geohash
string. The result is an array of 8 geohash strings in the order: N, NE, E, SE,
S, SW, W, NW.
+
+Format: `ST_GeoHashNeighbors(geohash: String)`
+
+Since: `v1.9.0`
+
+Example:
+
+```sql
+SELECT ST_GeoHashNeighbors('u1pb')
+```
+
+Output:
+
+```
+[u1pc, u301, u300, u2bp, u0zz, u0zx, u1p8, u1p9]
+```
+
+## ST_GeoHashNeighbor
+
+Introduction: Returns the neighbor geohash cell in the given direction. Valid
directions are: `n`, `ne`, `e`, `se`, `s`, `sw`, `w`, `nw` (case-insensitive).
+
+Format: `ST_GeoHashNeighbor(geohash: String, direction: String)`
+
+Since: `v1.9.0`
+
+Example:
+
+```sql
+SELECT ST_GeoHashNeighbor('u1pb', 'n')
+```
+
+Output:
+
+```
+u1pc
+```
+
## ST_GeometricMedian
Introduction: Computes the approximate geometric median of a MultiPoint
geometry using the Weiszfeld algorithm. The geometric median provides a
centrality measure that is less sensitive to outlier points than the centroid.
diff --git a/docs/api/snowflake/vector-data/Function.md
b/docs/api/snowflake/vector-data/Function.md
index 3377d4bfae..65376b6ce0 100644
--- a/docs/api/snowflake/vector-data/Function.md
+++ b/docs/api/snowflake/vector-data/Function.md
@@ -1396,6 +1396,54 @@ Result:
+-----------------------------+
```
+## ST_GeoHashNeighbors
+
+Introduction: Returns the 8 neighboring geohash cells of a given geohash
string. The result is an array of 8 geohash strings in the order: N, NE, E, SE,
S, SW, W, NW.
+
+Format: `ST_GeoHashNeighbors(geohash: String)`
+
+Example:
+
+Query:
+
+```sql
+SELECT ST_GeoHashNeighbors('u1pb')
+```
+
+Result:
+
+```
++-----------------------------+
+|geohash_neighbors |
++-----------------------------+
+|[u1pc, u301, u300, u2bp, ...] |
++-----------------------------+
+```
+
+## ST_GeoHashNeighbor
+
+Introduction: Returns the neighbor geohash cell in the given direction. Valid
directions are: `n`, `ne`, `e`, `se`, `s`, `sw`, `w`, `nw` (case-insensitive).
+
+Format: `ST_GeoHashNeighbor(geohash: String, direction: String)`
+
+Example:
+
+Query:
+
+```sql
+SELECT ST_GeoHashNeighbor('u1pb', 'n')
+```
+
+Result:
+
+```
++-----------------------------+
+|geohash_neighbor |
++-----------------------------+
+|u1pc |
++-----------------------------+
+```
+
## ST_GeometricMedian
Introduction: Computes the approximate geometric median of a MultiPoint
geometry using the Weiszfeld algorithm. The geometric median provides a
centrality measure that is less sensitive to outlier points than the centroid.
diff --git a/docs/api/sql/Function.md b/docs/api/sql/Function.md
index 09967b5aac..ad1895524a 100644
--- a/docs/api/sql/Function.md
+++ b/docs/api/sql/Function.md
@@ -1883,6 +1883,46 @@ Output:
u3r0p
```
+## ST_GeoHashNeighbors
+
+Introduction: Returns the 8 neighboring geohash cells of a given geohash
string. The result is an array of 8 geohash strings in the order: N, NE, E, SE,
S, SW, W, NW.
+
+Format: `ST_GeoHashNeighbors(geohash: String)`
+
+Since: `v1.9.0`
+
+SQL Example
+
+```sql
+SELECT ST_GeoHashNeighbors('u1pb')
+```
+
+Output:
+
+```
+[u1pc, u301, u300, u2bp, u0zz, u0zx, u1p8, u1p9]
+```
+
+## ST_GeoHashNeighbor
+
+Introduction: Returns the neighbor geohash cell in the given direction. Valid
directions are: `n`, `ne`, `e`, `se`, `s`, `sw`, `w`, `nw` (case-insensitive).
+
+Format: `ST_GeoHashNeighbor(geohash: String, direction: String)`
+
+Since: `v1.9.0`
+
+SQL Example
+
+```sql
+SELECT ST_GeoHashNeighbor('u1pb', 'n')
+```
+
+Output:
+
+```
+u1pc
+```
+
## ST_GeometricMedian
Introduction: Computes the approximate geometric median of a MultiPoint
geometry using the Weiszfeld algorithm. The geometric median provides a
centrality measure that is less sensitive to outlier points than the centroid.
diff --git a/flink/src/main/java/org/apache/sedona/flink/Catalog.java
b/flink/src/main/java/org/apache/sedona/flink/Catalog.java
index 0ee24de799..eefe48c821 100644
--- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java
+++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java
@@ -106,6 +106,8 @@ public class Catalog {
new FunctionsGeoTools.ST_Transform(),
new Functions.ST_FlipCoordinates(),
new Functions.ST_GeoHash(),
+ new Functions.ST_GeoHashNeighbors(),
+ new Functions.ST_GeoHashNeighbor(),
new Functions.ST_Perimeter(),
new Functions.ST_Perimeter2D(),
new Functions.ST_PointOnSurface(),
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 6be5c0f562..c4567a6a98 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
@@ -951,6 +951,20 @@ public class Functions {
}
}
+ public static class ST_GeoHashNeighbors extends ScalarFunction {
+ @DataTypeHint("ARRAY<STRING>")
+ public String[] eval(String geohash) {
+ return org.apache.sedona.common.Functions.geohashNeighbors(geohash);
+ }
+ }
+
+ public static class ST_GeoHashNeighbor extends ScalarFunction {
+ @DataTypeHint("String")
+ public String eval(String geohash, String direction) {
+ return org.apache.sedona.common.Functions.geohashNeighbor(geohash,
direction);
+ }
+ }
+
public static class ST_Perimeter extends ScalarFunction {
@DataTypeHint(value = "Double")
public Double eval(
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 d1095c98e7..bd310c65b7 100644
--- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
+++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
@@ -29,6 +29,7 @@ import java.util.stream.Collectors;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.flink.table.api.Table;
+import org.apache.flink.types.Row;
import org.apache.sedona.flink.expressions.Functions;
import org.apache.sedona.flink.expressions.FunctionsGeoTools;
import org.geotools.api.referencing.FactoryException;
@@ -794,6 +795,40 @@ public class FunctionTest extends TestBase {
assertEquals(first(pointTable).getField(0), "s0000");
}
+ @Test
+ public void testGeoHashNeighbors() {
+ Table resultTable = tableEnv.sqlQuery("SELECT
ST_GeoHashNeighbors('u1pb')");
+ Row result = first(resultTable);
+ String[] neighbors = (String[]) result.getField(0);
+ assertEquals(8, neighbors.length);
+ assertEquals("u1pc", neighbors[0]); // N
+ assertEquals("u300", neighbors[2]); // E
+ assertEquals("u0zz", neighbors[4]); // S
+ assertEquals("u1p8", neighbors[6]); // W
+ }
+
+ @Test
+ public void testGeoHashNeighborsNull() {
+ Table resultTable = tableEnv.sqlQuery("SELECT
ST_GeoHashNeighbors(CAST(NULL AS STRING))");
+ assertNull(first(resultTable).getField(0));
+ }
+
+ @Test
+ public void testGeoHashNeighbor() {
+ Table resultTable = tableEnv.sqlQuery("SELECT ST_GeoHashNeighbor('u1pb',
'n')");
+ assertEquals("u1pc", first(resultTable).getField(0));
+ resultTable = tableEnv.sqlQuery("SELECT ST_GeoHashNeighbor('u1pb', 'e')");
+ assertEquals("u300", first(resultTable).getField(0));
+ resultTable = tableEnv.sqlQuery("SELECT ST_GeoHashNeighbor('u1pb', 'NE')");
+ assertEquals("u301", first(resultTable).getField(0));
+ }
+
+ @Test
+ public void testGeoHashNeighborNull() {
+ Table resultTable = tableEnv.sqlQuery("SELECT ST_GeoHashNeighbor(CAST(NULL
AS STRING), 'n')");
+ assertNull(first(resultTable).getField(0));
+ }
+
@Test
public void testGeometryType() {
Table pointTable =
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 298942f9b0..4d7eda4346 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -54,6 +54,7 @@ all = [
dev = [
"pytest",
"pytest-cov",
+ "setuptools>=69,<82",
"notebook==6.4.12",
"jupyter",
"mkdocs",
diff --git a/python/sedona/spark/sql/st_functions.py
b/python/sedona/spark/sql/st_functions.py
index bc6b2d5e20..34afd113b0 100644
--- a/python/sedona/spark/sql/st_functions.py
+++ b/python/sedona/spark/sql/st_functions.py
@@ -763,6 +763,36 @@ def ST_GeoHash(geometry: ColumnOrName, precision:
Union[ColumnOrName, int]) -> C
return _call_st_function("ST_GeoHash", (geometry, precision))
+@validate_argument_types
+def ST_GeoHashNeighbors(geohash: ColumnOrName) -> Column:
+ """Return the 8 neighboring geohash cells of the given geohash string.
+
+ The neighbors are returned in the order: [N, NE, E, SE, S, SW, W, NW].
+
+ :param geohash: Geohash string column.
+ :type geohash: ColumnOrName
+ :return: Array of 8 neighboring geohash strings.
+ :rtype: Column
+ """
+ return _call_st_function("ST_GeoHashNeighbors", (geohash,))
+
+
+@validate_argument_types
+def ST_GeoHashNeighbor(
+ geohash: ColumnOrName, direction: Union[ColumnOrName, str]
+) -> Column:
+ """Return the neighboring geohash cell in the specified direction.
+
+ :param geohash: Geohash string column.
+ :type geohash: ColumnOrName
+ :param direction: Compass direction: 'n', 'ne', 'e', 'se', 's', 'sw', 'w',
'nw'.
+ :type direction: Union[ColumnOrName, str]
+ :return: Neighboring geohash string.
+ :rtype: Column
+ """
+ return _call_st_function("ST_GeoHashNeighbor", (geohash, direction))
+
+
@validate_argument_types
def ST_GeometricMedian(
geometry: ColumnOrName,
diff --git a/python/tests/sql/test_dataframe_api.py
b/python/tests/sql/test_dataframe_api.py
index 9629a7ca55..ad158e69ae 100644
--- a/python/tests/sql/test_dataframe_api.py
+++ b/python/tests/sql/test_dataframe_api.py
@@ -612,6 +612,29 @@ test_configurations = [
),
(stf.ST_GeometryN, ("geom", 0), "multipoint", "", "POINT (0 0)"),
(stf.ST_GeometryType, ("point",), "point_geom", "", "ST_Point"),
+ (
+ stf.ST_GeoHashNeighbors,
+ ("geohash",),
+ "constructor",
+ "",
+ [
+ "s00twy01mk",
+ "s00twy01mm",
+ "s00twy01mq",
+ "s00twy01ms",
+ "s00twy01mu",
+ "s00twy01mv",
+ "s00twy01mw",
+ "s00twy01my",
+ ],
+ ),
+ (
+ stf.ST_GeoHashNeighbor,
+ ("geohash", lambda: f.lit("n")),
+ "constructor",
+ "",
+ "s00twy01mw",
+ ),
(
stf.ST_HausdorffDistance,
(
@@ -1357,6 +1380,9 @@ wrong_type_configurations = [
(stf.ST_GeometryN, ("", None)),
(stf.ST_GeometryN, ("", 0.0)),
(stf.ST_GeometryType, (None,)),
+ (stf.ST_GeoHashNeighbors, (None,)),
+ (stf.ST_GeoHashNeighbor, (None, "n")),
+ (stf.ST_GeoHashNeighbor, ("", None)),
(stf.ST_GeneratePoints, (None, 0.0)),
(stf.ST_GeneratePoints, ("", None)),
(stf.ST_InteriorRingN, (None, 0)),
diff --git a/python/tests/sql/test_function.py
b/python/tests/sql/test_function.py
index 3b09b12b6a..00915c13e1 100644
--- a/python/tests/sql/test_function.py
+++ b/python/tests/sql/test_function.py
@@ -1868,6 +1868,33 @@ class TestPredicateJoin(TestBase):
for calculated_geohash, expected_geohash in geohash:
assert calculated_geohash == expected_geohash
+ def test_st_geohash_neighbors(self):
+ result = self.spark.sql("SELECT
ST_GeoHashNeighbors('u1pb')").collect()[0][0]
+
+ assert len(result) == 8
+ # Order: N, NE, E, SE, S, SW, W, NW
+ assert result[0] == "u1pc"
+ assert result[1] == "u301"
+ assert result[2] == "u300"
+ assert result[3] == "u2bp"
+ assert result[4] == "u0zz"
+ assert result[5] == "u0zx"
+ assert result[6] == "u1p8"
+ assert result[7] == "u1p9"
+
+ def test_st_geohash_neighbor(self):
+ # Test north neighbor
+ result_n = self.spark.sql("SELECT ST_GeoHashNeighbor('u1pb',
'n')").collect()[
+ 0
+ ][0]
+ assert result_n == "u1pc"
+
+ # Test east neighbor
+ result_e = self.spark.sql("SELECT ST_GeoHashNeighbor('u1pb',
'e')").collect()[
+ 0
+ ][0]
+ assert result_e == "u300"
+
def test_geom_from_geohash(self):
# Given
geometry_df = self.spark.createDataFrame(
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 20ab767fad..58bf1dc79f 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
@@ -471,6 +471,19 @@ public class TestFunctions extends TestBase {
"u3r0p");
}
+ @Test
+ public void test_ST_GeoHashNeighbors() {
+ registerUDF("ST_GeoHashNeighbors", String.class);
+ verifySqlSingleRes("select
ARRAY_SIZE(sedona.ST_GeoHashNeighbors('u1pb'))", 8);
+ }
+
+ @Test
+ public void test_ST_GeoHashNeighbor() {
+ registerUDF("ST_GeoHashNeighbor", String.class, String.class);
+ verifySqlSingleRes("select sedona.ST_GeoHashNeighbor('u1pb', 'n')",
"u1pc");
+ verifySqlSingleRes("select sedona.ST_GeoHashNeighbor('u1pb', 'e')",
"u300");
+ }
+
@Test
public void test_ST_GeometryN() {
registerUDF("ST_GeometryN", byte[].class, int.class);
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 425a5e288e..7aa8f15657 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
@@ -451,6 +451,19 @@ public class TestFunctionsV2 extends TestBase {
"u3r0p");
}
+ @Test
+ public void test_ST_GeoHashNeighbors() {
+ registerUDFV2("ST_GeoHashNeighbors", String.class);
+ verifySqlSingleRes("select
ARRAY_SIZE(sedona.ST_GeoHashNeighbors('u1pb'))", 8);
+ }
+
+ @Test
+ public void test_ST_GeoHashNeighbor() {
+ registerUDFV2("ST_GeoHashNeighbor", String.class, String.class);
+ verifySqlSingleRes("select sedona.ST_GeoHashNeighbor('u1pb', 'n')",
"u1pc");
+ verifySqlSingleRes("select sedona.ST_GeoHashNeighbor('u1pb', 'e')",
"u300");
+ }
+
@Test
public void test_ST_GeometryN() {
registerUDFV2("ST_GeometryN", String.class, int.class);
diff --git
a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
index c9405df46e..4f08f2250c 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
@@ -439,6 +439,16 @@ public class UDFs {
return Functions.geohash(GeometrySerde.deserialize(geometry), precision);
}
+ @UDFAnnotations.ParamMeta(argNames = {"geohash"})
+ public static String[] ST_GeoHashNeighbors(String geohash) {
+ return Functions.geohashNeighbors(geohash);
+ }
+
+ @UDFAnnotations.ParamMeta(argNames = {"geohash", "direction"})
+ public static String ST_GeoHashNeighbor(String geohash, String direction) {
+ return Functions.geohashNeighbor(geohash, direction);
+ }
+
@UDFAnnotations.ParamMeta(argNames = {"gml"})
public static byte[] ST_GeomFromGML(String gml)
throws IOException, ParserConfigurationException, SAXException {
diff --git
a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
index 09196bae25..48f8842a42 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
@@ -621,6 +621,20 @@ public class UDFsV2 {
return Functions.geohash(GeometrySerde.deserGeoJson(geometry), precision);
}
+ @UDFAnnotations.ParamMeta(
+ argNames = {"geohash"},
+ argTypes = {"String"})
+ public static String[] ST_GeoHashNeighbors(String geohash) {
+ return Functions.geohashNeighbors(geohash);
+ }
+
+ @UDFAnnotations.ParamMeta(
+ argNames = {"geohash", "direction"},
+ argTypes = {"String", "String"})
+ public static String ST_GeoHashNeighbor(String geohash, String direction) {
+ return Functions.geohashNeighbor(geohash, direction);
+ }
+
@UDFAnnotations.ParamMeta(
argNames = {"geometry", "n"},
argTypes = {"Geometry", "int"},
diff --git
a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
index 8b5882a989..a2d483398a 100644
--- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
+++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala
@@ -187,6 +187,8 @@ object Catalog extends AbstractCatalog with Logging {
function[ST_MaximumInscribedCircle](),
function[ST_MaxDistance](),
function[ST_GeoHash](),
+ function[ST_GeoHashNeighbors](),
+ function[ST_GeoHashNeighbor](),
function[ST_GeomFromGeoHash](null),
function[ST_PointFromGeoHash](null),
function[ST_GeogFromGeoHash](null),
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 54424510ff..329848d2fc 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
@@ -1156,6 +1156,22 @@ private[apache] case class ST_GeoHash(inputExpressions:
Seq[Expression])
}
}
+private[apache] case class ST_GeoHashNeighbors(inputExpressions:
Seq[Expression])
+ extends InferredExpression(Functions.geohashNeighbors _) {
+
+ protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) =
{
+ copy(inputExpressions = newChildren)
+ }
+}
+
+private[apache] case class ST_GeoHashNeighbor(inputExpressions:
Seq[Expression])
+ extends InferredExpression(Functions.geohashNeighbor _) {
+
+ protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) =
{
+ copy(inputExpressions = newChildren)
+ }
+}
+
/**
* Return the difference between geometry A and B
*
diff --git
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/InferredExpression.scala
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/InferredExpression.scala
index 606dcbb931..757948216c 100644
---
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/InferredExpression.scala
+++
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/InferredExpression.scala
@@ -194,6 +194,8 @@ object InferrableType {
new InferrableType[Array[java.lang.Long]] {}
implicit val doubleArrayInstance: InferrableType[Array[Double]] =
new InferrableType[Array[Double]] {}
+ implicit val stringArrayInstance: InferrableType[Array[String]] =
+ new InferrableType[Array[String]] {}
implicit val javaDoubleListInstance:
InferrableType[java.util.List[java.lang.Double]] =
new InferrableType[java.util.List[java.lang.Double]] {}
implicit val javaGeomListInstance: InferrableType[java.util.List[Geometry]] =
@@ -220,6 +222,22 @@ object InferredTypes {
expr.asString(input)
} else if (t =:= typeOf[Array[Long]]) { expr => input =>
expr.eval(input).asInstanceOf[ArrayData].toLongArray()
+ } else if (t =:= typeOf[Array[String]]) { expr => input =>
+ expr.eval(input).asInstanceOf[ArrayData] match {
+ case null => null
+ case arrayData: ArrayData =>
+ val n = arrayData.numElements()
+ val result = new Array[String](n)
+ var i = 0
+ while (i < n) {
+ if (!arrayData.isNullAt(i)) {
+ val utf8 = arrayData.getUTF8String(i)
+ if (utf8 != null) result(i) = utf8.toString
+ }
+ i += 1
+ }
+ result
+ }
} else if (t =:= typeOf[Array[Int]]) { expr => input =>
expr.eval(input).asInstanceOf[ArrayData] match {
case null => null
@@ -263,6 +281,14 @@ object InferredTypes {
} else {
null
}
+ } else if (t =:= typeOf[Array[String]]) { output =>
+ if (output != null) {
+ ArrayData.toArrayData(output.asInstanceOf[Array[String]].map { s =>
+ if (s != null) UTF8String.fromString(s) else null
+ })
+ } else {
+ null
+ }
} else if (t =:= typeOf[java.util.List[java.lang.Double]]) { output =>
if (output != null) {
ArrayData.toArrayData(
@@ -330,6 +356,8 @@ object InferredTypes {
DataTypes.createArrayType(LongType)
} else if (t =:= typeOf[Array[Double]] || t =:=
typeOf[java.util.List[java.lang.Double]]) {
DataTypes.createArrayType(DoubleType)
+ } else if (t =:= typeOf[Array[String]]) {
+ DataTypes.createArrayType(StringType)
} else if (t =:= typeOf[Option[Boolean]]) {
BooleanType
} else if (t =:= typeOf[Boolean]) {
diff --git
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
index f34a128307..818055b906 100644
---
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
+++
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
@@ -246,6 +246,20 @@ object st_functions {
def ST_GeoHash(geometry: String, precision: Int): Column =
wrapExpression[ST_GeoHash](geometry, precision)
+ def ST_GeoHashNeighbors(geohash: Column): Column =
+ wrapExpression[ST_GeoHashNeighbors](geohash)
+ def ST_GeoHashNeighbors(geohash: String): Column =
+ wrapExpression[ST_GeoHashNeighbors](geohash)
+
+ def ST_GeoHashNeighbor(geohash: Column, direction: Column): Column =
+ wrapExpression[ST_GeoHashNeighbor](geohash, direction)
+ def ST_GeoHashNeighbor(geohash: String, direction: String): Column =
+ wrapExpression[ST_GeoHashNeighbor](geohash, direction)
+ def ST_GeoHashNeighbor(geohash: Column, direction: String): Column =
+ wrapExpression[ST_GeoHashNeighbor](geohash, direction)
+ def ST_GeoHashNeighbor(geohash: String, direction: Column): Column =
+ wrapExpression[ST_GeoHashNeighbor](geohash, direction)
+
def ST_GeometryN(multiGeometry: Column, n: Column): Column =
wrapExpression[ST_GeometryN](multiGeometry, n)
def ST_GeometryN(multiGeometry: String, n: Int): Column =
diff --git
a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala
b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala
index 2fb0d5b5c5..725f4cc8a3 100644
---
a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala
+++
b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala
@@ -411,6 +411,43 @@ class dataFrameAPITestScala extends TestBaseScala {
assert(expected.equals(actual))
}
+ it("Passed ST_GeoHashNeighbors") {
+ val df = sparkSession
+ .sql("SELECT 'u1pb' AS geohash")
+ .select(ST_GeoHashNeighbors("geohash"))
+ val result = df.take(1)(0).getSeq[String](0)
+ assert(result.size == 8)
+ // Order: N, NE, E, SE, S, SW, W, NW
+ assert(result(0) == "u1pc")
+ assert(result(2) == "u300")
+ assert(result(4) == "u0zz")
+ assert(result(6) == "u1p8")
+ }
+
+ it("Passed ST_GeoHashNeighbor") {
+ val df = sparkSession
+ .sql("SELECT 'u1pb' AS geohash")
+ .select(ST_GeoHashNeighbor(col("geohash"), lit("n")))
+ val result = df.take(1)(0).getString(0)
+ assert(result == "u1pc")
+ }
+
+ it("Passed ST_GeoHashNeighbor with Column geohash and String direction") {
+ val df = sparkSession
+ .sql("SELECT 'u1pb' AS geohash, 'n' AS direction")
+ .select(ST_GeoHashNeighbor(col("geohash"), "direction"))
+ val result = df.take(1)(0).getString(0)
+ assert(result == "u1pc")
+ }
+
+ it("Passed ST_GeoHashNeighbor with String geohash and Column direction") {
+ val df = sparkSession
+ .sql("SELECT 'u1pb' AS geohash, 'n' AS direction")
+ .select(ST_GeoHashNeighbor("geohash", col("direction")))
+ val result = df.take(1)(0).getString(0)
+ assert(result == "u1pc")
+ }
+
it("passed st_geomfromgml") {
val gmlString =
"<gml:LineString
srsName=\"EPSG:4269\"><gml:coordinates>-71.16028,42.258729 -71.160837,42.259112
-71.161143,42.25932</gml:coordinates></gml:LineString>"
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 c2c36113c2..6c280e10fc 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
@@ -2803,6 +2803,10 @@ class functionTestScala
assert(functionDf.first().get(0) == null)
functionDf = sparkSession.sql("select ST_GeoHash(null, 1)")
assert(functionDf.first().get(0) == null)
+ functionDf = sparkSession.sql("select ST_GeoHashNeighbors(null)")
+ assert(functionDf.first().get(0) == null)
+ functionDf = sparkSession.sql("select ST_GeoHashNeighbor(null, 'n')")
+ assert(functionDf.first().get(0) == null)
functionDf = sparkSession.sql("select ST_Difference(null, null)")
assert(functionDf.first().get(0) == null)
functionDf = sparkSession.sql("select ST_SymDifference(null, null)")
@@ -4277,4 +4281,33 @@ class functionTestScala
FileUtils.deleteDirectory(new File(tmpDir))
}
+
+ it("Should pass ST_GeoHashNeighbors") {
+ var result = sparkSession.sql("SELECT
ST_GeoHashNeighbors('u1pb')").first().getList[String](0)
+ assert(result.size() == 8)
+ assert(result.get(0) == "u1pc") // N
+ assert(result.get(2) == "u300") // E
+ assert(result.get(4) == "u0zz") // S
+ assert(result.get(6) == "u1p8") // W
+
+ result = sparkSession.sql("SELECT
ST_GeoHashNeighbors('dqcjqc')").first().getList[String](0)
+ assert(result.size() == 8)
+ val expected =
+ Set("dqcjqf", "dqcjr4", "dqcjr1", "dqcjr0", "dqcjq9", "dqcjq8",
"dqcjqb", "dqcjqd")
+ val actual = (0 until 8).map(result.get).toSet
+ assert(actual == expected)
+ }
+
+ it("Should pass ST_GeoHashNeighbor") {
+ var result = sparkSession.sql("SELECT ST_GeoHashNeighbor('u1pb',
'n')").first().getString(0)
+ assert(result == "u1pc")
+ result = sparkSession.sql("SELECT ST_GeoHashNeighbor('u1pb',
'e')").first().getString(0)
+ assert(result == "u300")
+ result = sparkSession.sql("SELECT ST_GeoHashNeighbor('u1pb',
's')").first().getString(0)
+ assert(result == "u0zz")
+ result = sparkSession.sql("SELECT ST_GeoHashNeighbor('u1pb',
'w')").first().getString(0)
+ assert(result == "u1p8")
+ result = sparkSession.sql("SELECT ST_GeoHashNeighbor('u1pb',
'NE')").first().getString(0)
+ assert(result == "u301")
+ }
}