james-willis commented on code in PR #816:
URL: https://github.com/apache/sedona-db/pull/816#discussion_r3222906719


##########
python/sedonadb/tests/geography/test_geog_overlay.py:
##########
@@ -0,0 +1,605 @@
+# 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.
+
+import pytest
+import sedonadb
+from sedonadb.testing import BigQuery, SedonaDB, geog_or_null
+
+if "s2geography" not in sedonadb.__features__:
+    pytest.skip("Python package built without s2geography", 
allow_module_level=True)
+
+
+# ST_Intersection tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_intersection"),
+        pytest.param("POINT (0 0)", None, None, id="intersection_null"),
+        pytest.param(None, None, None, id="null_intersection_null"),
+        # Point + Point: same
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (0 0)", 
id="point_same"),
+        # Multipoint + Point: overlap
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (0 0)",
+            "POINT (0 0)",
+            id="multipoint_point_overlap",
+        ),
+        # Linestring + Linestring: same
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_same",
+        ),
+        # Linestring + Linestring: crossing (intersection is a point)
+        pytest.param(
+            "LINESTRING (0 -5, 0 5)",
+            "LINESTRING (-5 0, 5 0)",
+            "POINT (0 0)",
+            id="linestring_crossing",
+        ),
+        # Polygon + Polygon: same
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_same",
+        ),
+        # Point + Linestring: point at endpoint
+        pytest.param(
+            "POINT (0 0)",
+            "LINESTRING (0 0, 10 0)",
+            "POINT (0 0)",
+            id="point_on_linestring",
+        ),
+        # Point + Polygon: point inside
+        pytest.param(
+            "POINT (5 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (5 5)",
+            id="point_inside_polygon",
+        ),
+        # Point + Polygon: point on boundary
+        pytest.param(
+            "POINT (10 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (10 5)",
+            id="point_on_polygon_boundary",
+        ),
+        # Linestring + Polygon: line inside
+        pytest.param(
+            "LINESTRING (2 5, 8 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING (2 5, 8 5)",
+            id="linestring_inside_polygon",
+        ),
+    ],
+)
+def test_st_intersection(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties - BigQuery doesn't return specific empty types consistently
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT EMPTY",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="both_empty",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_polygon",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_intersection_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return EMPTY - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Linestring + Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "LINESTRING EMPTY",
+            id="linestring_disjoint",
+        ),
+        # Polygon + Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "POLYGON EMPTY",
+            id="polygon_disjoint",
+        ),
+        # Linestring + Polygon: line outside
+        pytest.param(
+            "LINESTRING (20 0, 30 0)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING EMPTY",
+            id="linestring_outside_polygon",
+        ),
+    ],
+)
+def test_st_intersection_returns_empty(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return POINT (nan nan) - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Point + Point: different
+        pytest.param(
+            "POINT (0 0)", "POINT (0 1)", "POINT (nan nan)", 
id="point_different"
+        ),
+        # Multipoint + Point: disjoint
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (2 2)",
+            "POINT (nan nan)",

Review Comment:
   is this really the right thing to return? I would think POINT EMPTY is more 
correct.
   



##########
python/sedonadb/tests/geography/test_geog_overlay.py:
##########
@@ -0,0 +1,605 @@
+# 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.
+
+import pytest
+import sedonadb
+from sedonadb.testing import BigQuery, SedonaDB, geog_or_null
+
+if "s2geography" not in sedonadb.__features__:
+    pytest.skip("Python package built without s2geography", 
allow_module_level=True)
+
+
+# ST_Intersection tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_intersection"),
+        pytest.param("POINT (0 0)", None, None, id="intersection_null"),
+        pytest.param(None, None, None, id="null_intersection_null"),
+        # Point + Point: same
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (0 0)", 
id="point_same"),
+        # Multipoint + Point: overlap
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (0 0)",
+            "POINT (0 0)",
+            id="multipoint_point_overlap",
+        ),
+        # Linestring + Linestring: same
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_same",
+        ),
+        # Linestring + Linestring: crossing (intersection is a point)
+        pytest.param(
+            "LINESTRING (0 -5, 0 5)",
+            "LINESTRING (-5 0, 5 0)",
+            "POINT (0 0)",
+            id="linestring_crossing",
+        ),
+        # Polygon + Polygon: same
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_same",
+        ),
+        # Point + Linestring: point at endpoint
+        pytest.param(
+            "POINT (0 0)",
+            "LINESTRING (0 0, 10 0)",
+            "POINT (0 0)",
+            id="point_on_linestring",
+        ),
+        # Point + Polygon: point inside
+        pytest.param(
+            "POINT (5 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (5 5)",
+            id="point_inside_polygon",
+        ),
+        # Point + Polygon: point on boundary
+        pytest.param(
+            "POINT (10 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (10 5)",
+            id="point_on_polygon_boundary",
+        ),
+        # Linestring + Polygon: line inside
+        pytest.param(
+            "LINESTRING (2 5, 8 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING (2 5, 8 5)",
+            id="linestring_inside_polygon",
+        ),
+    ],
+)
+def test_st_intersection(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties - BigQuery doesn't return specific empty types consistently
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT EMPTY",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="both_empty",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_polygon",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_intersection_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return EMPTY - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Linestring + Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "LINESTRING EMPTY",
+            id="linestring_disjoint",
+        ),
+        # Polygon + Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "POLYGON EMPTY",
+            id="polygon_disjoint",
+        ),
+        # Linestring + Polygon: line outside
+        pytest.param(
+            "LINESTRING (20 0, 30 0)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING EMPTY",
+            id="linestring_outside_polygon",
+        ),
+    ],
+)
+def test_st_intersection_returns_empty(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return POINT (nan nan) - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Point + Point: different
+        pytest.param(
+            "POINT (0 0)", "POINT (0 1)", "POINT (nan nan)", 
id="point_different"
+        ),
+        # Multipoint + Point: disjoint
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (2 2)",
+            "POINT (nan nan)",
+            id="multipoint_point_disjoint",
+        ),
+        # Point + Linestring: point off line
+        pytest.param(
+            "POINT (5 5)",
+            "LINESTRING (0 0, 10 0)",
+            "POINT (nan nan)",
+            id="point_off_linestring",
+        ),
+        # Point + Polygon: point outside
+        pytest.param(
+            "POINT (20 20)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (nan nan)",
+            id="point_outside_polygon",
+        ),
+    ],
+)
+def test_st_intersection_returns_empty_point(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# ST_Difference tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_difference"),
+        pytest.param("POINT (0 0)", None, None, id="difference_null"),
+        pytest.param(None, None, None, id="null_difference_null"),
+        # Point - Point: different
+        pytest.param("POINT (0 0)", "POINT (0 1)", "POINT (0 0)", 
id="point_different"),
+        # Linestring - Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_disjoint",
+        ),
+        # Polygon - Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            id="polygon_disjoint",
+        ),
+    ],
+)
+def test_st_difference(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties - BigQuery doesn't return specific empty types consistently
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Point - Point: same -> empty
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (nan nan)", 
id="point_same"),
+        # Linestring - Linestring: same -> empty
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING EMPTY",
+            id="linestring_same",
+        ),
+        # Polygon - Polygon: same -> empty
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            id="polygon_same",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "POINT (0 0)",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_difference_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Far apart geometries (coverings don't intersect)
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (180 0)",
+            "POINT (0 0)",
+            id="point_very_far",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((170 -5, 175 -5, 175 0, 170 0, 170 -5))",
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            id="polygon_very_far",
+        ),
+    ],
+)
+def test_st_difference_very_far(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# ST_Union tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_union"),
+        pytest.param("POINT (0 0)", None, None, id="union_null"),
+        pytest.param(None, None, None, id="null_union_null"),
+        # Point + Point: same
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (0 0)", 
id="point_same"),
+        # Point + Point: different
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            "MULTIPOINT (0 0, 0 1)",
+            id="point_different",
+        ),
+        # Multipoint + Point
+        pytest.param(
+            "MULTIPOINT (0 0, 1 1)",
+            "POINT (2 2)",
+            "MULTIPOINT (0 0, 1 1, 2 2)",
+            id="multipoint_point",
+        ),
+        # Multipoint + Point: overlap
+        pytest.param(
+            "MULTIPOINT (0 0, 1 1)",
+            "POINT (0 0)",
+            "MULTIPOINT (0 0, 1 1)",
+            id="multipoint_point_overlap",
+        ),
+        # Linestring + Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "MULTILINESTRING ((0 0, 10 0), (0 10, 10 10))",
+            id="linestring_disjoint",
+        ),
+        # Linestring + Linestring: same
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_same",
+        ),
+        # Polygon + Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "MULTIPOLYGON (((0 0, 5 0, 5 5, 0 5, 0 0)), "
+            "((10 10, 15 10, 15 15, 10 15, 10 10)))",
+            id="polygon_disjoint",
+        ),
+        # Polygon + Polygon: same
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_same",
+        ),
+    ],
+)
+def test_st_union(eng, geom1, geom2, expected):

Review Comment:
   overlapping polygon and overlapping at terminus linestring could be good 
cases.



##########
python/sedonadb/tests/geography/test_geog_transformations.py:
##########
@@ -37,3 +37,839 @@ def test_st_centroid(eng, geom, expected):
     eng.assert_query_result(
         f"SELECT ST_Centroid({geog_or_null(geom)})", expected, wkt_precision=4
     )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "expected"),
+    [
+        # Nulls
+        pytest.param(None, None, id="null_centroid"),
+        # Empties
+        pytest.param("POINT EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="point_empty"),
+        pytest.param(
+            "LINESTRING EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="linestring_empty"
+        ),
+        pytest.param("POLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="polygon_empty"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param("MULTIPOINT ((0 0), (0 1))", "POINT (0 0.5)", 
id="multipoint"),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "POINT (0 0.5)", 
id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 5)", "POINT (0 2.5)", 
id="linestring_two_segments"
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POINT (0.3333498812 0.3333442395)",
+            id="triangle",
+        ),
+    ],
+)
+def test_st_centroid_extended(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Centroid({geog_or_null(geog)})", expected, wkt_precision=10
+    )
+
+
+# Neither PostGIS nor BigQuery support ZM in their centroid calculation
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom", "expected"),
+    [
+        # Points with Z
+        pytest.param("POINT Z (0 1 10)", "POINT Z (0 1 10)", id="point_z"),
+        pytest.param(
+            "MULTIPOINT Z ((0 0 10), (0 1 11))",
+            "POINT Z (0 0.5 10.5)",
+            id="multipoint_z",
+        ),
+        # Points with M
+        pytest.param("POINT M (0 1 10)", "POINT M (0 1 10)", id="point_m"),
+        pytest.param(
+            "MULTIPOINT M ((0 0 10), (0 1 11))",
+            "POINT M (0 0.5 10.5)",
+            id="multipoint_m",
+        ),
+        # Points with ZM
+        pytest.param("POINT ZM (0 1 10 20)", "POINT ZM (0 1 10 20)", 
id="point_zm"),
+        pytest.param(
+            "MULTIPOINT ZM ((0 0 10 20), (0 1 11 21))",
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="multipoint_zm",
+        ),
+        # Linestrings with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11)",
+            "POINT Z (0 0.5 10.5)",
+            id="linestring_z",
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 5 15)",
+            "POINT Z (0 2.5 12.5)",
+            id="linestring_two_segments_z",
+        ),
+        # Linestrings with M
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11)",
+            "POINT M (0 0.5 10.5)",
+            id="linestring_m",
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11, 0 5 15)",
+            "POINT M (0 2.5 12.5)",
+            id="linestring_two_segments_m",
+        ),
+        # Linestrings with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21)",
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="linestring_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 5 15 25)",
+            "POINT ZM (0 2.5 12.5 22.5)",
+            id="linestring_two_segments_zm",
+        ),
+        # Polygons with Z
+        pytest.param(
+            "POLYGON Z ((0 0 10, 0 1 10, 1 0 10, 0 0 10))",
+            "POINT Z (0.3333 0.3333 10)",
+            id="triangle_z",
+        ),
+        pytest.param(
+            "POLYGON Z ((0 0 10, 0 2 10, 2 0 10, 0 0 10), "
+            "(0.1 0.1 11, 0.1 0.5 11, 0.5 0.1 11, 0.1 0.1 11))",
+            "POINT Z (0.6849 0.6848 10.0385)",
+            id="polygon_with_hole_z",
+        ),
+        # Polygons with M
+        pytest.param(
+            "POLYGON M ((0 0 10, 0 1 10, 1 0 10, 0 0 10))",
+            "POINT M (0.3333 0.3333 10)",
+            id="triangle_m",
+        ),
+        pytest.param(
+            "POLYGON M ((0 0 10, 0 2 10, 2 0 10, 0 0 10), "
+            "(0.1 0.1 11, 0.1 0.5 11, 0.5 0.1 11, 0.1 0.1 11))",
+            "POINT M (0.6849 0.6848 10.0385)",
+            id="polygon_with_hole_m",
+        ),
+        # Polygons with ZM
+        pytest.param(
+            "POLYGON ZM ((0 0 10 20, 0 1 10 20, 1 0 10 20, 0 0 10 20))",
+            "POINT ZM (0.3333 0.3333 10 20)",
+            id="triangle_zm",
+        ),
+        pytest.param(
+            "POLYGON ZM ((0 0 10 20, 0 2 10 20, 2 0 10 20, 0 0 10 20), "
+            "(0.1 0.1 11 21, 0.1 0.5 11 21, 0.5 0.1 11 21, 0.1 0.1 11 21))",
+            "POINT ZM (0.6849 0.6848 10.0385 20.0385)",
+            id="polygon_with_hole_zm",
+        ),
+    ],
+)
+def test_st_centroid_zm(eng, geom, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Centroid({geog_or_null(geom)})", expected, wkt_precision=4
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "expected"),
+    [
+        # Nulls
+        pytest.param(None, None, id="null_convex_hull"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param(
+            "MULTIPOINT ((0 0), (0 1), (1 0))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="multipoint_three",
+        ),
+        # Linestrings
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 1 0)",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="linestring_non_colinear",
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="triangle",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 0 2, 2 0, 0 0), (0.1 0.1, 0.1 0.5, 0.5 0.1, 0.1 
0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            id="polygon_with_hole",
+        ),
+        # GeometryCollection (convex hull of all vertices)
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1), POLYGON 
((0 0, 0 1, 1 0, 0 0)))",
+            "POLYGON ((0 0, 1 0, 5 5, 0 1, 0 0))",
+            id="geometrycollection",
+        ),
+    ],
+)
+def test_st_convexhull(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_ConvexHull({geog_or_null(geog)})", expected, 
wkt_precision=10
+    )
+
+
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geog", "expected"),
+    [
+        # Empties
+        pytest.param("POINT EMPTY", "POINT (nan nan)", id="point_empty"),
+        pytest.param("LINESTRING EMPTY", "LINESTRING EMPTY", 
id="linestring_empty"),
+        pytest.param("POLYGON EMPTY", "POLYGON EMPTY", id="polygon_empty"),
+        pytest.param(
+            "MULTIPOINT ((0 0), (0 1))", "LINESTRING (0 0, 0 1)", 
id="multipoint_two"
+        ),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "LINESTRING (0 0, 0 1)", 
id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            "LINESTRING (0 0, 0 2)",
+            id="linestring_colinear",
+        ),
+    ],
+)
+def test_st_convexhull_degenerate(eng, geog, expected):
+    # Empty/degenerate behaviour does not match BigQuery but instead matches
+    # what PostGIS would give for a geometry implementation.
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_ConvexHull({geog_or_null(geog)})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geog", "expected"),
+    [
+        # Empties
+        pytest.param("POINT EMPTY", "POINT (nan nan)", id="point_empty"),
+        pytest.param("LINESTRING EMPTY", "POINT (nan nan)", 
id="linestring_empty"),
+        pytest.param("POLYGON EMPTY", "POINT (nan nan)", id="polygon_empty"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param("MULTIPOINT ((0 0), (0 1))", "POINT (0 1)", 
id="multipoint"),
+        # Points with Z/M/ZM
+        pytest.param("POINT Z (0 1 10)", "POINT Z (0 1 10)", id="point_z"),
+        pytest.param("POINT M (0 1 10)", "POINT M (0 1 10)", id="point_m"),
+        pytest.param("POINT ZM (0 1 10 20)", "POINT ZM (0 1 10 20)", 
id="point_zm"),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "POINT (0 1)", id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 5)", "POINT (0 1)", 
id="linestring_three_vertices"
+        ),
+        # Linestrings with Z/M/ZM
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11)", "POINT Z (0 1 11)", 
id="linestring_z"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11)", "POINT M (0 1 11)", 
id="linestring_m"
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21)",
+            "POINT ZM (0 1 11 21)",
+            id="linestring_zm",
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POINT (0.224466 0.224464)",
+            id="triangle",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
+            "POINT (0.450237 0.450223)",
+            id="square",
+        ),
+        # Polygons with Z/M/ZM are not yet supported and return a 2D result
+    ],
+)
+def test_st_pointonsurface(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_PointOnSurface({geog_or_null(geog)})", expected, 
wkt_precision=6
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery, PostGIS])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Nulls
+        pytest.param(None, 0.5, None, id="null_line"),
+        # Endpoints and midpoints
+        pytest.param("LINESTRING (0 0, 0 2)", 0.0, "POINT (0 0)", id="start"),
+        pytest.param("LINESTRING (0 0, 0 2)", 1.0, "POINT (0 2)", id="end"),
+        pytest.param("LINESTRING (0 0, 0 2)", 0.5, "POINT (0 1)", 
id="midpoint"),
+        # Multi-segment line
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            0.25,
+            "POINT (0 0.5)",
+            id="multi_seg_quarter",
+        ),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            0.75,
+            "POINT (0 1.5)",
+            id="multi_seg_three_quarter",
+        ),
+        # Boundary fractions
+        pytest.param("LINESTRING (0 0, 0 2)", 0.0, "POINT (0 0)", 
id="fraction_zero"),
+        pytest.param("LINESTRING (0 0, 0 2)", 1.0, "POINT (0 2)", 
id="fraction_one"),
+    ],
+)
+def test_st_line_interpolate_point(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=4,
+    )
+
+
+# Degenerate behaviour matches PostGIS and not BigQuery
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Empties
+        pytest.param("LINESTRING EMPTY", 0.0, None, id="empty_line"),
+        # Degenerate
+        pytest.param(
+            "LINESTRING (1 1, 1 1)", 0.5, "POINT (1 1)", id="zero_length_line"
+        ),
+    ],
+)
+def test_st_line_interpolate_point_degenerate(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=15,
+    )
+
+
+# Z/M/ZM support only in SedonaDB
[email protected]("eng", [SedonaDB])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Linestring with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 0.0, "POINT Z (0 0 10)", 
id="start_z"
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 1.0, "POINT Z (0 2 12)", 
id="end_z"
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 0.5, "POINT Z (0 1 11)", 
id="midpoint_z"
+        ),
+        # Linestring with M
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 0.0, "POINT M (0 0 10)", 
id="start_m"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 1.0, "POINT M (0 2 12)", 
id="end_m"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 0.5, "POINT M (0 1 11)", 
id="midpoint_m"
+        ),
+        # Linestring with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            0.0,
+            "POINT ZM (0 0 10 20)",
+            id="start_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            1.0,
+            "POINT ZM (0 2 12 22)",
+            id="end_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            0.5,
+            "POINT ZM (0 1 11 21)",
+            id="midpoint_zm",
+        ),
+        # Multi-segment line with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 2 12)",
+            0.25,
+            "POINT Z (0 0.5 10.5)",
+            id="multi_seg_quarter_z",
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 2 12)",
+            0.75,
+            "POINT Z (0 1.5 11.5)",
+            id="multi_seg_three_quarter_z",
+        ),
+        # Multi-segment line with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 2 12 22)",
+            0.25,
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="multi_seg_quarter_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 2 12 22)",
+            0.75,
+            "POINT ZM (0 1.5 11.5 21.5)",
+            id="multi_seg_three_quarter_zm",
+        ),
+    ],
+)
+def test_st_line_interpolate_point_zm(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=15,
+    )
+
+
+# ST_Buffer tests - creates a buffer polygon around a geometry
[email protected]("eng", [SedonaDB, BigQuery, PostGIS])
[email protected](
+    ("geog", "distance", "expected"),
+    [
+        # Null
+        pytest.param(None, 100000.0, None, id="null_buffer"),
+        pytest.param("POINT (0 1)", None, None, id="buffer_null"),
+        # Point with positive distance: produces a polygon approximating a 
circle
+        pytest.param(
+            "POINT (0 0)",
+            100000.0,
+            31213847614.041348,
+            id="point_positive_distance",
+        ),
+        # Linestring with positive distance: produces a buffered corridor
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            53452519026.41781,
+            id="linestring_positive_distance",
+        ),
+        # Polygon with positive distance: expands the polygon
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
+            100000.0,
+            88052039626.29015,
+            id="polygon_positive_distance",
+        ),
+    ],
+)
+def test_st_buffer(eng, geog, distance, expected):
+    eng = eng.create_or_skip()
+    if eng.name() == "postgis":
+        eps = 1e-2
+    else:
+        eps = 1e-15
+
+    # Check the area because it is tricky to check the actual value and here we
+    # are mostly checking for plugged-in ness.
+    eng.assert_query_result(
+        f"SELECT ST_Area(ST_Buffer({geog_or_null(geog)}, 
{val_or_null(distance)}))",
+        expected,
+        numeric_epsilon=eps,
+    )
+
+
+# ST_Buffer tests with num_quad_segs - controls circle approximation quality
[email protected]("eng", [SedonaDB, BigQuery, PostGIS])
[email protected](
+    ("geog", "distance", "num_quad_segs", "expected"),
+    [
+        # Point with different num_quad_segs: more segments = closer to true 
circle
+        pytest.param(
+            "POINT (0 0)",
+            100000.0,
+            4,
+            30614189578.97585,
+            id="point_quad_segs_4",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            100000.0,
+            8,
+            31213847614.041348,
+            id="point_quad_segs_8",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            100000.0,
+            16,
+            31364850259.39363,
+            id="point_quad_segs_16",
+        ),
+    ],
+)
+def test_st_buffer_num_quad_segs(eng, geog, distance, num_quad_segs, expected):
+    eng = eng.create_or_skip()
+    if eng.name() == "postgis":
+        eps = 1e-2
+    else:
+        eps = 1e-14
+
+    eng.assert_query_result(
+        f"SELECT ST_Area(ST_Buffer({geog_or_null(geog)}, 
{val_or_null(distance)}, {num_quad_segs}))",
+        expected,
+        numeric_epsilon=eps,
+    )
+
+
+# ST_Buffer tests with style params - controls buffer shape via string 
parameters
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+    ("geog", "distance", "params", "expected"),
+    [
+        # Endcap styles (for linestrings)
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            "endcap=round",
+            53452519026.41781,
+            id="linestring_endcap_round",
+        ),
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            "endcap=flat",
+            22238671472.442112,
+            id="linestring_endcap_flat",
+        ),
+        # Side options (for linestrings)
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            "side=left",
+            11119335736.221052,
+            id="linestring_side_left",
+        ),
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            "side=right",
+            11119335736.221052,
+            id="linestring_side_right",
+        ),
+    ],
+)
+def test_st_buffer_params(eng, geog, distance, params, expected):
+    eng = eng.create_or_skip()
+    if eng.name() == "postgis":
+        eps = 1e-2
+    else:
+        eps = 1e-15
+
+    eng.assert_query_result(
+        f"SELECT ST_Area(ST_Buffer({geog_or_null(geog)}, 
{val_or_null(distance)}, '{params}'))",
+        expected,
+        numeric_epsilon=eps,
+    )
+
+
+# ST_ReducePrecision tests - snaps coordinates to a grid
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "grid_size", "expected"),
+    [
+        # Null inputs
+        pytest.param("POINT (0 0)", None, None, id="null_grid_size"),
+        # Point snapping to whole degrees (grid_size = 1.0)
+        pytest.param("POINT (0 0)", 1.0, "POINT (0 0)", id="point_on_grid"),
+        pytest.param("POINT (0.001 0.001)", 1.0, "POINT (0 0)", 
id="point_not_on_grid"),
+        pytest.param(
+            "POINT (0.001 0.001)", -1, "POINT (0.001 0.001)", 
id="point_no_snap"
+        ),
+        # Point snapping to 0.1 degree grid (grid_size = 0.1)
+        pytest.param(
+            "POINT (0.1 0.1)", 0.1, "POINT (0.1 0.1)", 
id="point_tenth_degree_on_grid"
+        ),
+        pytest.param(
+            "POINT (0.12 0.12)", 0.1, "POINT (0.1 0.1)", 
id="point_tenth_degree_snap"
+        ),
+        # Multipoint: two nearby points snap to same location
+        pytest.param(
+            "MULTIPOINT ((0.001 0.001), (0.002 0.002))",
+            1.0,
+            "POINT (0 0)",
+            id="multipoint_merge",
+        ),
+        # Multipoint: points remain distinct after snapping
+        pytest.param(
+            "MULTIPOINT ((0 0), (10 10))",
+            1.0,
+            "MULTIPOINT (0 0, 10 10)",
+            id="multipoint_distinct",
+        ),
+        # Linestring: no snapping needed
+        pytest.param(
+            "LINESTRING (0 0, 10 10)",
+            1.0,
+            "LINESTRING (0 0, 10 10)",
+            id="linestring_on_grid",
+        ),
+        # Linestring: endpoints snap to grid
+        pytest.param(
+            "LINESTRING (0.001 0.001, 10.001 10.001)",
+            1.0,
+            "LINESTRING (0 0, 10 10)",
+            id="linestring_snap",
+        ),
+        # Linestring: no snapping with negative grid size
+        pytest.param(
+            "LINESTRING (0.001 0.001, 10.001 10.001)",
+            -1,
+            "LINESTRING (0.001 0.001, 10.001 10.001)",
+            id="linestring_no_snap",
+        ),
+        # Polygon: single ring, no snapping
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            -1,
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_simple",
+        ),
+        # Polygon: single ring with snapping
+        pytest.param(
+            "POLYGON ((0.001 0.001, 10.001 0.001, 10.001 10.001, "
+            "0.001 10.001, 0.001 0.001))",
+            1.0,
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_snap",
+        ),
+    ],

Review Comment:
   what about polygon where two coordinates get rounded to the same value? also 
smae case but it degenerates the polygon



##########
python/sedonadb/tests/geography/test_geog_predicates.py:
##########
@@ -49,3 +236,395 @@ def test_st_intersects(eng, geom1, geom2, expected):
         f"SELECT ST_Intersects({geog_or_null(geom1)}, {geog_or_null(geom2)})",
         expected,
     )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT EMPTY", None, id="null_contains"),
+        pytest.param("POINT EMPTY", None, None, id="contains_null"),
+        pytest.param(None, None, None, id="null_contains_null"),
+        # Empties
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT EMPTY",
+            False,
+            id="contains_empty",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="empty_contains",
+        ),
+        # Polygon contains interior point
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT (0.25 0.25)",
+            True,
+            id="polygon_contains_point",
+        ),
+        # Point does not contain anything
+        pytest.param(
+            "POINT (0.25 0.25)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="point_not_contains_polygon",
+        ),
+        # Point definitely not in polygon (outside the covering)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT (-30 -30)",
+            False,
+            id="polygon_not_contains_distant_point",
+        ),
+        # Point definitely not in polygon (probably inside the covering)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT (1.01 1.01)",
+            False,
+            id="polygon_not_contains_close_point",
+        ),
+        # Polygon does not contain boundary point
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT (0 0)",
+            False,
+            id="polygon_not_contains_boundary_point",
+        ),
+        # Polygon contains interior sub-polygon
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            True,
+            id="polygon_contains_polygon",
+        ),
+        # Interior polygon does not contain Polygon
+        pytest.param(
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="polygon_does_not_contain_polygon",
+        ),
+        # Polygon contains interior linestring
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            True,
+            id="polygon_contains_interior_linestring",
+        ),
+        # Polygon does not contain linestring that crosses boundary
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 3 3)",
+            False,
+            id="polygon_not_contains_crossing_linestring",
+        ),
+        # Polygon does not contain exterior linestring
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (3 3, 4 4)",
+            False,
+            id="polygon_not_contains_exterior_linestring",
+        ),
+        # Linestring does not contain point
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            "POINT (10 10)",
+            False,
+            id="linestring_not_contains_point",
+        ),
+        # Linestring does not contain linestring
+        pytest.param(
+            "LINESTRING (0 0, 2 0)",
+            "LINESTRING (10 10, 11 10)",
+            False,
+            id="linestring_not_contains_linestring",
+        ),
+        # Polygon does not contain overlapping polygon
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((0.1 0.1, 3 0.1, 0.1 3, 0.1 0.1))",
+            False,
+            id="polygon_not_contains_overlapping_polygon",
+        ),
+        # Polygon does not contain distant polygon
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            False,
+            id="polygon_not_contains_distant_polygon",
+        ),
+        # GEOMETRYCOLLECTION tests
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), LINESTRING (40 40, 41 40), 
POLYGON ((0 0, 2 0, 0 2, 0 0)))",
+            "POINT (0.25 0.25)",
+            True,
+            id="gc_contains_point",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            True,
+            id="gc_contains_linestring",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "GEOMETRYCOLLECTION (POINT (0.25 0.25), LINESTRING (0.3 0.3, 0.4 
0.4))",
+            True,
+            id="polygon_contains_gc",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "GEOMETRYCOLLECTION (POINT (30 30), LINESTRING (0.3 0.3, 0.4 
0.4))",
+            False,
+            id="polygon_not_contains_gc_point_outside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "POLYGON ((0.1 0.1, 3 0.1, 0.1 3, 0.1 0.1))",
+            False,
+            id="gc_not_contains_crossing_polygon",
+        ),
+    ],
+)
+def test_st_contains(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Contains({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Within is the reverse of Contains
+        # Point within polygon
+        pytest.param(
+            "POINT (0.25 0.25)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            True,
+            id="point_within_polygon",
+        ),
+        # Point not within polygon (outside)
+        pytest.param(
+            "POINT (-1 -1)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="point_not_within_polygon",
+        ),
+        # Polygon is not within point
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POINT (0.25 0.25)",
+            False,
+            id="polygon_not_within_point",
+        ),
+        # Null handling
+        pytest.param(None, "POLYGON ((0 0, 2 0, 0 2, 0 0))", None, 
id="null_within"),
+        pytest.param("POINT (0 0)", None, None, id="within_null"),
+        # Interior linestring within polygon
+        pytest.param(
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            True,
+            id="linestring_within_polygon",
+        ),
+        # Crossing linestring not within polygon
+        pytest.param(
+            "LINESTRING (0.25 0.25, 3 3)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="crossing_linestring_not_within_polygon",
+        ),
+        # Interior polygon within larger polygon
+        pytest.param(
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            True,
+            id="polygon_within_polygon",
+        ),
+        # Boundary point not within polygon
+        pytest.param(
+            "POINT (0 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            False,
+            id="boundary_point_not_within_polygon",
+        ),
+    ],
+)
+def test_st_within(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Within({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT EMPTY", None, id="null_equals"),
+        pytest.param("POINT EMPTY", None, None, id="equals_null"),
+        pytest.param(None, None, None, id="null_equals_null"),
+        # Empties
+        pytest.param("POINT (0 0)", "POINT EMPTY", False, id="equals_empty"),
+        pytest.param("POINT EMPTY", "POINT (0 0)", False, id="empty_equals"),
+        pytest.param(
+            "POINT EMPTY", "POINT EMPTY", True, 
id="empty_point_equals_empty_point"
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "LINESTRING EMPTY",
+            True,
+            id="empty_point_equals_empty_linestring",
+        ),
+        # Fast path for identical values
+        pytest.param(

Review Comment:
   should there be a case for wrap around?



##########
python/sedonadb/tests/geography/test_geog_transformations.py:
##########
@@ -37,3 +37,839 @@ def test_st_centroid(eng, geom, expected):
     eng.assert_query_result(
         f"SELECT ST_Centroid({geog_or_null(geom)})", expected, wkt_precision=4
     )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "expected"),
+    [
+        # Nulls
+        pytest.param(None, None, id="null_centroid"),
+        # Empties
+        pytest.param("POINT EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="point_empty"),
+        pytest.param(
+            "LINESTRING EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="linestring_empty"
+        ),
+        pytest.param("POLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="polygon_empty"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param("MULTIPOINT ((0 0), (0 1))", "POINT (0 0.5)", 
id="multipoint"),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "POINT (0 0.5)", 
id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 5)", "POINT (0 2.5)", 
id="linestring_two_segments"

Review Comment:
   should there be a case where the midpoint isnt colinear?



##########
python/sedonadb/tests/geography/test_geog_distance.py:
##########
@@ -0,0 +1,1065 @@
+# 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.
+
+import pytest
+import sedonadb
+from sedonadb.testing import BigQuery, SedonaDB, geog_or_null
+
+if "s2geography" not in sedonadb.__features__:
+    pytest.skip("Python package built without s2geography", 
allow_module_level=True)
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT EMPTY", None, id="null_distance"),
+        pytest.param("POINT EMPTY", None, None, id="distance_null"),
+        pytest.param(None, None, None, id="null_distance_null"),
+        # Empties
+        pytest.param("POINT (0 0)", "POINT EMPTY", None, id="distance_empty"),
+        pytest.param("POINT EMPTY", "POINT (0 0)", None, id="empty_distance"),
+        # Point x point
+        pytest.param("POINT (0 0)", "POINT (0 0)", 0.0, 
id="point_distance_same_point"),
+        pytest.param(
+            "POINT (0 0)", "POINT (0 1)", 111195.10117748393, 
id="point_distance_point"
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (360 90)",
+            10007559.105973553,
+            id="point_distance_wraparound_lng",
+        ),
+        # Point x linestring (point on linestring)
+        pytest.param(
+            "POINT (0 0)",
+            "LINESTRING (0 0, 0 1)",
+            0.0,
+            id="point_distance_linestring_on",
+        ),
+        # Point x linestring (point off linestring)
+        pytest.param(
+            "POINT (1 0)",
+            "LINESTRING (0 0, 0 1)",
+            111195.10117748393,
+            id="point_distance_linestring_off",
+        ),
+        # Linestring x point (point on linestring)
+        pytest.param(
+            "LINESTRING (0 0, 0 1)",
+            "POINT (0 0)",
+            0.0,
+            id="linestring_distance_point_on",
+        ),
+        # Linestring x point (point off linestring)
+        pytest.param(
+            "LINESTRING (0 0, 0 1)",
+            "POINT (1 0)",
+            111195.10117748393,
+            id="linestring_distance_point_off",
+        ),
+        # Point x polygon (point inside)
+        pytest.param(
+            "POINT (0.25 0.25)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="point_distance_polygon_inside",
+        ),
+        # Point x polygon (point on boundary)
+        pytest.param(
+            "POINT (0 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="point_distance_polygon_boundary",
+        ),
+        # Point x polygon (point outside)
+        pytest.param(
+            "POINT (-1 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            111195.10117748393,
+            id="point_distance_polygon_outside",
+        ),
+        # Linestring x polygon (linestring fully inside)
+        pytest.param(
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="linestring_distance_polygon_inside",
+        ),
+        # Polygon x linestring (linestring fully inside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            0.0,
+            id="polygon_distance_linestring_inside",
+        ),
+        # Linestring x polygon (linestring partially crosses boundary)
+        pytest.param(
+            "LINESTRING (0.25 0.25, 3 3)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="linestring_distance_polygon_crossing",
+        ),
+        # Polygon x linestring (linestring partially crosses boundary)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 3 3)",
+            0.0,
+            id="polygon_distance_linestring_crossing",
+        ),
+        # Linestring x polygon (linestring crosses through, neither vertex 
inside)
+        pytest.param(
+            "LINESTRING (-1 0.5, 3 0.5)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="linestring_distance_polygon_through",
+        ),
+        # Polygon x linestring (linestring crosses through, neither vertex 
inside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (-1 0.5, 3 0.5)",
+            0.0,
+            id="polygon_distance_linestring_through",
+        ),
+        # Linestring x polygon (linestring fully outside)
+        pytest.param(
+            "LINESTRING (3 3, 4 4)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            314367.35908786188,
+            id="linestring_distance_polygon_outside",
+        ),
+        # Polygon x linestring (linestring fully outside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (3 3, 4 4)",
+            314367.35908786188,
+            id="polygon_distance_linestring_outside",
+        ),
+        # Polygon x polygon (one fully inside the other)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            0.0,
+            id="polygon_distance_polygon_inside",
+        ),
+        # Polygon x polygon (one fully inside, reversed)
+        pytest.param(
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="polygon_distance_polygon_inside_rev",
+        ),
+        # Polygon x polygon (partially overlapping)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((1 0, 3 0, 1 2, 1 0))",
+            0.0,
+            id="polygon_distance_polygon_crossing",
+        ),
+        # Polygon x polygon (partially overlapping, reversed)
+        pytest.param(
+            "POLYGON ((1 0, 3 0, 1 2, 1 0))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            id="polygon_distance_polygon_crossing_rev",
+        ),
+        # Polygon x polygon (fully outside)
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            4520972.0955287321,
+            id="polygon_distance_polygon_outside",
+        ),
+        # Polygon x polygon (fully outside, reversed)
+        pytest.param(
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            4520972.0955287321,
+            id="polygon_distance_polygon_outside_rev",
+        ),
+        # Polygon x polygon (north pole vs south pole)
+        pytest.param(
+            "POLYGON ((-120 80, 0 80, 120 80, -120 80))",
+            "POLYGON ((-120 -80, 0 -80, 120 -80, -120 -80))",
+            17791216.188397426,
+            id="polygon_distance_polygon_poles",
+        ),
+        # Polygon x polygon (north pole vs south pole, reversed)
+        pytest.param(
+            "POLYGON ((-120 -80, 0 -80, 120 -80, -120 -80))",
+            "POLYGON ((-120 80, 0 80, 120 80, -120 80))",
+            17791216.188397426,
+            id="polygon_distance_polygon_poles_rev",
+        ),
+        # Linestring x linestring (antipodal crossing)
+        pytest.param(
+            "LINESTRING (-90 -80, 90 -80)",
+            "LINESTRING (0 80, 180 80)",
+            18446595.193179362,
+            id="linestring_distance_linestring_poles",
+        ),
+        # Linestring x polygon (antipodal crossing)
+        pytest.param(
+            "LINESTRING (-90 -80, 90 -80)",
+            "POLYGON ((-120 90, 0 90, 120 90, -120 90))",
+            18903167.200172286,
+            id="linestring_distance_polygon_poles",
+        ),
+        # Polygon x linestring (antipodal crossing)
+        pytest.param(
+            "POLYGON ((-120 90, 0 90, 120 90, -120 90))",
+            "LINESTRING (-90 -80, 90 -80)",
+            18903167.200172286,
+            id="polygon_distance_linestring_poles",
+        ),
+        # Point x point (antipodal crossing)
+        pytest.param(
+            "POINT (0 -90)",
+            "POINT (0 90)",
+            20015118.21194711,
+            id="point_distance_point_poles",
+        ),
+        # GEOMETRYCOLLECTION tests
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            "POINT (0 0)",
+            0.0,
+            id="gc_no_polygon_distance_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            0.0,
+            id="point_distance_gc_no_polygon",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), POLYGON ((0 0, 2 0, 0 2, 0 0)))",
+            "POINT (0.25 0.25)",
+            0.0,
+            id="gc_with_polygon_distance_point_inside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "POINT (-1 0)",
+            111195.10117748393,
+            id="gc_with_polygon_distance_point_outside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            "LINESTRING (0 0.5, 1 0.5)",
+            0.0,
+            id="gc_no_polygon_distance_linestring",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            0.0,
+            id="gc_with_polygon_distance_linestring_inside",
+        ),
+        pytest.param(
+            "LINESTRING (3 3, 4 4)",
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            314367.35908786188,
+            id="linestring_distance_gc_with_polygon_outside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), POLYGON ((0 0, 2 0, 0 2, 0 0)))",
+            "GEOMETRYCOLLECTION (POINT (6 6), POLYGON ((0.5 0.5, 1.5 0.5, 0.5 
1.5, 0.5 0.5)))",
+            0.0,
+            id="gc_distance_gc_overlapping",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (0 0), POLYGON ((0 0, 1 0, 0 1, 0 0)))",
+            "GEOMETRYCOLLECTION (POINT (40 40), POLYGON ((30 30, 31 30, 30 31, 
30 30)))",
+            4520972.0955287321,
+            id="gc_distance_gc_disjoint",
+        ),
+    ],
+)
+def test_st_distance(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Distance({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "distance", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT EMPTY", 0.0, None, id="null_dwithin"),
+        pytest.param("POINT EMPTY", None, 0.0, None, id="dwithin_null"),
+        pytest.param(None, None, 0.0, None, id="null_dwithin_null"),
+        # Empties return False
+        pytest.param("POINT (0 0)", "POINT EMPTY", 0.0, False, 
id="dwithin_empty"),
+        pytest.param("POINT EMPTY", "POINT (0 0)", 0.0, False, 
id="empty_dwithin"),
+        # Point x point at exact distance
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            111195.10117748393,
+            True,
+            id="point_dwithin_point_exact",
+        ),
+        # Point x point just under distance
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            50000.0,
+            False,
+            id="point_not_dwithin_point",
+        ),
+        # Point inside polygon
+        pytest.param(
+            "POINT (0.25 0.25)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            0.0,
+            True,
+            id="point_dwithin_polygon_inside",
+        ),
+        # Point outside polygon within threshold
+        pytest.param(
+            "POINT (-1 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            200000.0,
+            True,
+            id="point_dwithin_polygon_threshold",
+        ),
+        # Point outside polygon beyond threshold
+        pytest.param(
+            "POINT (-1 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            50000.0,
+            False,
+            id="point_not_dwithin_polygon",
+        ),
+        # Distant polygons within threshold
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            5000000.0,
+            True,
+            id="polygon_dwithin_polygon_threshold",
+        ),
+        # Distant polygons beyond threshold
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            1000000.0,
+            False,
+            id="polygon_not_dwithin_polygon",
+        ),
+    ],
+)
+def test_st_dwithin(eng, geom1, geom2, distance, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_DWithin({geog_or_null(geom1)}, {geog_or_null(geom2)}, 
{distance})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT EMPTY", None, id="null_max_distance"),
+        pytest.param("POINT EMPTY", None, None, id="max_distance_null"),
+        pytest.param(None, None, None, id="null_max_distance_null"),
+        # Empties
+        pytest.param("POINT (0 0)", "POINT EMPTY", None, 
id="max_distance_empty"),
+        pytest.param("POINT EMPTY", "POINT (0 0)", None, 
id="empty_max_distance"),
+        # Point x point (same point)
+        pytest.param(
+            "POINT (0 0)", "POINT (0 0)", 0.0, 
id="point_max_distance_same_point"
+        ),
+        # Point x point
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            111195.10117748393,
+            id="point_max_distance_point",
+        ),
+        # Point x linestring (point on linestring)
+        pytest.param(
+            "POINT (0 0)",
+            "LINESTRING (0 0, 0 1)",
+            111195.10117748393,
+            id="point_max_distance_linestring_on",
+        ),
+        # Point x linestring (point off linestring)
+        pytest.param(
+            "POINT (1 0)",
+            "LINESTRING (0 0, 0 1)",
+            157249.62809250789,
+            id="point_max_distance_linestring_off",
+        ),
+        # Linestring x point (point on linestring)
+        pytest.param(
+            "LINESTRING (0 0, 0 1)",
+            "POINT (0 0)",
+            111195.10117748393,
+            id="linestring_max_distance_point_on",
+        ),
+        # Linestring x point (point off linestring)
+        pytest.param(
+            "LINESTRING (0 0, 0 1)",
+            "POINT (1 0)",
+            157249.62809250789,
+            id="linestring_max_distance_point_off",
+        ),
+        # Point x polygon (point inside)
+        pytest.param(
+            "POINT (0.25 0.25)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            196566.41390163341,
+            id="point_max_distance_polygon_inside",
+        ),
+        # Point x polygon (point on boundary)
+        pytest.param(
+            "POINT (0 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            222390.20235496786,
+            id="point_max_distance_polygon_boundary",
+        ),
+        # Point x polygon (point outside)
+        pytest.param(
+            "POINT (-1 0)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            333585.3035324518,
+            id="point_max_distance_polygon_outside",
+        ),
+        # Linestring x polygon (linestring fully inside)
+        pytest.param(
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            196566.41390163341,
+            id="linestring_max_distance_polygon_inside",
+        ),
+        # Polygon x linestring (linestring fully inside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            196566.41390163341,
+            id="polygon_max_distance_linestring_inside",
+        ),
+        # Linestring x polygon (linestring partially crosses boundary)
+        pytest.param(
+            "LINESTRING (0.25 0.25, 3 3)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            471653.02881023812,
+            id="linestring_max_distance_polygon_crossing",
+        ),
+        # Polygon x linestring (linestring partially crosses boundary)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (0.25 0.25, 3 3)",
+            471653.02881023812,
+            id="polygon_max_distance_linestring_crossing",
+        ),
+        # Linestring x polygon (linestring crosses through, neither vertex 
inside)
+        pytest.param(
+            "LINESTRING (-1 0.5, 3 0.5)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            372880.15844616242,
+            id="linestring_max_distance_polygon_through",
+        ),
+        # Polygon x linestring (linestring crosses through, neither vertex 
inside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (-1 0.5, 3 0.5)",
+            372880.15844616242,
+            id="polygon_max_distance_linestring_through",
+        ),
+        # Linestring x polygon (linestring fully outside)
+        pytest.param(
+            "LINESTRING (3 3, 4 4)",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            628758.78426786896,
+            id="linestring_max_distance_polygon_outside",
+        ),
+        # Polygon x linestring (linestring fully outside)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "LINESTRING (3 3, 4 4)",
+            628758.78426786896,
+            id="polygon_max_distance_linestring_outside",
+        ),
+        # Polygon x polygon (one fully inside the other)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            218461.11755505961,
+            id="polygon_max_distance_polygon_inside",
+        ),
+        # Polygon x polygon (one fully inside, reversed)
+        pytest.param(
+            "POLYGON ((0.1 0.1, 0.5 0.1, 0.1 0.5, 0.1 0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            218461.11755505961,
+            id="polygon_max_distance_polygon_inside_rev",
+        ),
+        # Polygon x polygon (partially overlapping)
+        pytest.param(
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            "POLYGON ((1 0, 3 0, 1 2, 1 0))",
+            400863.2536725945,
+            id="polygon_max_distance_polygon_crossing",
+        ),
+        # Polygon x polygon (partially overlapping, reversed)
+        pytest.param(
+            "POLYGON ((1 0, 3 0, 1 2, 1 0))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            400863.2536725945,
+            id="polygon_max_distance_polygon_crossing_rev",
+        ),
+        # Polygon x polygon (fully outside)
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            4677959.9936393471,
+            id="polygon_max_distance_polygon_outside",
+        ),
+        # Polygon x polygon (fully outside, reversed)
+        pytest.param(
+            "POLYGON ((30 30, 31 30, 30 31, 30 30))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            4677959.9936393471,
+            id="polygon_max_distance_polygon_outside_rev",
+        ),
+        # Polygon x polygon (north pole vs south pole)
+        pytest.param(
+            "POLYGON ((-120 80, 0 80, 120 80, -120 80))",
+            "POLYGON ((-120 -80, 0 -80, 120 -80, -120 -80))",
+            20015118.21194711,
+            id="polygon_max_distance_polygon_poles",
+        ),
+        # Polygon x polygon (north pole vs south pole, reversed)
+        pytest.param(
+            "POLYGON ((-120 -80, 0 -80, 120 -80, -120 -80))",
+            "POLYGON ((-120 80, 0 80, 120 80, -120 80))",
+            20015118.21194711,
+            id="polygon_max_distance_polygon_poles_rev",
+        ),
+        # Linestring x linestring (antipodal crossing)
+        pytest.param(
+            "LINESTRING (-90 -80, 90 -80)",
+            "LINESTRING (0 80, 180 80)",
+            20015118.022076216,
+            id="linestring_max_distance_linestring_poles",
+        ),
+        # Linestring x polygon (antipodal crossing)
+        pytest.param(
+            "LINESTRING (-90 -80, 90 -80)",
+            "POLYGON ((-120 90, 0 90, 120 90, -120 90))",
+            20015118.21194711,
+            id="linestring_max_distance_polygon_poles",
+        ),
+        # Polygon x linestring (antipodal crossing)
+        pytest.param(
+            "POLYGON ((-120 90, 0 90, 120 90, -120 90))",
+            "LINESTRING (-90 -80, 90 -80)",
+            20015118.21194711,
+            id="polygon_max_distance_linestring_poles",
+        ),
+        # Point x point (antipodal crossing)
+        pytest.param(
+            "POINT (0 -90)",
+            "POINT (0 90)",
+            20015118.21194711,
+            id="point_max_distance_point_poles",
+        ),
+        # GEOMETRYCOLLECTION tests
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            "POINT (0 0)",
+            785768.45419216133,
+            id="gc_no_polygon_max_distance_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            785768.45419216133,
+            id="point_max_distance_gc_no_polygon",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), POLYGON ((0 0, 2 0, 0 2, 0 0)))",
+            "POINT (0.25 0.25)",
+            746455.18632442318,
+            id="gc_with_polygon_max_distance_point_inside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "POINT (-1 0)",
+            4677959.9936393471,
+            id="gc_with_polygon_max_distance_point_outside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1))",
+            "LINESTRING (0 0.5, 1 0.5)",
+            747405.65220515686,
+            id="gc_no_polygon_max_distance_linestring",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            "LINESTRING (0.25 0.25, 0.5 0.5)",
+            4565335.4112626193,
+            id="gc_with_polygon_max_distance_linestring_inside",
+        ),
+        pytest.param(
+            "LINESTRING (3 3, 4 4)",
+            "GEOMETRYCOLLECTION (POINT (30 30), POLYGON ((0 0, 2 0, 0 2, 0 
0)))",
+            4134193.266520442,
+            id="linestring_max_distance_gc_with_polygon_outside",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), POLYGON ((0 0, 2 0, 0 2, 0 0)))",
+            "GEOMETRYCOLLECTION (POINT (6 6), POLYGON ((0.5 0.5, 1.5 0.5, 0.5 
1.5, 0.5 0.5)))",
+            942657.82524783083,
+            id="gc_max_distance_gc_overlapping",
+        ),
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (0 0), POLYGON ((0 0, 1 0, 0 1, 0 0)))",
+            "GEOMETRYCOLLECTION (POINT (40 40), POLYGON ((30 30, 31 30, 30 31, 
30 30)))",
+            6012101.3650370687,
+            id="gc_max_distance_gc_disjoint",
+        ),
+    ],
+)
+def test_st_max_distance(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_MaxDistance({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        numeric_epsilon=1e-2,
+    )
+
+
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Empties with ZM
+        pytest.param(
+            "POINT ZM (0 0 0 0)", "POINT ZM EMPTY", None, 
id="distance_empty_zm"
+        ),
+        pytest.param(
+            "POINT ZM EMPTY", "POINT ZM (0 0 0 0)", None, 
id="empty_distance_zm"
+        ),
+        # Point ZM x point ZM
+        pytest.param(
+            "POINT ZM (0 0 1 2)",
+            "POINT ZM (0 1 2 3)",
+            111195.10117748393,
+            id="point_distance_point_zm",
+        ),
+        # Point Z x point Z
+        pytest.param(
+            "POINT Z (0 0 1)",
+            "POINT Z (0 1 2)",
+            111195.10117748393,
+            id="point_distance_point_z",
+        ),
+        # Point M x point M
+        pytest.param(
+            "POINT M (0 0 2)",
+            "POINT M (0 1 3)",
+            111195.10117748393,
+            id="point_distance_point_m",
+        ),
+        # Z Point x polygon (point inside)
+        pytest.param(
+            "POINT Z (0.25 0.25 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            0.0,
+            id="point_z_distance_polygon_inside",
+        ),
+        # Z Point x polygon (point on boundary)
+        pytest.param(
+            "POINT Z (0 0 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            0.0,
+            id="point_z_distance_polygon_boundary",
+        ),
+        # Z Point x polygon (point outside)
+        pytest.param(
+            "POINT Z (-1 0 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            111195.10117748393,
+            id="point_z_distance_polygon_outside",
+        ),
+    ],
+)
+def test_st_distance_zm(geom1, geom2, expected):
+    eng = SedonaDB.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Distance({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        numeric_epsilon=1e-2,
+    )
+
+
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Z Point x polygon (point inside)
+        pytest.param(
+            "POINT Z (0.25 0.25 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            196566.41390163341,
+            id="point_z_max_distance_polygon_inside",
+        ),
+        # Z Point x polygon (point on boundary)
+        pytest.param(
+            "POINT Z (0 0 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            222390.20235496786,
+            id="point_z_max_distance_polygon_boundary",
+        ),
+        # Z Point x polygon (point outside)
+        pytest.param(
+            "POINT Z (-1 0 10)",
+            "POLYGON Z ((0 0 12, 2 0 12, 0 2 12, 0 0 12))",
+            333585.3035324518,
+            id="point_z_max_distance_polygon_outside",
+        ),
+    ],
+)
+def test_st_max_distance_zm(geom1, geom2, expected):
+    eng = SedonaDB.create_or_skip()

Review Comment:
   should we always parameterize for consistency? 
   



##########
python/sedonadb/tests/geography/test_geog_transformations.py:
##########
@@ -37,3 +37,839 @@ def test_st_centroid(eng, geom, expected):
     eng.assert_query_result(
         f"SELECT ST_Centroid({geog_or_null(geom)})", expected, wkt_precision=4
     )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "expected"),
+    [
+        # Nulls
+        pytest.param(None, None, id="null_centroid"),
+        # Empties
+        pytest.param("POINT EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="point_empty"),
+        pytest.param(
+            "LINESTRING EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="linestring_empty"
+        ),
+        pytest.param("POLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY", 
id="polygon_empty"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param("MULTIPOINT ((0 0), (0 1))", "POINT (0 0.5)", 
id="multipoint"),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "POINT (0 0.5)", 
id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 5)", "POINT (0 2.5)", 
id="linestring_two_segments"
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POINT (0.3333498812 0.3333442395)",
+            id="triangle",
+        ),
+    ],
+)
+def test_st_centroid_extended(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Centroid({geog_or_null(geog)})", expected, wkt_precision=10
+    )
+
+
+# Neither PostGIS nor BigQuery support ZM in their centroid calculation
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom", "expected"),
+    [
+        # Points with Z
+        pytest.param("POINT Z (0 1 10)", "POINT Z (0 1 10)", id="point_z"),
+        pytest.param(
+            "MULTIPOINT Z ((0 0 10), (0 1 11))",
+            "POINT Z (0 0.5 10.5)",
+            id="multipoint_z",
+        ),
+        # Points with M
+        pytest.param("POINT M (0 1 10)", "POINT M (0 1 10)", id="point_m"),
+        pytest.param(
+            "MULTIPOINT M ((0 0 10), (0 1 11))",
+            "POINT M (0 0.5 10.5)",
+            id="multipoint_m",
+        ),
+        # Points with ZM
+        pytest.param("POINT ZM (0 1 10 20)", "POINT ZM (0 1 10 20)", 
id="point_zm"),
+        pytest.param(
+            "MULTIPOINT ZM ((0 0 10 20), (0 1 11 21))",
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="multipoint_zm",
+        ),
+        # Linestrings with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11)",
+            "POINT Z (0 0.5 10.5)",
+            id="linestring_z",
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 5 15)",
+            "POINT Z (0 2.5 12.5)",
+            id="linestring_two_segments_z",
+        ),
+        # Linestrings with M
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11)",
+            "POINT M (0 0.5 10.5)",
+            id="linestring_m",
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11, 0 5 15)",
+            "POINT M (0 2.5 12.5)",
+            id="linestring_two_segments_m",
+        ),
+        # Linestrings with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21)",
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="linestring_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 5 15 25)",
+            "POINT ZM (0 2.5 12.5 22.5)",
+            id="linestring_two_segments_zm",
+        ),
+        # Polygons with Z
+        pytest.param(
+            "POLYGON Z ((0 0 10, 0 1 10, 1 0 10, 0 0 10))",
+            "POINT Z (0.3333 0.3333 10)",
+            id="triangle_z",
+        ),
+        pytest.param(
+            "POLYGON Z ((0 0 10, 0 2 10, 2 0 10, 0 0 10), "
+            "(0.1 0.1 11, 0.1 0.5 11, 0.5 0.1 11, 0.1 0.1 11))",
+            "POINT Z (0.6849 0.6848 10.0385)",
+            id="polygon_with_hole_z",
+        ),
+        # Polygons with M
+        pytest.param(
+            "POLYGON M ((0 0 10, 0 1 10, 1 0 10, 0 0 10))",
+            "POINT M (0.3333 0.3333 10)",
+            id="triangle_m",
+        ),
+        pytest.param(
+            "POLYGON M ((0 0 10, 0 2 10, 2 0 10, 0 0 10), "
+            "(0.1 0.1 11, 0.1 0.5 11, 0.5 0.1 11, 0.1 0.1 11))",
+            "POINT M (0.6849 0.6848 10.0385)",
+            id="polygon_with_hole_m",
+        ),
+        # Polygons with ZM
+        pytest.param(
+            "POLYGON ZM ((0 0 10 20, 0 1 10 20, 1 0 10 20, 0 0 10 20))",
+            "POINT ZM (0.3333 0.3333 10 20)",
+            id="triangle_zm",
+        ),
+        pytest.param(
+            "POLYGON ZM ((0 0 10 20, 0 2 10 20, 2 0 10 20, 0 0 10 20), "
+            "(0.1 0.1 11 21, 0.1 0.5 11 21, 0.5 0.1 11 21, 0.1 0.1 11 21))",
+            "POINT ZM (0.6849 0.6848 10.0385 20.0385)",
+            id="polygon_with_hole_zm",
+        ),
+    ],
+)
+def test_st_centroid_zm(eng, geom, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Centroid({geog_or_null(geom)})", expected, wkt_precision=4
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geog", "expected"),
+    [
+        # Nulls
+        pytest.param(None, None, id="null_convex_hull"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param(
+            "MULTIPOINT ((0 0), (0 1), (1 0))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="multipoint_three",
+        ),
+        # Linestrings
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 1 0)",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="linestring_non_colinear",
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POLYGON ((0 0, 1 0, 0 1, 0 0))",
+            id="triangle",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 0 2, 2 0, 0 0), (0.1 0.1, 0.1 0.5, 0.5 0.1, 0.1 
0.1))",
+            "POLYGON ((0 0, 2 0, 0 2, 0 0))",
+            id="polygon_with_hole",
+        ),
+        # GeometryCollection (convex hull of all vertices)
+        pytest.param(
+            "GEOMETRYCOLLECTION (POINT (5 5), LINESTRING (0 0, 0 1), POLYGON 
((0 0, 0 1, 1 0, 0 0)))",
+            "POLYGON ((0 0, 1 0, 5 5, 0 1, 0 0))",
+            id="geometrycollection",
+        ),
+    ],
+)
+def test_st_convexhull(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_ConvexHull({geog_or_null(geog)})", expected, 
wkt_precision=10
+    )
+
+
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geog", "expected"),
+    [
+        # Empties
+        pytest.param("POINT EMPTY", "POINT (nan nan)", id="point_empty"),
+        pytest.param("LINESTRING EMPTY", "LINESTRING EMPTY", 
id="linestring_empty"),
+        pytest.param("POLYGON EMPTY", "POLYGON EMPTY", id="polygon_empty"),
+        pytest.param(
+            "MULTIPOINT ((0 0), (0 1))", "LINESTRING (0 0, 0 1)", 
id="multipoint_two"
+        ),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "LINESTRING (0 0, 0 1)", 
id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            "LINESTRING (0 0, 0 2)",
+            id="linestring_colinear",
+        ),
+    ],
+)
+def test_st_convexhull_degenerate(eng, geog, expected):
+    # Empty/degenerate behaviour does not match BigQuery but instead matches
+    # what PostGIS would give for a geometry implementation.
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_ConvexHull({geog_or_null(geog)})",
+        expected,
+    )
+
+
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geog", "expected"),
+    [
+        # Empties
+        pytest.param("POINT EMPTY", "POINT (nan nan)", id="point_empty"),
+        pytest.param("LINESTRING EMPTY", "POINT (nan nan)", 
id="linestring_empty"),
+        pytest.param("POLYGON EMPTY", "POINT (nan nan)", id="polygon_empty"),
+        # Points
+        pytest.param("POINT (0 1)", "POINT (0 1)", id="point"),
+        pytest.param("MULTIPOINT ((0 0), (0 1))", "POINT (0 1)", 
id="multipoint"),
+        # Points with Z/M/ZM
+        pytest.param("POINT Z (0 1 10)", "POINT Z (0 1 10)", id="point_z"),
+        pytest.param("POINT M (0 1 10)", "POINT M (0 1 10)", id="point_m"),
+        pytest.param("POINT ZM (0 1 10 20)", "POINT ZM (0 1 10 20)", 
id="point_zm"),
+        # Linestrings
+        pytest.param("LINESTRING (0 0, 0 1)", "POINT (0 1)", id="linestring"),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 5)", "POINT (0 1)", 
id="linestring_three_vertices"
+        ),
+        # Linestrings with Z/M/ZM
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11)", "POINT Z (0 1 11)", 
id="linestring_z"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 1 11)", "POINT M (0 1 11)", 
id="linestring_m"
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21)",
+            "POINT ZM (0 1 11 21)",
+            id="linestring_zm",
+        ),
+        # Polygons
+        pytest.param(
+            "POLYGON ((0 0, 0 1, 1 0, 0 0))",
+            "POINT (0.224466 0.224464)",
+            id="triangle",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
+            "POINT (0.450237 0.450223)",
+            id="square",
+        ),
+        # Polygons with Z/M/ZM are not yet supported and return a 2D result
+    ],
+)
+def test_st_pointonsurface(eng, geog, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_PointOnSurface({geog_or_null(geog)})", expected, 
wkt_precision=6
+    )
+
+
[email protected]("eng", [SedonaDB, BigQuery, PostGIS])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Nulls
+        pytest.param(None, 0.5, None, id="null_line"),
+        # Endpoints and midpoints
+        pytest.param("LINESTRING (0 0, 0 2)", 0.0, "POINT (0 0)", id="start"),
+        pytest.param("LINESTRING (0 0, 0 2)", 1.0, "POINT (0 2)", id="end"),
+        pytest.param("LINESTRING (0 0, 0 2)", 0.5, "POINT (0 1)", 
id="midpoint"),
+        # Multi-segment line
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            0.25,
+            "POINT (0 0.5)",
+            id="multi_seg_quarter",
+        ),
+        pytest.param(
+            "LINESTRING (0 0, 0 1, 0 2)",
+            0.75,
+            "POINT (0 1.5)",
+            id="multi_seg_three_quarter",
+        ),
+        # Boundary fractions
+        pytest.param("LINESTRING (0 0, 0 2)", 0.0, "POINT (0 0)", 
id="fraction_zero"),
+        pytest.param("LINESTRING (0 0, 0 2)", 1.0, "POINT (0 2)", 
id="fraction_one"),
+    ],
+)
+def test_st_line_interpolate_point(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=4,
+    )
+
+
+# Degenerate behaviour matches PostGIS and not BigQuery
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Empties
+        pytest.param("LINESTRING EMPTY", 0.0, None, id="empty_line"),
+        # Degenerate
+        pytest.param(
+            "LINESTRING (1 1, 1 1)", 0.5, "POINT (1 1)", id="zero_length_line"
+        ),
+    ],
+)
+def test_st_line_interpolate_point_degenerate(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=15,
+    )
+
+
+# Z/M/ZM support only in SedonaDB
[email protected]("eng", [SedonaDB])
[email protected](
+    ("line", "fraction", "expected"),
+    [
+        # Linestring with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 0.0, "POINT Z (0 0 10)", 
id="start_z"
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 1.0, "POINT Z (0 2 12)", 
id="end_z"
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 2 12)", 0.5, "POINT Z (0 1 11)", 
id="midpoint_z"
+        ),
+        # Linestring with M
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 0.0, "POINT M (0 0 10)", 
id="start_m"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 1.0, "POINT M (0 2 12)", 
id="end_m"
+        ),
+        pytest.param(
+            "LINESTRING M (0 0 10, 0 2 12)", 0.5, "POINT M (0 1 11)", 
id="midpoint_m"
+        ),
+        # Linestring with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            0.0,
+            "POINT ZM (0 0 10 20)",
+            id="start_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            1.0,
+            "POINT ZM (0 2 12 22)",
+            id="end_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 2 12 22)",
+            0.5,
+            "POINT ZM (0 1 11 21)",
+            id="midpoint_zm",
+        ),
+        # Multi-segment line with Z
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 2 12)",
+            0.25,
+            "POINT Z (0 0.5 10.5)",
+            id="multi_seg_quarter_z",
+        ),
+        pytest.param(
+            "LINESTRING Z (0 0 10, 0 1 11, 0 2 12)",
+            0.75,
+            "POINT Z (0 1.5 11.5)",
+            id="multi_seg_three_quarter_z",
+        ),
+        # Multi-segment line with ZM
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 2 12 22)",
+            0.25,
+            "POINT ZM (0 0.5 10.5 20.5)",
+            id="multi_seg_quarter_zm",
+        ),
+        pytest.param(
+            "LINESTRING ZM (0 0 10 20, 0 1 11 21, 0 2 12 22)",
+            0.75,
+            "POINT ZM (0 1.5 11.5 21.5)",
+            id="multi_seg_three_quarter_zm",
+        ),
+    ],
+)
+def test_st_line_interpolate_point_zm(eng, line, fraction, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_LineInterpolatePoint({geog_or_null(line)}, 
{val_or_null(fraction)})",
+        expected,
+        wkt_precision=15,
+    )
+
+
+# ST_Buffer tests - creates a buffer polygon around a geometry
[email protected]("eng", [SedonaDB, BigQuery, PostGIS])
[email protected](
+    ("geog", "distance", "expected"),
+    [
+        # Null
+        pytest.param(None, 100000.0, None, id="null_buffer"),
+        pytest.param("POINT (0 1)", None, None, id="buffer_null"),
+        # Point with positive distance: produces a polygon approximating a 
circle
+        pytest.param(
+            "POINT (0 0)",
+            100000.0,
+            31213847614.041348,
+            id="point_positive_distance",
+        ),
+        # Linestring with positive distance: produces a buffered corridor
+        pytest.param(
+            "LINESTRING (0 0, 1 0)",
+            100000.0,
+            53452519026.41781,
+            id="linestring_positive_distance",
+        ),
+        # Polygon with positive distance: expands the polygon
+        pytest.param(
+            "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
+            100000.0,
+            88052039626.29015,
+            id="polygon_positive_distance",
+        ),

Review Comment:
   should we do a triangle? rectangle? concave geometry?



##########
python/sedonadb/tests/geography/test_geog_overlay.py:
##########
@@ -0,0 +1,605 @@
+# 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.
+
+import pytest
+import sedonadb
+from sedonadb.testing import BigQuery, SedonaDB, geog_or_null
+
+if "s2geography" not in sedonadb.__features__:
+    pytest.skip("Python package built without s2geography", 
allow_module_level=True)
+
+
+# ST_Intersection tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_intersection"),
+        pytest.param("POINT (0 0)", None, None, id="intersection_null"),
+        pytest.param(None, None, None, id="null_intersection_null"),
+        # Point + Point: same
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (0 0)", 
id="point_same"),
+        # Multipoint + Point: overlap
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (0 0)",
+            "POINT (0 0)",
+            id="multipoint_point_overlap",
+        ),
+        # Linestring + Linestring: same
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_same",
+        ),
+        # Linestring + Linestring: crossing (intersection is a point)
+        pytest.param(
+            "LINESTRING (0 -5, 0 5)",
+            "LINESTRING (-5 0, 5 0)",
+            "POINT (0 0)",
+            id="linestring_crossing",
+        ),
+        # Polygon + Polygon: same
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_same",
+        ),
+        # Point + Linestring: point at endpoint
+        pytest.param(
+            "POINT (0 0)",
+            "LINESTRING (0 0, 10 0)",
+            "POINT (0 0)",
+            id="point_on_linestring",
+        ),
+        # Point + Polygon: point inside
+        pytest.param(
+            "POINT (5 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (5 5)",
+            id="point_inside_polygon",
+        ),
+        # Point + Polygon: point on boundary
+        pytest.param(
+            "POINT (10 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (10 5)",
+            id="point_on_polygon_boundary",
+        ),
+        # Linestring + Polygon: line inside
+        pytest.param(
+            "LINESTRING (2 5, 8 5)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING (2 5, 8 5)",
+            id="linestring_inside_polygon",
+        ),
+    ],
+)
+def test_st_intersection(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties - BigQuery doesn't return specific empty types consistently
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT EMPTY",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="both_empty",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a_polygon",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_intersection_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return EMPTY - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Linestring + Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "LINESTRING EMPTY",
+            id="linestring_disjoint",
+        ),
+        # Polygon + Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "POLYGON EMPTY",
+            id="polygon_disjoint",
+        ),
+        # Linestring + Polygon: line outside
+        pytest.param(
+            "LINESTRING (20 0, 30 0)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "LINESTRING EMPTY",
+            id="linestring_outside_polygon",
+        ),
+    ],
+)
+def test_st_intersection_returns_empty(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Results that return POINT (nan nan) - BigQuery differs in handling
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Point + Point: different
+        pytest.param(
+            "POINT (0 0)", "POINT (0 1)", "POINT (nan nan)", 
id="point_different"
+        ),
+        # Multipoint + Point: disjoint
+        pytest.param(
+            "MULTIPOINT ((0 0), (1 1))",
+            "POINT (2 2)",
+            "POINT (nan nan)",
+            id="multipoint_point_disjoint",
+        ),
+        # Point + Linestring: point off line
+        pytest.param(
+            "POINT (5 5)",
+            "LINESTRING (0 0, 10 0)",
+            "POINT (nan nan)",
+            id="point_off_linestring",
+        ),
+        # Point + Polygon: point outside
+        pytest.param(
+            "POINT (20 20)",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POINT (nan nan)",
+            id="point_outside_polygon",
+        ),
+    ],
+)
+def test_st_intersection_returns_empty_point(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Intersection({geog_or_null(geom1)}, 
{geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# ST_Difference tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_difference"),
+        pytest.param("POINT (0 0)", None, None, id="difference_null"),
+        pytest.param(None, None, None, id="null_difference_null"),
+        # Point - Point: different
+        pytest.param("POINT (0 0)", "POINT (0 1)", "POINT (0 0)", 
id="point_different"),
+        # Linestring - Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_disjoint",
+        ),
+        # Polygon - Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            id="polygon_disjoint",
+        ),
+    ],
+)
+def test_st_difference(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties - BigQuery doesn't return specific empty types consistently
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Point - Point: same -> empty
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (nan nan)", 
id="point_same"),
+        # Linestring - Linestring: same -> empty
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING EMPTY",
+            id="linestring_same",
+        ),
+        # Polygon - Polygon: same -> empty
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            id="polygon_same",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "GEOMETRYCOLLECTION EMPTY",
+            id="empty_a",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "POINT (0 0)",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_difference_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Far apart geometries (coverings don't intersect)
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (180 0)",
+            "POINT (0 0)",
+            id="point_very_far",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((170 -5, 175 -5, 175 0, 170 0, 170 -5))",
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            id="polygon_very_far",
+        ),
+    ],
+)
+def test_st_difference_very_far(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Difference({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# ST_Union tests
[email protected]("eng", [SedonaDB, BigQuery])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_union"),
+        pytest.param("POINT (0 0)", None, None, id="union_null"),
+        pytest.param(None, None, None, id="null_union_null"),
+        # Point + Point: same
+        pytest.param("POINT (0 0)", "POINT (0 0)", "POINT (0 0)", 
id="point_same"),
+        # Point + Point: different
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            "MULTIPOINT (0 0, 0 1)",
+            id="point_different",
+        ),
+        # Multipoint + Point
+        pytest.param(
+            "MULTIPOINT (0 0, 1 1)",
+            "POINT (2 2)",
+            "MULTIPOINT (0 0, 1 1, 2 2)",
+            id="multipoint_point",
+        ),
+        # Multipoint + Point: overlap
+        pytest.param(
+            "MULTIPOINT (0 0, 1 1)",
+            "POINT (0 0)",
+            "MULTIPOINT (0 0, 1 1)",
+            id="multipoint_point_overlap",
+        ),
+        # Linestring + Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "MULTILINESTRING ((0 0, 10 0), (0 10, 10 10))",
+            id="linestring_disjoint",
+        ),
+        # Linestring + Linestring: same
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 0, 10 0)",
+            id="linestring_same",
+        ),
+        # Polygon + Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "MULTIPOLYGON (((0 0, 5 0, 5 5, 0 5, 0 0)), "
+            "((10 10, 15 10, 15 15, 10 15, 10 10)))",
+            id="polygon_disjoint",
+        ),
+        # Polygon + Polygon: same
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="polygon_same",
+        ),
+    ],
+)
+def test_st_union(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Union({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# Empties
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        pytest.param(
+            "POINT EMPTY",
+            "POINT EMPTY",
+            "POINT (nan nan)",
+            id="both_empty",
+        ),
+        pytest.param(
+            "POINT EMPTY",
+            "POINT (0 0)",
+            "POINT (0 0)",
+            id="empty_a_point",
+        ),
+        pytest.param(
+            "POINT (0 0)",
+            "POINT EMPTY",
+            "POINT (0 0)",
+            id="empty_b_point",
+        ),
+        pytest.param(
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="empty_a_polygon",
+        ),
+        pytest.param(
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            "POLYGON EMPTY",
+            "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
+            id="empty_b_polygon",
+        ),
+    ],
+)
+def test_st_union_empties(eng, geom1, geom2, expected):
+    eng = eng.create_or_skip()
+    eng.assert_query_result(
+        f"SELECT ST_Union({geog_or_null(geom1)}, {geog_or_null(geom2)})",
+        expected,
+        wkt_precision=6,
+    )
+
+
+# ST_SymDifference tests (not implemented on BigQuery)
[email protected]("eng", [SedonaDB])
[email protected](
+    ("geom1", "geom2", "expected"),
+    [
+        # Nulls
+        pytest.param(None, "POINT (0 0)", None, id="null_symdifference"),
+        pytest.param("POINT (0 0)", None, None, id="symdifference_null"),
+        pytest.param(None, None, None, id="null_symdifference_null"),
+        # Point symdiff Point: different
+        pytest.param(
+            "POINT (0 0)",
+            "POINT (0 1)",
+            "MULTIPOINT (0 0, 0 1)",
+            id="point_different",
+        ),
+        # Linestring symdiff Linestring: disjoint
+        pytest.param(
+            "LINESTRING (0 0, 10 0)",
+            "LINESTRING (0 10, 10 10)",
+            "MULTILINESTRING ((0 0, 10 0), (0 10, 10 10))",
+            id="linestring_disjoint",
+        ),
+        # Polygon symdiff Polygon: disjoint
+        pytest.param(
+            "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
+            "POLYGON ((10 10, 15 10, 15 15, 10 15, 10 10))",
+            "MULTIPOLYGON (((0 0, 5 0, 5 5, 0 5, 0 0)), "
+            "((10 10, 15 10, 15 15, 10 15, 10 10)))",
+            id="polygon_disjoint",
+        ),
+    ],
+)
+def test_st_symdifference(eng, geom1, geom2, expected):

Review Comment:
   this could use some partially overlapping cases too
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to