Repository: geopandas/geopandas

Issue:
ENH: support how="inner" in sjoin_nearest
Followup to #1865 .

We should also discuss if `"inner"` should be the default.


Discussion:
@adriangb did you already start looking at it? (no pressure, just to know if I would start writing some tests myself I would be duplicating work or not)
I started, but haven't spent more than 30 min on it so no worries about duplicate work 😃
If you have something, feel free to push a branch to your fork even if it's only minor work (in any case, I won't be able to start looking at before this evening)

Resolving patch:
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -127,9 +127,7 @@ def sjoin(
     return joined
 
 
-def _basic_checks(
-    left_df, right_df, how, lsuffix, rsuffix, allowed_hows=("left", "right", "inner")
-):
+def _basic_checks(left_df, right_df, how, lsuffix, rsuffix):
     """Checks the validity of join input parameters.
 
     `how` must be one of the valid options.
@@ -140,7 +138,7 @@ def _basic_checks(
     ------------
     left_df : GeoDataFrame
     right_df : GeoData Frame
-    how : str, one of allowed_hows
+    how : str, one of 'left', 'right', 'inner'
         join type
     lsuffix : str
         left index suffix
@@ -157,6 +155,7 @@ def _basic_checks(
             "'right_df' should be GeoDataFrame, got {}".format(type(right_df))
         )
 
+    allowed_hows = ["left", "right", "inner"]
     if how not in allowed_hows:
         raise ValueError(
             '`how` was "{}" but is expected to be in {}'.format(how, allowed_hows)
@@ -403,7 +402,7 @@ def _nearest_query(
 def sjoin_nearest(
     left_df: GeoDataFrame,
     right_df: GeoDataFrame,
-    how: str = "left",
+    how: str = "inner",
     max_distance: Optional[float] = None,
     lsuffix: str = "left",
     rsuffix: str = "right",
@@ -422,11 +421,13 @@ def sjoin_nearest(
     Parameters
     ----------
     left_df, right_df : GeoDataFrames
-    how : string, default 'left'
+    how : string, default 'inner'
         The type of join:
 
         * 'left': use keys from left_df; retain only left_df geometry column
         * 'right': use keys from right_df; retain only right_df geometry column
+        * 'inner': use intersection of keys from both dfs; retain only
+          left_df geometry column
     max_distance : float, default None
         Maximum distance within which to query for nearest geometry.
         Must be greater than 0.
@@ -503,9 +504,7 @@ def sjoin_nearest(
     Every operation in GeoPandas is planar, i.e. the potential third
     dimension is not taken into account.
     """
-    _basic_checks(
-        left_df, right_df, how, lsuffix, rsuffix, allowed_hows=("left", "right")
-    )
+    _basic_checks(left_df, right_df, how, lsuffix, rsuffix)
 
     left_df.geometry.values.check_geographic_crs(stacklevel=1)
     right_df.geometry.values.check_geographic_crs(stacklevel=1)


Test patch:
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -585,13 +585,15 @@ def test_no_nearest_all():
     ),
 )
 class TestNearest:
-    @pytest.mark.parametrize("how_kwargs", ({}, {"how": "left"}, {"how": "right"}))
+    @pytest.mark.parametrize(
+        "how_kwargs", ({}, {"how": "inner"}, {"how": "left"}, {"how": "right"})
+    )
     def test_allowed_hows(self, how_kwargs):
         left = geopandas.GeoDataFrame({"geometry": []})
         right = geopandas.GeoDataFrame({"geometry": []})
         sjoin_nearest(left, right, **how_kwargs)  # no error
 
-    @pytest.mark.parametrize("how", ("inner", "abcde"))
+    @pytest.mark.parametrize("how", ("outer", "abcde"))
     def test_invalid_hows(self, how: str):
         left = geopandas.GeoDataFrame({"geometry": []})
         right = geopandas.GeoDataFrame({"geometry": []})
@@ -629,16 +631,12 @@ def test_empty_right_df_how_right(self, distance_col: str):
         if distance_col is not None:
             assert distance_col in joined
 
+    @pytest.mark.parametrize("how", ["inner", "left"])
     @pytest.mark.parametrize("distance_col", (None, "distance"))
-    def test_empty_left_df_how_left(self, distance_col: str):
+    def test_empty_left_df_how_left(self, how, distance_col: str):
         right = geopandas.GeoDataFrame({"geometry": [Point(0, 0), Point(1, 1)]})
         left = geopandas.GeoDataFrame({"geometry": []})
-        joined = sjoin_nearest(
-            left,
-            right,
-            how="left",
-            distance_col=distance_col,
-        )
+        joined = sjoin_nearest(left, right, how=how, distance_col=distance_col)
         assert joined.empty
         if distance_col is not None:
             assert distance_col in joined
@@ -658,7 +656,8 @@ def test_empty_left_df_how_right(self, distance_col: str):
         if distance_col is not None:
             assert joined[distance_col].isna().all()
 
-    def test_empty_join_due_to_max_distance_how_left(self):
+    @pytest.mark.parametrize("how", ["inner", "left"])
+    def test_empty_join_due_to_max_distance_how_left(self, how):
         # after applying max_distance the join comes back empty
         # (as in NaN in the joined columns)
         left = geopandas.GeoDataFrame({"geometry": [Point(0, 0)]})
@@ -666,13 +665,16 @@ def test_empty_join_due_to_max_distance_how_left(self):
         joined = sjoin_nearest(
             left,
             right,
-            how="left",
+            how=how,
             max_distance=1,
             distance_col="distances",
         )
         expected = left.copy()
         expected["index_right"] = [np.nan]
         expected["distances"] = [np.nan]
+        if how == "inner":
+            expected = expected.dropna()
+            expected["index_right"] = expected["index_right"].astype("int64")
         assert_geodataframe_equal(joined, expected)
 
     def test_empty_join_due_to_max_distance_how_right(self):
@@ -693,19 +695,23 @@ def test_empty_join_due_to_max_distance_how_right(self):
         expected = expected[["index_left", "geometry", "distances"]]
         assert_geodataframe_equal(joined, expected)
 
-    def test_max_distance_how_left(self):
+    @pytest.mark.parametrize("how", ["inner", "left"])
+    def test_max_distance_how_left(self, how):
         left = geopandas.GeoDataFrame({"geometry": [Point(0, 0), Point(1, 1)]})
         right = geopandas.GeoDataFrame({"geometry": [Point(1, 1), Point(2, 2)]})
         joined = sjoin_nearest(
             left,
             right,
-            how="left",
+            how=how,
             max_distance=1,
             distance_col="distances",
         )
         expected = left.copy()
         expected["index_right"] = [np.nan, 0]
         expected["distances"] = [np.nan, 0]
+        if how == "inner":
+            expected = expected.dropna()
+            expected["index_right"] = expected["index_right"].astype("int64")
         assert_geodataframe_equal(joined, expected)
 
     def test_max_distance_how_right(self):
@@ -724,6 +730,7 @@ def test_max_distance_how_right(self):
         expected = expected[["index_left", "geometry", "distances"]]
         assert_geodataframe_equal(joined, expected)
 
+    @pytest.mark.parametrize("how", ["inner", "left"])
     @pytest.mark.parametrize(
         "geo_left, geo_right, expected_left, expected_right, distances",
         [
@@ -792,18 +799,21 @@ def test_sjoin_nearest_left(
         expected_left: Sequence[int],
         expected_right: Sequence[int],
         distances: Sequence[float],
+        how,
     ):
         left = geopandas.GeoDataFrame({"geometry": geo_left})
         right = geopandas.GeoDataFrame({"geometry": geo_right})
-        expected_gdf = left.iloc[expected_left]
+        expected_gdf = left.iloc[expected_left].copy()
         expected_gdf["index_right"] = expected_right
         # without distance col
-        joined = sjoin_nearest(left, right, how="left")
-        assert_geodataframe_equal(expected_gdf, joined)
+        joined = sjoin_nearest(left, right, how=how)
+        # inner / left join give a different row order
+        check_like = how == "inner"
+        assert_geodataframe_equal(expected_gdf, joined, check_like=check_like)
         # with distance col
         expected_gdf["distance_col"] = np.array(distances, dtype=float)
-        joined = sjoin_nearest(left, right, how="left", distance_col="distance_col")
-        assert_geodataframe_equal(expected_gdf, joined)
+        joined = sjoin_nearest(left, right, how=how, distance_col="distance_col")
+        assert_geodataframe_equal(expected_gdf, joined, check_like=check_like)
 
     @pytest.mark.parametrize(
         "geo_left, geo_right, expected_left, expected_right, distances",
@@ -870,7 +880,7 @@ def test_sjoin_nearest_right(
     ):
         left = geopandas.GeoDataFrame({"geometry": geo_left})
         right = geopandas.GeoDataFrame({"geometry": geo_right})
-        expected_gdf = right.iloc[expected_right]
+        expected_gdf = right.iloc[expected_right].copy()
         expected_gdf["index_left"] = expected_left
         expected_gdf = expected_gdf[["index_left", "geometry"]]
         # without distance col
@@ -880,3 +890,31 @@ def test_sjoin_nearest_right(
         expected_gdf["distance_col"] = np.array(distances, dtype=float)
         joined = sjoin_nearest(left, right, how="right", distance_col="distance_col")
         assert_geodataframe_equal(expected_gdf, joined)
+
+    @pytest.mark.filterwarnings("ignore:Geometry is in a geographic CRS")
+    def test_sjoin_nearest_inner(self):
+        # check equivalency of left and inner join
+        countries = read_file(geopandas.datasets.get_path("naturalearth_lowres"))
+        cities = read_file(geopandas.datasets.get_path("naturalearth_cities"))
+        countries = countries[["geometry", "name"]].rename(columns={"name": "country"})
+
+        # default: inner and left give the same result
+        result1 = sjoin_nearest(cities, countries, distance_col="dist")
+        assert result1.shape[0] == cities.shape[0]
+        result2 = sjoin_nearest(cities, countries, distance_col="dist", how="inner")
+        assert_geodataframe_equal(result2, result1)
+        result3 = sjoin_nearest(cities, countries, distance_col="dist", how="left")
+        assert_geodataframe_equal(result3, result1, check_like=True)
+
+        # with max_distance: rows that go above are dropped in case of inner
+        result4 = sjoin_nearest(cities, countries, distance_col="dist", max_distance=1)
+        assert_geodataframe_equal(
+            result4, result1[result1["dist"] < 1], check_like=True
+        )
+        result5 = sjoin_nearest(
+            cities, countries, distance_col="dist", max_distance=1, how="left"
+        )
+        assert result5.shape[0] == cities.shape[0]
+        result5 = result5.dropna()
+        result5["index_right"] = result5["index_right"].astype("int64")
+        assert_geodataframe_equal(result5, result4, check_like=True)