blob: 607ff93e6d5be8a81707023e6342476b88f78e32 [file]
# 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 json
import re
import warnings
import geopandas as gpd
import numpy as np
import pandas as pd
import pytest
import sedonadb
from sedonadb.testing import PostGIS, SedonaDB, random_geometry, skip_if_not_exists
from shapely.geometry import Point
@pytest.mark.parametrize(
"join_type", ["INNER JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN"]
)
@pytest.mark.parametrize(
"on",
[
"ST_Intersects(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_Within(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_Contains(sjoin_polygon.geometry, sjoin_point.geometry)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, 1.0)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, sjoin_point.dist / 100)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, sjoin_polygon.dist / 100)",
],
)
def test_spatial_join(join_type, on):
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
df_point = random_geometry("Point", 100, seed=42)
df_polygon = random_geometry(
"Polygon", 100, hole_rate=0.5, num_vertices=(2, 10), seed=43
)
eng_sedonadb.create_table_arrow("sjoin_point", df_point)
eng_sedonadb.create_table_arrow("sjoin_polygon", df_polygon)
eng_postgis.create_table_arrow("sjoin_point", df_point)
eng_postgis.create_table_arrow("sjoin_polygon", df_polygon)
sql = f"""
SELECT sjoin_point.id id0, sjoin_polygon.id id1
FROM sjoin_point {join_type} sjoin_polygon
ON {on}
ORDER BY id0, id1
"""
sedonadb_results = eng_sedonadb.execute_and_collect(sql).to_pandas()
assert len(sedonadb_results) > 0
eng_postgis.assert_query_result(sql, sedonadb_results)
def _plan_text(df):
query_plan = df.to_pandas()
return "\n".join(query_plan.iloc[:, 1].astype(str).tolist())
def _spatial_join_side_file_names(plan_text):
"""Extract the left/right parquet file names used by `SpatialJoinExec`.
Example input:
SpatialJoinExec: join_type=Inner, on=ST_intersects(geo_right@0, geo_left@0)
ProjectionExec: expr=[geometry@0 as geo_right]
DataSourceExec: file_groups={1 group: [[.../natural-earth_countries_geo.parquet]]}, projection=[geometry], file_type=parquet
ProbeShuffleExec: partitioning=RoundRobinBatch(1)
ProjectionExec: expr=[geometry@0 as geo_left]
DataSourceExec: file_groups={1 group: [[.../natural-earth_cities_geo.parquet]]}, projection=[geometry], file_type=parquet
Example output:
["natural-earth_countries_geo", "natural-earth_cities_geo"]
"""
spatial_join_idx = plan_text.find("SpatialJoinExec:")
assert spatial_join_idx != -1, plan_text
file_names = re.findall(
r"DataSourceExec:.*?/([^/\]]+)\.parquet", plan_text[spatial_join_idx:]
)
assert len(file_names) >= 2, plan_text
return file_names[:2]
def test_spatial_join_reordering_can_be_disabled_e2e(geoarrow_data):
path_left = (
geoarrow_data / "natural-earth" / "files" / "natural-earth_cities_geo.parquet"
)
path_right = (
geoarrow_data
/ "natural-earth"
/ "files"
/ "natural-earth_countries_geo.parquet"
)
skip_if_not_exists(path_left)
skip_if_not_exists(path_right)
with SedonaDB.create_or_skip() as eng_sedonadb:
sql = f"""
SELECT t1.name
FROM '{path_left}' AS t1
JOIN '{path_right}' AS t2
ON ST_Intersects(t1.geometry, t2.geometry)
"""
# Test 1: regular run swaps the join order
plan_text = _plan_text(eng_sedonadb.con.sql(f"EXPLAIN {sql}"))
print(f"Plan with reordering enabled:\n{plan_text}")
assert _spatial_join_side_file_names(plan_text) == [
"natural-earth_countries_geo",
"natural-earth_cities_geo",
], plan_text
result_with_reordering = (
eng_sedonadb.execute_and_collect(sql)
.to_pandas()
.sort_values("name")
.reset_index(drop=True)
)
assert len(result_with_reordering) > 0
# Test 2: with config disabled, join won't reorder
eng_sedonadb.con.sql(
"SET sedona.spatial_join.spatial_join_reordering TO false"
).execute()
plan_text = _plan_text(eng_sedonadb.con.sql(f"EXPLAIN {sql}"))
print(f"Plan with reordering disabled:\n{plan_text}")
assert _spatial_join_side_file_names(plan_text) == [
"natural-earth_cities_geo",
"natural-earth_countries_geo",
], plan_text
result_without_reordering = (
eng_sedonadb.execute_and_collect(sql)
.to_pandas()
.sort_values("name")
.reset_index(drop=True)
)
pd.testing.assert_frame_equal(
result_without_reordering,
result_with_reordering,
)
@pytest.mark.parametrize(
"join_type",
[
"LEFT SEMI JOIN",
"LEFT ANTI JOIN",
"RIGHT SEMI JOIN",
"RIGHT ANTI JOIN",
],
)
@pytest.mark.parametrize(
"on",
[
"ST_Intersects(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_Within(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_Contains(sjoin_polygon.geometry, sjoin_point.geometry)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, 1.0)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, sjoin_point.dist / 100)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, sjoin_polygon.dist / 100)",
],
)
def test_spatial_join_semi_anti(join_type, on):
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
options = json.dumps(
{
"geom_type": "Point",
"polygon_hole_rate": 0.5,
"num_parts_range": [2, 10],
"vertices_per_linestring_range": [2, 10],
"seed": 42,
}
)
df_point = eng_sedonadb.execute_and_collect(
f"SELECT * FROM sd_random_geometry('{options}') LIMIT 100"
)
options = json.dumps(
{
"geom_type": "Polygon",
"polygon_hole_rate": 0.5,
"num_parts_range": [2, 10],
"vertices_per_linestring_range": [2, 10],
"seed": 43,
}
)
df_polygon = eng_sedonadb.execute_and_collect(
f"SELECT * FROM sd_random_geometry('{options}') LIMIT 100"
)
eng_sedonadb.create_table_arrow("sjoin_point", df_point)
eng_sedonadb.create_table_arrow("sjoin_polygon", df_polygon)
eng_postgis.create_table_arrow("sjoin_point", df_point)
eng_postgis.create_table_arrow("sjoin_polygon", df_polygon)
is_left = join_type.startswith("LEFT")
is_semi = "SEMI" in join_type
if is_left:
sedona_sql = f"""
SELECT sjoin_point.id id0
FROM sjoin_point {join_type} sjoin_polygon
ON {on}
ORDER BY id0
"""
exists = f"EXISTS (SELECT 1 FROM sjoin_polygon WHERE {on})"
where = exists if is_semi else f"NOT {exists}"
postgis_sql = f"""
SELECT sjoin_point.id id0
FROM sjoin_point
WHERE {where}
ORDER BY id0
"""
else:
sedona_sql = f"""
SELECT sjoin_polygon.id id1
FROM sjoin_point {join_type} sjoin_polygon
ON {on}
ORDER BY id1
"""
exists = f"EXISTS (SELECT 1 FROM sjoin_point WHERE {on})"
where = exists if is_semi else f"NOT {exists}"
postgis_sql = f"""
SELECT sjoin_polygon.id id1
FROM sjoin_polygon
WHERE {where}
ORDER BY id1
"""
sedonadb_results = eng_sedonadb.execute_and_collect(sedona_sql).to_pandas()
assert len(sedonadb_results) > 0
eng_postgis.assert_query_result(postgis_sql, sedonadb_results)
@pytest.mark.parametrize(
"outer",
["point", "polygon"],
)
@pytest.mark.parametrize(
"on",
[
"ST_Intersects(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_Within(sjoin_point.geometry, sjoin_polygon.geometry)",
"ST_DWithin(sjoin_point.geometry, sjoin_polygon.geometry, 1.0)",
],
)
def test_spatial_mark_join_via_correlated_exists(outer, on):
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
options = json.dumps(
{
"geom_type": "Point",
"polygon_hole_rate": 0.5,
"num_parts_range": [2, 10],
"vertices_per_linestring_range": [2, 10],
"seed": 42,
}
)
df_point = eng_sedonadb.execute_and_collect(
f"SELECT * FROM sd_random_geometry('{options}') LIMIT 100"
)
options = json.dumps(
{
"geom_type": "Polygon",
"polygon_hole_rate": 0.5,
"num_parts_range": [2, 10],
"vertices_per_linestring_range": [2, 10],
"seed": 43,
}
)
df_polygon = eng_sedonadb.execute_and_collect(
f"SELECT * FROM sd_random_geometry('{options}') LIMIT 100"
)
eng_sedonadb.create_table_arrow("sjoin_point", df_point)
eng_sedonadb.create_table_arrow("sjoin_polygon", df_polygon)
eng_postgis.create_table_arrow("sjoin_point", df_point)
eng_postgis.create_table_arrow("sjoin_polygon", df_polygon)
if outer == "point":
sql = f"""
SELECT sjoin_point.id id0
FROM sjoin_point
WHERE sjoin_point.id = 1 OR EXISTS (SELECT 1 FROM sjoin_polygon WHERE {on})
ORDER BY id0
"""
else:
sql = f"""
SELECT sjoin_polygon.id id1, ST_AsBinary(sjoin_polygon.geometry) geom
FROM sjoin_polygon
WHERE sjoin_polygon.id = 1 OR EXISTS (SELECT 1 FROM sjoin_point WHERE {on})
ORDER BY id1
"""
# Verify the physical query plan contains a Mark join
query_plan = eng_sedonadb.execute_and_collect(f"EXPLAIN {sql}").to_pandas()
plan_text = "\n".join(query_plan.iloc[:, 1].astype(str).tolist())
assert any(
"SpatialJoinExec" in line and ("LeftMark" in line or "RightMark" in line)
for line in plan_text.splitlines()
), plan_text
sedonadb_results = eng_sedonadb.execute_and_collect(sql).to_pandas()
assert len(sedonadb_results) > 0
eng_postgis.assert_query_result(sql, sedonadb_results)
@pytest.mark.parametrize(
"join_type", ["INNER JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN"]
)
@pytest.mark.parametrize(
"on",
[
"ST_Intersects(sjoin_geog1.geog, sjoin_geog2.geog)",
"ST_Distance(sjoin_geog1.geog, sjoin_geog2.geog) < 100000",
],
)
def test_spatial_join_geography(join_type, on):
if "s2geography" not in sedonadb.__features__:
pytest.skip("Python package built without s2geography")
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
# Select two sets of bounding boxes that cross the antimeridian,
# which would be disjoint on a Euclidean plane. A geography join will produce non-empty results,
# whereas a geometry join would not.
west_most_bound = [-190, -10, -170, 10]
east_most_bound = [170, -10, 190, 10]
options = json.dumps(
{
"geom_type": "Point",
"num_parts": [2, 10],
"num_vertices": [2, 10],
"bounds": west_most_bound,
"size": [0.1, 5],
"seed": 542,
}
)
df_point = eng_sedonadb.execute_and_collect(
f"SELECT id, ST_SetSRID(ST_GeogFromWKB(ST_AsBinary(geometry)), 4326) geog, dist FROM sd_random_geometry('{options}') LIMIT 100"
)
options = json.dumps(
{
"geom_type": "Polygon",
"hole_rate": 0.5,
"num_parts": [2, 10],
"num_vertices": [2, 10],
"bounds": east_most_bound,
"size": [0.1, 5],
"seed": 44,
}
)
df_polygon = eng_sedonadb.execute_and_collect(
f"SELECT id, ST_SetSRID(ST_GeogFromWKB(ST_AsBinary(geometry)), 4326) geog, dist FROM sd_random_geometry('{options}') LIMIT 100"
)
eng_sedonadb.create_table_arrow("sjoin_geog1", df_point)
eng_sedonadb.create_table_arrow("sjoin_geog2", df_polygon)
eng_postgis.create_table_arrow("sjoin_geog1", df_point)
eng_postgis.create_table_arrow("sjoin_geog2", df_polygon)
sql = f"""
SELECT sjoin_geog1.id id0, sjoin_geog2.id id1
FROM sjoin_geog1 {join_type} sjoin_geog2
ON {on}
ORDER BY id0, id1
"""
sedonadb_results = eng_sedonadb.execute_and_collect(sql).to_pandas()
eng_postgis.assert_query_result(sql, sedonadb_results)
def test_query_window_in_subquery():
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
df_point = random_geometry("Point", 100, seed=100)
df_polygon = random_geometry(
"Polygon", 100, hole_rate=0.5, num_vertices=(2, 10), size=(50, 60), seed=999
)
eng_sedonadb.create_table_arrow("sjoin_point", df_point)
eng_sedonadb.create_table_arrow("sjoin_polygon", df_polygon)
eng_postgis.create_table_arrow("sjoin_point", df_point)
eng_postgis.create_table_arrow("sjoin_polygon", df_polygon)
# This should be optimized to a spatial join
sql = """
SELECT id FROM sjoin_point AS L
WHERE ST_Intersects(L.geometry, (SELECT R.geometry FROM sjoin_polygon AS R WHERE R.id = 1))
ORDER BY id
"""
# Verify that the physical query plan should contain a SpatialJoinExec
query_plan = eng_sedonadb.execute_and_collect(f"EXPLAIN {sql}").to_pandas()
assert "SpatialJoinExec" in query_plan.iloc[1, 1]
sedonadb_results = eng_sedonadb.execute_and_collect(sql).to_pandas()
assert len(sedonadb_results) > 0
eng_postgis.assert_query_result(sql, sedonadb_results)
def test_non_optimizable_subquery():
with (
SedonaDB.create_or_skip() as eng_sedonadb,
PostGIS.create_or_skip() as eng_postgis,
):
df_main = random_geometry("Point", 100, seed=42)
df_subquery = random_geometry("Point", 100, seed=43)
eng_sedonadb.create_table_arrow("sjoin_main", df_main)
eng_sedonadb.create_table_arrow("sjoin_subquery", df_subquery)
eng_postgis.create_table_arrow("sjoin_main", df_main)
eng_postgis.create_table_arrow("sjoin_subquery", df_subquery)
# This cannot be optimized to a spatial join, but the query result should still be correct
sql = """
SELECT id FROM sjoin_main AS L
WHERE ST_DWithin(L.geometry, ST_Point(10, 10), (SELECT R.dist FROM sjoin_subquery AS R WHERE R.id = 1))
ORDER BY id
"""
sedonadb_results = eng_sedonadb.execute_and_collect(sql).to_pandas()
assert len(sedonadb_results) > 0
eng_postgis.assert_query_result(sql, sedonadb_results)
def test_spatial_join_with_pandas_metadata(con):
# Previous versions of SedonaDB failed to execute this because of a mismatched
# schema. Attempts to simplify this reproducer weren't able to recreate the
# initial error (PhysicalOptimizer rule 'join_selection' failed).
# https://github.com/apache/sedona-db/issues/477
# 1. Generate Data
n_points = 1000
n_polys = 10
# Points
rng = np.random.Generator(np.random.MT19937(49791))
lons = rng.uniform(-6, 2, n_points)
lats = rng.uniform(50, 59, n_points)
pts_df = pd.DataFrame(
{"idx": range(n_points), "geometry": [Point(x, y) for x, y in zip(lons, lats)]}
)
pts_gdf = gpd.GeoDataFrame(pts_df, crs="EPSG:4326")
# Polygons (Centers buffered)
plons = rng.uniform(-6, 2, n_polys)
plats = rng.uniform(50, 59, n_polys)
poly_centers = gpd.GeoDataFrame(
{"geometry": [Point(x, y) for x, y in zip(plons, plats)]}, crs="EPSG:4326"
)
# Simple buffer in degrees (test data so we don't need the GeoPandas warning here)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
polys_gdf = poly_centers.buffer(0.1).to_frame(name="geometry")
# 2. Load
con.create_data_frame(pts_gdf).to_view("points", overwrite=True)
con.create_data_frame(polys_gdf).to_view("polygons", overwrite=True)
# 3. Intersection
query = """
SELECT p.idx
FROM points AS p, polygons AS poly
WHERE ST_Intersects(p.geometry, poly.geometry)
ORDER BY p.idx
"""
res = con.sql(query).to_pandas()
pd.testing.assert_frame_equal(res, pd.DataFrame({"idx": [304, 342, 490, 705]}))