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 8ef77d973ca..c77297f03fa 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
@@ -26,10 +26,12 @@
import com.google.common.geometry.S2Shape;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.util.Arrays;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKBConstants;
/**
* A Geography implementation that stores WKB bytes as the primary representation, with lazy-parsed
@@ -80,6 +82,14 @@ private WKBGeography(byte[] wkbBytes, int srid) {
*/
public static WKBGeography fromWKB(byte[] wkb, int srid) {
WKBGeography geog = new WKBGeography(wkb, srid);
+ // Sedona versions before 1.9.1 could emit an empty LineString as only its 5-byte WKB header
+ // (or 9 bytes when an EWKB SRID was present), without the required zero coordinate count.
+ // Normalize that legacy representation at the boundary so it never escapes as malformed WKB.
+ if (wkb.length >= 5
+ && geog.wkbBaseType() == WKBConstants.wkbLineString
+ && wkb.length == geog.wkbPayloadOffset()) {
+ geog = new WKBGeography(Arrays.copyOf(wkb, wkb.length + Integer.BYTES), srid);
+ }
if (eagerShapeIndex) {
geog.getShapeIndexGeography();
}
@@ -245,6 +255,27 @@ public boolean isPoint() {
return wkbBaseType() == 1;
}
+ /**
+ * Returns true if the top-level WKB is empty without constructing JTS or S2 objects. For
+ * non-point WKB, the first payload integer is the element/ring/vertex count.
+ */
+ public boolean isEmpty() {
+ int type = wkbBaseType();
+ int payloadOffset = wkbPayloadOffset();
+ if (type == 1) {
+ if (wkbBytes.length < payloadOffset + 2 * Double.BYTES) return false;
+ return getPointX() == null;
+ }
+ if (type >= 2 && type <= 7) {
+ if (wkbBytes.length < payloadOffset + Integer.BYTES) return false;
+ boolean le = (wkbBytes[0] == 0x01);
+ ByteBuffer bb =
+ ByteBuffer.wrap(wkbBytes).order(le ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN);
+ return bb.getInt(payloadOffset) == 0;
+ }
+ return false;
+ }
+
/** Returns the X coordinate directly from Point WKB, or null for non-points and empty points. */
public Double getPointX() {
return getPointOrdinate(0);
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 f495d86c723..6c246752c43 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
@@ -20,11 +20,14 @@
import com.google.common.geometry.*;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.apache.sedona.common.S2Geography.*;
import org.apache.sedona.common.sphere.Haversine;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.MultiPoint;
@@ -35,6 +38,8 @@
public class Functions {
private static final double EPSILON = 1e-9;
+ private static final S1Angle PROJECT_PERPENDICULAR_ERROR =
+ S1Angle.radians((2.0 + 2.0 / Math.sqrt(3.0)) * S2.DBL_ERROR).add(S2.ROBUST_CROSS_PROD_ERROR);
// S2 expands conservative rectangle bounds by a few floating-point ulps. Snap only those tiny
// expansions back to a source ordinate; genuine great-circle extrema remain unchanged.
private static final double BOUND_ORDINATE_SNAP_TOLERANCE_DEGREES = 1e-12;
@@ -267,6 +272,280 @@ public static Double y(Geography g) {
return org.apache.sedona.common.Functions.y(toJTS(g));
}
+ /**
+ * Returns the smallest convex region containing {@code g} on the sphere.
+ *
+ *
The result follows the usual convex-hull dimensionality rules: one unique vertex produces a
+ * point, two or more collinear vertices produce a line, and non-collinear vertices produce a
+ * polygon whose edges are geodesics. Empty inputs preserve their input type.
+ *
+ * @throws UnsupportedOperationException when the hull is the full sphere, which cannot be
+ * represented by OGC WKB
+ */
+ public static Geography convexHull(Geography g) {
+ if (g == null) return null;
+ if (g instanceof WKBGeography && ((WKBGeography) g).isEmpty()) return g;
+ if (!(g instanceof WKBGeography) && g.numShapes() == 0) return g;
+
+ Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g;
+ S2ConvexHullQuery query = new S2ConvexHullQuery();
+ List vertices = new ArrayList<>();
+ Geometry sourceGeometry = null;
+ boolean needsJtsFallback = addPointAndLineVertices(typed, query, vertices);
+ if (needsJtsFallback) {
+ sourceGeometry = toJTS(g);
+ if (sourceGeometry != null) {
+ addJtsPointAndLineVertices(sourceGeometry, query, vertices);
+ }
+ }
+ boolean hasPolygon = addPolygonRegions(typed, query, vertices);
+ if (vertices.isEmpty() && !hasPolygon) return g;
+
+ S2Loop loop = query.getConvexHull();
+ if (loop.isFull()) {
+ throw new UnsupportedOperationException(
+ "ST_ConvexHull produced the full sphere, which cannot be represented as OGC WKB");
+ }
+
+ S2Point[] degenerateHull = vertices.isEmpty() ? null : getDegenerateHull(vertices);
+ if (degenerateHull != null) {
+ if (sourceGeometry == null) sourceGeometry = toJTS(g);
+ return createDegenerateHull(sourceGeometry, degenerateHull, g.getSRID());
+ }
+
+ if (sourceGeometry == null) sourceGeometry = toJTS(g);
+ return createPolygonHull(sourceGeometry, loop, g.getSRID());
+ }
+
+ /**
+ * Collects geographies into the corresponding multi-geography or geography collection. Null
+ * elements are ignored, member order and duplicates are retained, and the first non-null input
+ * supplies the output SRID.
+ */
+ public static Geography createMultiGeography(Geography[] geographies) {
+ List geometries = new ArrayList<>();
+ Integer srid = null;
+ for (Geography geography : geographies) {
+ if (geography == null) continue;
+ if (srid == null) srid = geography.getSRID();
+ Geometry geometry = toJTS(geography);
+ if (geometry != null) geometries.add(geometry);
+ }
+
+ Geometry result =
+ org.apache.sedona.common.Functions.createMultiGeometry(geometries.toArray(new Geometry[0]));
+ if (srid != null) result.setSRID(srid);
+ return WKBGeography.fromJTS(result);
+ }
+
+ /**
+ * Adds point and polyline vertices from the S2 representation.
+ *
+ * @return whether an empty S2 polyline needs a JTS fallback to recover coincident source vertices
+ * that S2Builder collapsed
+ */
+ private static boolean addPointAndLineVertices(
+ Geography geography, S2ConvexHullQuery query, List vertices) {
+ if (geography instanceof PointGeography) {
+ for (S2Point point : ((PointGeography) geography).getPoints()) {
+ query.addPoint(point);
+ vertices.add(point);
+ }
+ return false;
+ }
+ if (geography instanceof PolylineGeography) {
+ List polylines = ((PolylineGeography) geography).getPolylines();
+ boolean needsJtsFallback = polylines.isEmpty();
+ for (S2Polyline polyline : polylines) {
+ if (polyline.numVertices() < 2) {
+ needsJtsFallback = true;
+ } else {
+ query.addPolyline(polyline);
+ vertices.addAll(polyline.vertices());
+ }
+ }
+ return needsJtsFallback;
+ }
+ if (geography instanceof GeographyCollection) {
+ boolean needsJtsFallback = false;
+ for (Geography feature : ((GeographyCollection) geography).getFeatures()) {
+ needsJtsFallback |= addPointAndLineVertices(feature, query, vertices);
+ }
+ return needsJtsFallback;
+ }
+ return false;
+ }
+
+ private static void addJtsPointAndLineVertices(
+ Geometry geometry, S2ConvexHullQuery query, List vertices) {
+ if (geometry instanceof Polygon) return;
+ if (geometry instanceof Point || geometry instanceof LineString) {
+ for (org.locationtech.jts.geom.Coordinate coordinate : geometry.getCoordinates()) {
+ S2Point vertex = S2LatLng.fromDegrees(coordinate.getY(), coordinate.getX()).toPoint();
+ query.addPoint(vertex);
+ vertices.add(vertex);
+ }
+ return;
+ }
+ if (geometry instanceof GeometryCollection) {
+ for (int i = 0; i < geometry.getNumGeometries(); i++) {
+ addJtsPointAndLineVertices(geometry.getGeometryN(i), query, vertices);
+ }
+ }
+ }
+
+ /**
+ * Adds polygon regions to the hull query. Polygons must be added as regions rather than as vertex
+ * sets so the query respects the S2 representation's resolved interior. Public Geography readers
+ * normalize shells to at most one hemisphere, while directly constructed S2 Geography values can
+ * still represent a larger directed region whose hull is the full sphere.
+ *
+ * @return whether a non-empty polygon was added
+ */
+ private static boolean addPolygonRegions(
+ Geography geography, S2ConvexHullQuery query, List vertices) {
+ if (geography instanceof PolygonGeography) {
+ S2Polygon polygon = ((PolygonGeography) geography).polygon;
+ if (polygon.isEmpty()) return false;
+ query.addPolygon(polygon);
+ for (int i = 0; i < polygon.numLoops(); i++) {
+ S2Loop loop = polygon.loop(i);
+ if (loop.depth() == 0 && !loop.isEmptyOrFull()) {
+ vertices.addAll(loop.vertices());
+ }
+ }
+ return true;
+ }
+ if (geography instanceof GeographyCollection) {
+ boolean hasPolygon = false;
+ for (Geography feature : ((GeographyCollection) geography).getFeatures()) {
+ hasPolygon |= addPolygonRegions(feature, query, vertices);
+ }
+ return hasPolygon;
+ }
+ return false;
+ }
+
+ /**
+ * Returns either one point for a point hull, two endpoints for a collinear hull, or {@code null}
+ * for a polygonal hull. The two-pass farthest-point search is linear and finds the endpoints of
+ * any set contained by one geodesic segment.
+ */
+ private static S2Point[] getDegenerateHull(List vertices) {
+ S2Point firstEndpoint = farthestPoint(vertices.get(0), vertices);
+ S2Point secondEndpoint = farthestPoint(firstEndpoint, vertices);
+ S1Angle span = new S1Angle(firstEndpoint, secondEndpoint);
+ if (span.lessOrEquals(PROJECT_PERPENDICULAR_ERROR)) {
+ return new S2Point[] {firstEndpoint};
+ }
+
+ for (S2Point vertex : vertices) {
+ if (S2EdgeUtil.getDistance(vertex, firstEndpoint, secondEndpoint)
+ .greaterThan(PROJECT_PERPENDICULAR_ERROR)) {
+ return null;
+ }
+ }
+ return new S2Point[] {firstEndpoint, secondEndpoint};
+ }
+
+ private static S2Point farthestPoint(S2Point origin, List vertices) {
+ S2Point farthest = origin;
+ S1ChordAngle farthestDistance = S1ChordAngle.ZERO;
+ for (S2Point vertex : vertices) {
+ S1ChordAngle distance = new S1ChordAngle(origin, vertex);
+ if (distance.greaterThan(farthestDistance)) {
+ farthest = vertex;
+ farthestDistance = distance;
+ }
+ }
+ return farthest;
+ }
+
+ /**
+ * Writes a degenerate hull from its exact source coordinates. S2 still selects the spherical
+ * endpoint(s), but converting those points back to longitude/latitude would introduce
+ * floating-point drift into an otherwise unchanged input vertex.
+ */
+ private static Geography createDegenerateHull(
+ Geometry sourceGeometry, S2Point[] endpoints, int srid) {
+ SourceCoordinateIndex sourceIndex = new SourceCoordinateIndex(sourceGeometry.getCoordinates());
+ GeometryFactory factory = new GeometryFactory(new PrecisionModel(), srid);
+ Coordinate first = sourceIndex.resolve(endpoints[0]);
+ Geometry result;
+ if (endpoints.length == 1) {
+ result = factory.createPoint(first);
+ } else {
+ Coordinate second = sourceIndex.resolve(endpoints[1]);
+ result = factory.createLineString(new Coordinate[] {first, second});
+ }
+ return WKBGeography.fromJTS(result);
+ }
+
+ /**
+ * Writes a polygonal hull from the exact source coordinates selected by S2. For a non-degenerate
+ * hull, S2ConvexHullQuery returns a subset of its input vertices, so no computed vertex needs to
+ * be rounded back to longitude/latitude.
+ */
+ private static Geography createPolygonHull(Geometry sourceGeometry, S2Loop loop, int srid) {
+ SourceCoordinateIndex sourceIndex = new SourceCoordinateIndex(sourceGeometry.getCoordinates());
+ Coordinate[] hullCoordinates = new Coordinate[loop.numVertices() + 1];
+ for (int i = 0; i < loop.numVertices(); i++) {
+ hullCoordinates[i] = sourceIndex.resolve(loop.vertex(i));
+ }
+ hullCoordinates[loop.numVertices()] = new Coordinate(hullCoordinates[0]);
+
+ GeometryFactory factory = new GeometryFactory(new PrecisionModel(), srid);
+ return WKBGeography.fromJTS(factory.createPolygon(hullCoordinates));
+ }
+
+ /**
+ * Resolves the S2 vertices selected by the convex-hull query to their exact source coordinates.
+ * S2ConvexHullQuery retains input vertices for non-degenerate hulls, so the normal path is an
+ * exact hash lookup. The nearest-coordinate fallback covers directly constructed S2 Geography
+ * values whose JTS conversion may not reproduce the same S2Point bits.
+ */
+ private static final class SourceCoordinateIndex {
+ private final Coordinate[] sourceCoordinates;
+ private final Map exactCoordinates = new HashMap<>();
+
+ SourceCoordinateIndex(Coordinate[] sourceCoordinates) {
+ this.sourceCoordinates = sourceCoordinates;
+ for (Coordinate coordinate : sourceCoordinates) {
+ if (!Double.isFinite(coordinate.x) || !Double.isFinite(coordinate.y)) continue;
+ S2Point point = S2LatLng.fromDegrees(coordinate.y, coordinate.x).toPoint();
+ // Keep the first source spelling when equivalent coordinates map to the same S2 point.
+ exactCoordinates.putIfAbsent(point, new Coordinate(coordinate.x, coordinate.y));
+ }
+ }
+
+ Coordinate resolve(S2Point point) {
+ Coordinate coordinate = exactCoordinates.get(point);
+ if (coordinate != null) {
+ return new Coordinate(coordinate.x, coordinate.y);
+ }
+ return nearestSourceCoordinate(point, sourceCoordinates);
+ }
+ }
+
+ private static Coordinate nearestSourceCoordinate(
+ S2Point endpoint, Coordinate[] sourceCoordinates) {
+ Coordinate nearest = null;
+ double nearestDistance = Double.POSITIVE_INFINITY;
+ for (Coordinate coordinate : sourceCoordinates) {
+ if (!Double.isFinite(coordinate.x) || !Double.isFinite(coordinate.y)) continue;
+ S2Point candidate = S2LatLng.fromDegrees(coordinate.y, coordinate.x).toPoint();
+ double distance = endpoint.getDistance2(candidate);
+ if (distance < nearestDistance) {
+ nearest = coordinate;
+ nearestDistance = distance;
+ }
+ }
+ if (nearest == null) {
+ throw new IllegalArgumentException("Cannot construct a convex hull without finite vertices");
+ }
+ return new Coordinate(nearest.x, nearest.y);
+ }
+
/**
* Creates a line from two Point, MultiPoint, or LineString geographies. The returned geography
* preserves the first input's SRID; its edges are interpreted as great-circle arcs by geography
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 c6019b7be1b..8f5431071ad 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
@@ -25,6 +25,7 @@
import com.google.common.geometry.S2Loop;
import com.google.common.geometry.S2Point;
import org.apache.sedona.common.S2Geography.Geography;
+import org.apache.sedona.common.S2Geography.GeographyWKBSerializer;
import org.apache.sedona.common.S2Geography.PolygonGeography;
import org.apache.sedona.common.S2Geography.WKBGeography;
import org.apache.sedona.common.geography.Constructors;
@@ -32,6 +33,8 @@
import org.junit.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.MultiPoint;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.PrecisionModel;
@@ -221,6 +224,142 @@ public void xY_nonPointAndNull() throws ParseException {
assertNull(Functions.y(null));
}
+ @Test
+ public void convexHull_nullAndEmptyPreserveType() throws Exception {
+ assertNull(Functions.convexHull(null));
+
+ for (String wkt : new String[] {"POINT EMPTY", "LINESTRING EMPTY", "POLYGON EMPTY"}) {
+ Geography input = Constructors.geogFromWKT(wkt, 4326);
+ Geography hull = Functions.convexHull(input);
+ assertEquals(wkt, hull.toString());
+ assertEquals(4326, hull.getSRID());
+
+ Geography roundTripped =
+ GeographyWKBSerializer.deserialize(GeographyWKBSerializer.serialize(hull));
+ assertEquals(wkt, roundTripped.toString());
+ assertEquals(4326, roundTripped.getSRID());
+ }
+
+ WKBGeography legacyEmptyLine = WKBGeography.fromWKB(new byte[] {1, 2, 0, 0, 0}, 4326);
+ assertEquals(9, legacyEmptyLine.getWKBBytes().length);
+ Geography legacyHull = Functions.convexHull(legacyEmptyLine);
+ assertEquals("LINESTRING EMPTY", legacyHull.toString());
+ assertEquals(9, ((WKBGeography) legacyHull).getWKBBytes().length);
+ }
+
+ @Test
+ public void convexHull_degenerateInputsReturnPointOrLine() throws ParseException {
+ Geometry coincidentJts = new WKTReader().read("LINESTRING (1 2, 1 2, 1 2)");
+ coincidentJts.setSRID(4326);
+ Geography coincidentLine = WKBGeography.fromJTS(coincidentJts);
+ Geometry pointHull = Constructors.geogToGeometry(Functions.convexHull(coincidentLine));
+ assertTrue(pointHull instanceof Point);
+ assertEquals(1.0, ((Point) pointHull).getX(), 0.0);
+ assertEquals(2.0, ((Point) pointHull).getY(), 0.0);
+
+ Geography twoPoints = Constructors.geogFromWKT("MULTIPOINT ((0 0), (0 2))", 4326);
+ assertLineEndpoints(Functions.convexHull(twoPoints), 0, 0, 0, 2);
+
+ Geography preciseTwoPoints =
+ Constructors.geogFromWKT("MULTIPOINT ((-73.9857 40.7484), (-122.4194 37.7749))", 4326);
+ assertLineEndpoints(
+ Functions.convexHull(preciseTwoPoints), -73.9857, 40.7484, -122.4194, 37.7749, 0.0);
+
+ Geography collinear = Constructors.geogFromWKT("LINESTRING (0 0, 0 1, 0 2, 0 1)", 4326);
+ assertLineEndpoints(Functions.convexHull(collinear), 0, 0, 0, 2);
+
+ Geography fourCollinear =
+ Constructors.geogFromWKT("MULTIPOINT ((0 0), (10 0), (20 0), (30 0))", 4326);
+ assertLineEndpoints(Functions.convexHull(fourCollinear), 0, 0, 30, 0);
+
+ Geography equivalentLongitudes = Constructors.geogFromWKT("MULTIPOINT ((0 0), (360 0))", 4326);
+ assertTrue(
+ Constructors.geogToGeometry(Functions.convexHull(equivalentLongitudes)) instanceof Point);
+
+ Geography equivalentPoles =
+ Constructors.geogFromWKT("MULTIPOINT ((0 90), (120 90), (-45 90))", 4326);
+ assertTrue(Constructors.geogToGeometry(Functions.convexHull(equivalentPoles)) instanceof Point);
+
+ Geography collinearPolygon = Constructors.geogFromWKT("POLYGON ((0 0, 10 0, 20 0, 0 0))", 4326);
+ assertLineEndpoints(Functions.convexHull(collinearPolygon), 0, 0, 20, 0);
+
+ Geography collectionWithCollinearPolygon =
+ Constructors.geogFromWKT(
+ "GEOMETRYCOLLECTION (POLYGON ((0 0, 10 0, 20 0, 0 0)), POINT (30 0))", 4326);
+ assertLineEndpoints(Functions.convexHull(collectionWithCollinearPolygon), 0, 0, 30, 0);
+ }
+
+ @Test
+ public void convexHull_polygonSkipsHolesAndPreservesSrid() throws ParseException {
+ Geography input =
+ Constructors.geogFromWKT(
+ "POLYGON ((0 0, 2 0, 0 2, 0 0), (0.2 0.2, 0.4 0.2, 0.2 0.4, 0.2 0.2))", 4326);
+ Geography hull = Functions.convexHull(input);
+ Geometry jts = Constructors.geogToGeometry(hull);
+
+ assertTrue(jts instanceof Polygon);
+ assertEquals(0, ((Polygon) jts).getNumInteriorRing());
+ assertEquals(4, ((Polygon) jts).getExteriorRing().getNumPoints());
+ assertEquals(4326, hull.getSRID());
+ }
+
+ @Test
+ public void convexHull_polygonPreservesExactSourceCoordinates() throws ParseException {
+ Geography input =
+ Constructors.geogFromWKT("MULTIPOINT ((0 0), (10 0), (10 10), (0 10), (5 5))", 4326);
+ Geometry hull = Constructors.geogToGeometry(Functions.convexHull(input));
+
+ assertTrue(hull instanceof Polygon);
+ assertEquals(5, hull.getNumPoints());
+ for (Coordinate coordinate : hull.getCoordinates()) {
+ boolean isExactSourceVertex =
+ (coordinate.x == 0.0 && coordinate.y == 0.0)
+ || (coordinate.x == 10.0 && coordinate.y == 0.0)
+ || (coordinate.x == 10.0 && coordinate.y == 10.0)
+ || (coordinate.x == 0.0 && coordinate.y == 10.0);
+ assertTrue(
+ "Hull vertex drifted from its source coordinate: " + coordinate, isExactSourceVertex);
+ }
+ }
+
+ @Test
+ public void convexHull_collectionAndAntimeridianAreSpherical() throws ParseException {
+ Geography collection =
+ Constructors.geogFromWKT(
+ "GEOMETRYCOLLECTION (POINT (0 0), LINESTRING (1 0, 5 5), "
+ + "POLYGON ((0 1, 0.2 0.2, 1 0, 0 1)))",
+ 4326);
+ assertTrue(Constructors.geogToGeometry(Functions.convexHull(collection)) instanceof Polygon);
+
+ Geography acrossDateLine =
+ Constructors.geogFromWKT("MULTIPOINT ((170 -10), (170 10), (-170 10), (-170 -10))", 4326);
+ Geography hull = Functions.convexHull(acrossDateLine);
+ assertTrue(Constructors.geogToGeometry(hull) instanceof Polygon);
+ assertTrue("expected the small antimeridian hull", Functions.area(hull) < 1e14);
+ }
+
+ @Test
+ public void convexHull_fullSphereIsRejected() throws ParseException {
+ Geography input = Constructors.geogFromWKT("MULTIPOINT ((0 0), (120 0), (-120 0))", 4326);
+ try {
+ Functions.convexHull(input);
+ fail("Expected a full-sphere hull to be rejected");
+ } catch (UnsupportedOperationException e) {
+ assertTrue(e.getMessage().contains("full sphere"));
+ }
+ }
+
+ @Test
+ public void convexHull_publicPolygonUsesNormalizedShellInterior() throws ParseException {
+ Geography polygon = Constructors.geogFromWKT("POLYGON ((0 0, 0 10, 10 0, 0 0))", 4326);
+ Geography hull = Functions.convexHull(polygon);
+
+ assertEquals("ST_Polygon", Functions.geometryType(hull));
+ assertTrue(Functions.area(hull) < 1.0e13);
+ assertTrue(Functions.contains(hull, Constructors.geogFromWKT("POINT (1 1)", 4326)));
+ assertFalse(Functions.contains(hull, Constructors.geogFromWKT("POINT (20 20)", 4326)));
+ }
+
@Test
public void asEWKT_usesStoredWKBForOrdinaryGeography() throws ParseException {
Geography geography = Constructors.geogFromWKT("POINT (-122.4194 37.7749)", 4326);
@@ -344,6 +483,52 @@ public void makeLine_multiPointAndLineString() throws ParseException {
}
}
+ @Test
+ public void createMultiGeography_preservesMembersAndFirstSrid() throws ParseException {
+ Geography first = Constructors.geogFromWKT("POINT (1 2)", 4326);
+ Geography duplicate = Constructors.geogFromWKT("POINT (1 2)", 3857);
+ Geography second = Constructors.geogFromWKT("POINT (3 4)", 4326);
+ Geography collected =
+ Functions.createMultiGeography(new Geography[] {first, null, duplicate, second});
+ Geometry jts = Constructors.geogToGeometry(collected);
+
+ assertTrue(jts instanceof MultiPoint);
+ assertEquals(3, jts.getNumGeometries());
+ assertEquals(4326, collected.getSRID());
+
+ Geography line = Constructors.geogFromWKT("LINESTRING (0 0, 1 1)", 4326);
+ Geography mixed = Functions.createMultiGeography(new Geography[] {first, line});
+ assertEquals("GeometryCollection", Constructors.geogToGeometry(mixed).getGeometryType());
+
+ Geography empty = Functions.createMultiGeography(new Geography[] {null});
+ assertEquals("GEOMETRYCOLLECTION EMPTY", Functions.asText(empty));
+ }
+
+ private static void assertLineEndpoints(
+ Geography geography, double x0, double y0, double x1, double y1) {
+ assertLineEndpoints(geography, x0, y0, x1, y1, EPS);
+ }
+
+ private static void assertLineEndpoints(
+ Geography geography, double x0, double y0, double x1, double y1, double tolerance) {
+ Geometry geometry = Constructors.geogToGeometry(geography);
+ assertTrue(geometry instanceof LineString);
+ LineString line = (LineString) geometry;
+ Coordinate start = line.getCoordinateN(0);
+ Coordinate end = line.getCoordinateN(line.getNumPoints() - 1);
+ boolean forward =
+ Math.abs(start.x - x0) <= tolerance
+ && Math.abs(start.y - y0) <= tolerance
+ && Math.abs(end.x - x1) <= tolerance
+ && Math.abs(end.y - y1) <= tolerance;
+ boolean reverse =
+ Math.abs(start.x - x1) <= tolerance
+ && Math.abs(start.y - y1) <= tolerance
+ && Math.abs(end.x - x0) <= tolerance
+ && Math.abs(end.y - y0) <= tolerance;
+ assertTrue("Unexpected line endpoints: " + geometry, forward || reverse);
+ }
+
@Test
public void makeLine_antimeridianUsesGreatCircleLength() throws ParseException {
Geography start = Constructors.geogFromWKT("POINT (179 0)", 4326);
diff --git a/docs/api/flink/Aggregate-Functions/ST_Collect_Agg.md b/docs/api/flink/Aggregate-Functions/ST_Collect_Agg.md
new file mode 100644
index 00000000000..a473142f4d0
--- /dev/null
+++ b/docs/api/flink/Aggregate-Functions/ST_Collect_Agg.md
@@ -0,0 +1,67 @@
+
+
+# ST_Collect_Agg
+
+Introduction: Collects all non-null spatial values in a group without dissolving their
+boundaries. Homogeneous `Point`, `LineString`, or `Polygon` values produce the corresponding
+multi-object; mixed types produce a `GeometryCollection`.
+
+
+
+Format:
+
+`ST_Collect_Agg (A: Geometry)`
+
+`ST_Collect_Agg (A: Geography)`
+
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.9.1`
+
+Null values are ignored and duplicates are preserved. The function returns `NULL` when a group
+has no non-null values. All `Geography` values in a group must have the same SRID.
+
+`ST_Collect_Aggr` is also registered as an alias.
+
+Geometry example:
+
+```sql
+SELECT ST_Collect_Agg(geom)
+FROM (
+ VALUES
+ (ST_GeomFromWKT('POINT(1 2)')),
+ (ST_GeomFromWKT('POINT(3 4)')),
+ (ST_GeomFromWKT('POINT(1 2)'))
+) AS observations(geom)
+```
+
+Output:
+
+```
+MULTIPOINT ((1 2), (3 4), (1 2))
+```
+
+Geography example:
+
+```sql
+SELECT region, ST_Collect_Agg(geog) AS geographies
+FROM observations
+GROUP BY region
+```
diff --git a/docs/api/flink/Geography-Functions.md b/docs/api/flink/Geography-Functions.md
index 0f9b772714e..55a648f1513 100644
--- a/docs/api/flink/Geography-Functions.md
+++ b/docs/api/flink/Geography-Functions.md
@@ -41,7 +41,7 @@ These functions create geography objects from various formats, or convert betwee
## Geography Functions
-These functions edit, measure, or format geography objects. Measurements are computed on a spherical model of the Earth (radius `R = 6 371 008 m`, the mean Earth radius), not the WGS84 ellipsoid — areas in square meters and lengths/distances in meters. (`ST_Buffer` is the exception: it is computed on the WGS84 spheroid.)
+These functions process, collect, measure, or format geography objects. Measurements are computed on a spherical model of the Earth (radius `R = 6 371 008 m`, the mean Earth radius), not the WGS84 ellipsoid — areas in square meters and lengths/distances in meters. (`ST_Buffer` is the exception: it is computed on the WGS84 spheroid.)
| Function | Return type | Description | Since |
| :--- | :--- | :--- | :--- |
@@ -50,6 +50,8 @@ These functions edit, measure, or format geography objects. Measurements are com
| [ST_AsText](Geography-Functions/ST_AsText.md) | String | Return the WKT representation of a geography. | v1.9.1 |
| [ST_Buffer](Geography-Functions/ST_Buffer.md) | Geography | Return the geodesic buffer of a geography (distance in meters). | v1.9.1 |
| [ST_Centroid](Geography-Functions/ST_Centroid.md) | Geography | Return the centroid of a geography as a Geography point. | v1.9.1 |
+| [ST_Collect](Geometry-Editors/ST_Collect.md) | Geography | Collect two geographies or an array of geographies into a multi-object or collection without dissolving boundaries. | v1.9.1 |
+| [ST_ConvexHull](Geometry-Processing/ST_ConvexHull.md) | Geography | Return the spherical convex hull of a geography with geodesic edges. | v1.9.1 |
| [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.1 |
| [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box of a geography. Supports antimeridian splitting. | v1.9.1 |
| [ST_GeometryType](Geography-Functions/ST_GeometryType.md) | String | Return the type of a geography as a string. | v1.9.1 |
@@ -60,6 +62,14 @@ These functions edit, measure, or format geography objects. Measurements are com
| [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 |
+## Geography Aggregate Functions
+
+These functions aggregate groups of geography values.
+
+| Function | Return type | Description | Since |
+| :--- | :--- | :--- | :--- |
+| [ST_Collect_Agg](Aggregate-Functions/ST_Collect_Agg.md) | Geography | Collect all non-null geographies in a group without dissolving boundaries. All inputs must have the same SRID. | v1.9.1 |
+
## Geography Predicates
These functions test spatial relationships between two geographies on the sphere.
diff --git a/docs/api/flink/Geometry-Editors/ST_Collect.md b/docs/api/flink/Geometry-Editors/ST_Collect.md
index 5d15e1edc1f..ed08fb419de 100644
--- a/docs/api/flink/Geometry-Editors/ST_Collect.md
+++ b/docs/api/flink/Geometry-Editors/ST_Collect.md
@@ -19,21 +19,30 @@
# ST_Collect
-Introduction: Returns MultiGeometry object based on geometry column/s or array with geometries
+Introduction: Collects spatial values without dissolving their boundaries. Homogeneous
+`Point`, `LineString`, or `Polygon` values produce the corresponding multi-object; mixed types
+produce a `GeometryCollection`.

Format:
-`ST_Collect(*geom: Geometry)`
+`ST_Collect(geom1: Geometry, geom2: Geometry)`
`ST_Collect(geom: ARRAY[Geometry])`
-Return type: `Geometry`
+`ST_Collect(geog1: Geography, geog2: Geography)`
-Since: `v1.5.0`
+`ST_Collect(geog: ARRAY[Geography])`
-Example:
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.5.0` (`Geometry`), `v1.9.1` (`Geography`)
+
+Member order and duplicates are retained. The `Geography` overloads ignore null elements and use
+the first non-null input's SRID for the result.
+
+Geometry example:
```sql
SELECT ST_Collect(
@@ -52,23 +61,21 @@ Result:
+---------------------------------------------------------------+
```
-Example:
+Geography array example:
```sql
-SELECT ST_Collect(
- Array(
- ST_GeomFromText('POINT(21.427834 52.042576573)'),
- ST_GeomFromText('POINT(45.342524 56.342354355)')
+SELECT ST_AsEWKT(
+ ST_Collect(
+ ARRAY[
+ ST_GeogFromWKT('POINT(-122.4 37.8)', 4326),
+ ST_GeogFromWKT('POINT(-122.5 37.7)', 4326)
+ ]
)
-) AS geom
+) AS geog
```
Result:
```
-+---------------------------------------------------------------+
-|geom |
-+---------------------------------------------------------------+
-|MULTIPOINT ((21.427834 52.042576573), (45.342524 56.342354355))|
-+---------------------------------------------------------------+
+SRID=4326;MULTIPOINT ((-122.4 37.8), (-122.5 37.7))
```
diff --git a/docs/api/flink/Geometry-Functions.md b/docs/api/flink/Geometry-Functions.md
index f3e9d279f90..1dee6619226 100644
--- a/docs/api/flink/Geometry-Functions.md
+++ b/docs/api/flink/Geometry-Functions.md
@@ -109,7 +109,7 @@ These functions create modified geometries by changing type, structure, or verti
| Function | Return type | Description | Since |
| :--- | :--- | :--- | :--- |
| [ST_AddPoint](Geometry-Editors/ST_AddPoint.md) | Geometry | Return Linestring with additional point at the given index, if position is not available the point will be added at the end of line. | v1.3.0 |
-| [ST_Collect](Geometry-Editors/ST_Collect.md) | Geometry | Returns MultiGeometry object based on geometry column/s or array with geometries | v1.5.0 |
+| [ST_Collect](Geometry-Editors/ST_Collect.md) | Geometry or Geography | Collects two spatial values or an array of spatial values into a multi-object or collection without dissolving boundaries. | v1.5.0 |
| [ST_CollectionExtract](Geometry-Editors/ST_CollectionExtract.md) | Geometry | Returns a homogeneous multi-geometry from a given geometry collection. | v1.5.0 |
| [ST_FlipCoordinates](Geometry-Editors/ST_FlipCoordinates.md) | Geometry | Returns a version of the given geometry with X and Y axis flipped. | v1.2.0 |
| [ST_Force2D](Geometry-Editors/ST_Force2D.md) | Geometry | Forces the geometries into a "2-dimensional mode" so that all output representations will only have the X and Y coordinates. This function is an alias of [ST_Force_2D](Geometry-Editors/ST_Force_2D.md). | v1.8.0 |
@@ -216,7 +216,7 @@ These functions compute geometric constructions, or alter geometry size or shape
| [ST_BuildArea](Geometry-Processing/ST_BuildArea.md) | Geometry | Returns the areal geometry formed by the constituent linework of the input geometry. | v1.2.1 |
| [ST_Centroid](Geometry-Processing/ST_Centroid.md) | Geometry | Return the centroid point of A | v1.5.0 |
| [ST_ConcaveHull](Geometry-Processing/ST_ConcaveHull.md) | Geometry | Return the Concave Hull of polygon A, with alpha set to pctConvex[0, 1] in the Delaunay Triangulation method, the concave hull will not contain a hole unless allowHoles is set to true | v1.4.0 |
-| [ST_ConvexHull](Geometry-Processing/ST_ConvexHull.md) | Geometry | Return the Convex Hull of polygon A | v1.5.0 |
+| [ST_ConvexHull](Geometry-Processing/ST_ConvexHull.md) | Geometry or Geography | Returns the planar convex hull of a Geometry or spherical convex hull of a Geography. | v1.5.0 |
| [ST_DelaunayTriangles](Geometry-Processing/ST_DelaunayTriangles.md) | Geometry | This function computes the [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation) for the set of vertices in the input geometry. An optional `tolerance` parameter allows sna... | v1.6.1 |
| [ST_GeneratePoints](Geometry-Processing/ST_GeneratePoints.md) | Geometry | Generates a specified quantity of pseudo-random points within the boundaries of the provided polygonal geometry. When `seed` is either zero or not defined then output will be random. | v1.6.1 |
| [ST_GeometricMedian](Geometry-Processing/ST_GeometricMedian.md) | Geometry | 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 th... | v1.4.1 |
@@ -266,10 +266,11 @@ These functions change the position and shape of geometries using affine transfo
## Aggregate Functions
-These functions perform aggregate operations on groups of geometries.
+These functions perform aggregate operations on groups of spatial values.
| Function | Return type | Description | Since |
| :--- | :--- | :--- | :--- |
+| [ST_Collect_Agg](Aggregate-Functions/ST_Collect_Agg.md) | Geometry or Geography | Collects all non-null spatial values in a group without dissolving boundaries. Geography inputs must share an SRID. | v1.9.1 |
| [ST_Envelope_Agg](Aggregate-Functions/ST_Envelope_Agg.md) | Geometry | Return the entire envelope boundary of all geometries in A. Empty geometries and null values are skipped. If all inputs are empty or null, the result is null. This behavior is consistent with PostG... | v1.3.0 |
| [ST_Intersection_Agg](Aggregate-Functions/ST_Intersection_Agg.md) | Geometry | Return the polygon intersection of all polygons in A | v1.5.0 |
| [ST_Union_Agg](Aggregate-Functions/ST_Union_Agg.md) | Geometry | Return the polygon union of all polygons in A. All inputs must be polygons. | v1.3.0 |
diff --git a/docs/api/flink/Geometry-Processing/ST_ConvexHull.md b/docs/api/flink/Geometry-Processing/ST_ConvexHull.md
index 617ff90d4c7..14233ed9e33 100644
--- a/docs/api/flink/Geometry-Processing/ST_ConvexHull.md
+++ b/docs/api/flink/Geometry-Processing/ST_ConvexHull.md
@@ -19,17 +19,27 @@
# ST_ConvexHull
-Introduction: Return the Convex Hull of polygon A
+Introduction: Returns the smallest convex region containing the input. `Geometry` hulls are
+computed in the coordinate plane. `Geography` hulls are computed on the sphere, with geodesic
+edges.

-Format: `ST_ConvexHull (A: Geometry)`
+Format:
-Return type: `Geometry`
+`ST_ConvexHull (A: Geometry)`
-Since: `v1.5.0`
+`ST_ConvexHull (A: Geography)`
-Example:
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.5.0` (`Geometry`), `v1.9.1` (`Geography`)
+
+For `Geography`, one unique point produces a `Point`, collinear points produce a `LineString`,
+and all other non-empty inputs produce a spherical `Polygon`. Empty inputs retain their type and
+SRID. A hull covering the full sphere is not supported.
+
+Geometry example:
```sql
SELECT ST_ConvexHull(ST_GeomFromText('POLYGON((175 150, 20 40, 50 60, 125 100, 175 150))'))
@@ -40,3 +50,22 @@ Output:
```
POLYGON ((20 40, 175 150, 125 100, 20 40))
```
+
+Geography example:
+
+```sql
+SELECT ST_GeometryType(
+ ST_ConvexHull(
+ ST_GeogFromWKT(
+ 'MULTIPOINT ((170 -10), (170 10), (-170 10), (-170 -10))',
+ 4326
+ )
+ )
+)
+```
+
+Output:
+
+```
+ST_Polygon
+```
diff --git a/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md b/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md
index 5f8081f75f1..76415b03d6a 100644
--- a/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md
+++ b/docs/api/sql/Aggregate-Functions/ST_Collect_Agg.md
@@ -19,15 +19,27 @@
# ST_Collect_Agg
-Introduction: Collects all geometries in a geometry column into a single multi-geometry (MultiPoint, MultiLineString, MultiPolygon, or GeometryCollection). Unlike `ST_Union_Agg`, this function does not dissolve boundaries between geometries - it simply collects them into a multi-geometry.
+Introduction: Collects all non-null spatial values in a column into a single multi-object or
+collection. Unlike `ST_Union_Agg`, this function does not dissolve boundaries. Homogeneous
+`Point`, `LineString`, or `Polygon` inputs produce the corresponding multi-object; mixed input
+types produce the OGC/WKT `GeometryCollection` type.

-Format: `ST_Collect_Agg (A: geometryColumn)`
+Format:
-Return type: `Geometry`
+`ST_Collect_Agg (A: Geometry)`
-Since: `v1.8.1`
+`ST_Collect_Agg (A: Geography)`
+
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.8.1` (`Geometry`), `v1.9.1` (`Geography`)
+
+Duplicates are preserved. The function returns `NULL` when a group has no non-null values.
+All non-null `Geography` values in a group must have the same SRID; a group containing mixed
+SRIDs is rejected. In contrast, scalar [`ST_Collect`](../Geometry-Editors/ST_Collect.md) uses the
+first non-null Geography input's SRID for the output.
SQL Example
@@ -52,3 +64,18 @@ SQL Example with GROUP BY
```sql
SELECT category, ST_Collect_Agg(geom) FROM geometries GROUP BY category
```
+
+Geography SQL Example with GROUP BY
+
+```sql
+WITH observations AS (
+ SELECT 'west' AS region, ST_GeogFromWKT('POINT(-122.4 37.8)', 4326) AS geog
+ UNION ALL
+ SELECT 'west' AS region, ST_GeogFromWKT('POINT(-122.5 37.7)', 4326) AS geog
+ UNION ALL
+ SELECT 'east' AS region, ST_GeogFromWKT('POINT(-73.9 40.7)', 4326) AS geog
+)
+SELECT region, ST_Collect_Agg(geog) AS geographies
+FROM observations
+GROUP BY region
+```
diff --git a/docs/api/sql/Geometry-Editors/ST_Collect.md b/docs/api/sql/Geometry-Editors/ST_Collect.md
index 768551fe6a0..31f4b76271a 100644
--- a/docs/api/sql/Geometry-Editors/ST_Collect.md
+++ b/docs/api/sql/Geometry-Editors/ST_Collect.md
@@ -19,7 +19,9 @@
# ST_Collect
-Introduction: Returns MultiGeometry object based on geometry column/s or array with geometries
+Introduction: Collects spatial values into a multi-object or collection without dissolving their
+boundaries. Homogeneous `Point`, `LineString`, or `Polygon` inputs produce the corresponding
+multi-object; mixed input types produce the OGC/WKT `GeometryCollection` type.

@@ -29,9 +31,18 @@ Format:
`ST_Collect(geom: ARRAY[Geometry])`
-Return type: `Geometry`
+`ST_Collect(*geog: Geography)`
-Since: `v1.2.0`
+`ST_Collect(geog: ARRAY[Geography])`
+
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.2.0` (`Geometry`), `v1.9.1` (`Geography`)
+
+Null elements are ignored. Member values and duplicates are preserved. For `Geography` inputs,
+the first non-null input supplies the output SRID; later inputs are not required to have the same
+SRID. In contrast, [`ST_Collect_Agg`](../Aggregate-Functions/ST_Collect_Agg.md) rejects a group
+containing mixed Geography SRIDs.
SQL Example
@@ -52,6 +63,21 @@ Result:
+---------------------------------------------------------------+
```
+Geography SQL Example with GROUP BY
+
+```sql
+WITH observations AS (
+ SELECT 'west' AS region, ST_GeogFromWKT('POINT(-122.4 37.8)', 4326) AS geog
+ UNION ALL
+ SELECT 'west' AS region, ST_GeogFromWKT('POINT(-122.5 37.7)', 4326) AS geog
+ UNION ALL
+ SELECT 'east' AS region, ST_GeogFromWKT('POINT(-73.9 40.7)', 4326) AS geog
+)
+SELECT region, ST_Collect(ARRAY_AGG(geog)) AS geographies
+FROM observations
+GROUP BY region
+```
+
SQL Example
```sql
diff --git a/docs/api/sql/Geometry-Processing/ST_ConvexHull.md b/docs/api/sql/Geometry-Processing/ST_ConvexHull.md
index 8e67a57670e..2292eebfada 100644
--- a/docs/api/sql/Geometry-Processing/ST_ConvexHull.md
+++ b/docs/api/sql/Geometry-Processing/ST_ConvexHull.md
@@ -19,15 +19,26 @@
# ST_ConvexHull
-Introduction: Return the Convex Hull of polygon A
+Introduction: Returns the smallest convex region containing A. For `Geometry` inputs, the hull is
+computed in the coordinate plane. For `Geography` inputs, it is computed on the sphere and its
+edges follow geodesics.

-Format: `ST_ConvexHull (A: Geometry)`
+Format:
-Return type: `Geometry`
+`ST_ConvexHull (A: Geometry)`
-Since: `v1.0.0`
+`ST_ConvexHull (A: Geography)`
+
+Return type: `Geometry` or `Geography`, matching the input type
+
+Since: `v1.0.0` (`Geometry`), `v1.9.1` (`Geography`)
+
+For a `Geography` input, a single unique point produces a `Point`, while collinear points produce
+a `LineString`. Empty inputs preserve their input type. Polygon holes do not change the hull.
+If the spherical convex hull is the full sphere, the function throws an
+`UnsupportedOperationException` because the full sphere cannot be represented by OGC WKB.
SQL Example
@@ -40,3 +51,22 @@ Output:
```
POLYGON ((20 40, 175 150, 125 100, 20 40))
```
+
+Geography SQL Example
+
+```sql
+SELECT ST_GeometryType(
+ ST_ConvexHull(
+ ST_GeogFromWKT(
+ 'MULTIPOINT ((170 -10), (170 10), (-170 10), (-170 -10))',
+ 4326
+ )
+ )
+)
+```
+
+Output:
+
+```
+ST_Polygon
+```
diff --git a/docs/api/sql/geography/Geography-Functions.md b/docs/api/sql/geography/Geography-Functions.md
index 92755121126..950ebfbd443 100644
--- a/docs/api/sql/geography/Geography-Functions.md
+++ b/docs/api/sql/geography/Geography-Functions.md
@@ -46,6 +46,9 @@ These functions operate on geography type objects.
| [ST_AsText](Geography-Functions/ST_AsText.md) | String | Return the Well-Known Text (WKT) representation of a geography. | v1.9.1 |
| [ST_Centroid](Geography-Functions/ST_Centroid.md) | Geography | Return the planar centroid of a geography as a Geography point (computed in projected lon/lat space). | v1.9.1 |
| [ST_Buffer](Geography-Functions/ST_Buffer.md) | Geography | Return the metric ε-buffer of a geography. Distance is always interpreted as meters along the spheroid. | v1.9.1 |
+| [ST_Collect](../Geometry-Editors/ST_Collect.md) | Geography | Collect Geography values without dissolving boundaries, returning a `MultiPoint`, `MultiLineString`, `MultiPolygon`, or OGC/WKT `GeometryCollection`. The first non-null input supplies the output SRID. | v1.9.1 |
+| [ST_Collect_Agg](../Aggregate-Functions/ST_Collect_Agg.md) | Geography | Aggregate Geography values without dissolving boundaries. All non-null values in a group must have the same SRID. | v1.9.1 |
+| [ST_ConvexHull](../Geometry-Processing/ST_ConvexHull.md) | Geography | Return the spherical convex hull with geodesic edges. Full-sphere hulls are not supported because they cannot be represented by OGC WKB. | v1.9.1 |
| [ST_Envelope](Geography-Functions/ST_Envelope.md) | Geography | Return the bounding box (envelope) of a geography. Supports anti-meridian splitting. | v1.8.0 |
| [ST_GeometryType](Geography-Functions/ST_GeometryType.md) | String | Return the type of a geography as a string (e.g., "ST_Point", "ST_Polygon"). | v1.9.1 |
| [ST_MakeLine](Geography-Functions/ST_MakeLine.md) | Geography | Create a geography LineString from two Point, MultiPoint, or LineString geographies. | v1.9.1 |
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 ca5ed46ea61..49391445c23 100644
--- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java
+++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java
@@ -30,10 +30,12 @@ public static UserDefinedFunction[] getFuncs() {
new Aggregators.ST_3DExtent(),
new Aggregators.ST_Intersection_Aggr(),
new Aggregators.ST_Union_Aggr(),
+ new Aggregators.ST_Collect_Aggr(),
// Aliases for *_Aggr functions with *_Agg suffix
new Aggregators.ST_Envelope_Agg(),
new Aggregators.ST_Intersection_Agg(),
new Aggregators.ST_Union_Agg(),
+ new Aggregators.ST_Collect_Agg(),
new Constructors.ST_Point(),
new Constructors.ST_PointZ(),
new Constructors.ST_PointM(),
diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Accumulators.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Accumulators.java
index 9550934db92..68079adb5be 100644
--- a/flink/src/main/java/org/apache/sedona/flink/expressions/Accumulators.java
+++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Accumulators.java
@@ -18,6 +18,9 @@
*/
package org.apache.sedona.flink.expressions;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.sedona.flink.GeometryTypeSerializer;
import org.locationtech.jts.geom.Geometry;
@@ -79,4 +82,24 @@ public static class AccGeometryN {
this.numGeoms = numGeoms;
}
}
+
+ /**
+ * RAW accumulator used by ST_Collect_Aggr for both Geometry and Geography input.
+ *
+ * Values are stored in their stable binary forms so that the accumulator does not depend on
+ * either spatial object's internal representation.
+ */
+ public static class AccGeometryCollection implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ public List values = new ArrayList<>();
+ public Boolean geography;
+ public int srid;
+
+ void reset() {
+ values.clear();
+ geography = null;
+ srid = 0;
+ }
+ }
}
diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Aggregators.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Aggregators.java
index ff13fd8e739..14c55a0c93f 100644
--- a/flink/src/main/java/org/apache/sedona/flink/expressions/Aggregators.java
+++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Aggregators.java
@@ -18,12 +18,18 @@
*/
package org.apache.sedona.flink.expressions;
+import java.io.IOException;
+import java.util.Arrays;
import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.FunctionHint;
import org.apache.flink.table.functions.AggregateFunction;
+import org.apache.sedona.common.S2Geography.Geography;
+import org.apache.sedona.common.S2Geography.GeographyWKBSerializer;
import org.apache.sedona.common.geometryObjects.Box2D;
import org.apache.sedona.common.geometryObjects.Box3D;
import org.apache.sedona.flink.Box2DTypeSerializer;
import org.apache.sedona.flink.Box3DTypeSerializer;
+import org.apache.sedona.flink.GeographyTypeSerializer;
import org.apache.sedona.flink.GeometryTypeSerializer;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
@@ -372,6 +378,167 @@ public void resetAccumulator(Accumulators.AccGeometry acc) {
}
}
+ /**
+ * Collects all non-null Geometry or Geography inputs while preserving order and duplicates.
+ *
+ * The output spatial type follows the input type. Geography values in a group must use the
+ * same SRID.
+ */
+ @FunctionHint(
+ input =
+ @DataTypeHint(
+ value = "RAW",
+ rawSerializer = GeometryTypeSerializer.class,
+ bridgedTo = Geometry.class),
+ accumulator =
+ @DataTypeHint(value = "RAW", bridgedTo = Accumulators.AccGeometryCollection.class),
+ output =
+ @DataTypeHint(
+ value = "RAW",
+ rawSerializer = GeometryTypeSerializer.class,
+ bridgedTo = Geometry.class))
+ @FunctionHint(
+ input =
+ @DataTypeHint(
+ value = "RAW",
+ rawSerializer = GeographyTypeSerializer.class,
+ bridgedTo = Geography.class),
+ accumulator =
+ @DataTypeHint(value = "RAW", bridgedTo = Accumulators.AccGeometryCollection.class),
+ output =
+ @DataTypeHint(
+ value = "RAW",
+ rawSerializer = GeographyTypeSerializer.class,
+ bridgedTo = Geography.class))
+ public static class ST_Collect_Aggr
+ extends AggregateFunction