Repository: geopandas/geopandas

Issue:
BUG: sjoin fails for empty dataframe
```
import geopandas as gpd

gdf = gpd.read_file(gpd.datasets.get_path('nybb'))
gpd.sjoin(gdf, gdf.head(0))

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-23-a647daaec265> in <module>
----> 1 gpd.sjoin(gdf.head(0), gdf)

~/model/.venv/lib/python3.7/site-packages/geopandas/tools/sjoin.py in sjoin(left_df, right_df, how, op, lsuffix, rsuffix)
    126             lambda x: list(tree_idx.intersection(x)) if not x == () else []
    127         )
--> 128         idxmatch = idxmatch[idxmatch.apply(len) > 0]
    129         # indexes of overlapping boundaries
    130         if idxmatch.shape[0] > 0:

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/common.py in new_method(self, other)
     62         other = item_from_zerodim(other)
     63
---> 64         return method(self, other)
     65
     66     return new_method

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/__init__.py in wrapper(self, other)
    524         rvalues = extract_array(other, extract_numpy=True)
    525
--> 526         res_values = comparison_op(lvalues, rvalues, op)
    527
    528         return _construct_result(self, res_values, index=self.index, name=res_name)

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/array_ops.py in comparison_op(left, right, op)
    235
    236     if should_extension_dispatch(lvalues, rvalues):
--> 237         res_values = dispatch_to_extension_op(op, lvalues, rvalues)
    238
    239     elif is_scalar(rvalues) and isna(rvalues):

~/model/.venv/lib/python3.7/site-packages/pandas/core/ops/dispatch.py in dispatch_to_extension_op(op, left, right)
    123     # The op calls will raise TypeError if the op is not defined
    124     # on the ExtensionArray
--> 125     res_values = op(left, right)
    126     return res_values

TypeError: '>' not supported between instances of 'GeometryArray' and 'int'
```
Looks like the existing checks handle an empty geometry but not a totally empty row; would just wrapping this line in `if not idxmatch.empty:` be sufficient...?
Spatial join fails if column has all empty geometries
I ran into this edge case with `geopandas.sjoin`. If the left GeoDataFrame in a join has all empty geometries, then a spatial index is not generated, leading to an `AttributeError` further in the function.

Here is a minimal example:

```python
import geopandas
import shapely

df1 = geopandas.GeoDataFrame(geometry=[shapely.geometry.Point(0,0)])
df2 = geopandas.GeoDataFrame(geometry=[shapely.geometry.GeometryCollection()])

# geopandas.sjoin(df1, df2) # works
geopandas.sjoin(df2, df1) # fails
```

Which produces:

```pytb
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-17-e670cb379738> in <module>
      7 # geopandas.sjoin(df1, df2) # works
      8 # df1.sindex
----> 9 geopandas.sjoin(df2, df1) # fails

/opt/conda/lib/python3.7/site-packages/geopandas/tools/sjoin.py in sjoin(left_df, right_df, how, op, lsuffix, rsuffix)
    117         # tree_idx_df == 'left'
    118         idxmatch = right_df.geometry.apply(lambda x: x.bounds).apply(
--> 119             lambda x: list(tree_idx.intersection(x)) if not x == () else []
    120         )
    121         idxmatch = idxmatch[idxmatch.apply(len) > 0]

/opt/conda/lib/python3.7/site-packages/pandas/core/series.py in apply(self, func, convert_dtype, args, **kwds)
   4043             else:
   4044                 values = self.astype(object).values
-> 4045                 mapped = lib.map_infer(values, f, convert=convert_dtype)
   4046 
   4047         if len(mapped) and isinstance(mapped[0], Series):

pandas/_libs/lib.pyx in pandas._libs.lib.map_infer()

/opt/conda/lib/python3.7/site-packages/geopandas/tools/sjoin.py in <lambda>(x)
    117         # tree_idx_df == 'left'
    118         idxmatch = right_df.geometry.apply(lambda x: x.bounds).apply(
--> 119             lambda x: list(tree_idx.intersection(x)) if not x == () else []
    120         )
    121         idxmatch = idxmatch[idxmatch.apply(len) > 0]

AttributeError: 'NoneType' object has no attribute 'intersection'
```

A workaround is to manually trigger index creation in the other GeoDataFrame:
```python
import geopandas
import shapely

df1 = geopandas.GeoDataFrame(geometry=[shapely.geometry.Point(0,0)])
df2 = geopandas.GeoDataFrame(geometry=[shapely.geometry.GeometryCollection()])

df1.sindex
geopandas.sjoin(df2, df1) # works now
```

I am running this with geopandas 0.7.0. Similar to, but distinct from #251, #944, as far as I can tell.


Discussion:
We can check for empty gdf in the beginning before generating spatial index. You may end up generating sindex for one gdf just to find out later that the other one is empty.
Thanks for the report. It is not related to those linked issues, but it is an unexpected corner case. It would require one check after which we should either raise an error that there is nothing to join to (no valid geometry) or return original gdf. What would you expect to happen in this case?
My expectation is that a spatial join on a column full of empty geometries would produce no matches on the predicate op. So rather than erroring, or returning the original gdf, it would return an empty gdf.

My reasoning is that this would be less surprising in the case that a gdf has a few non-empty rows. If it is mostly-empty, the spatial join will join on the few non-empty ones. If, then, those non-empty rows are dropped, I would expect the resulting join would drop correspondingly.

To make that more concrete, consider the following:
```python
import geopandas
import shapely

df1 = geopandas.GeoDataFrame(geometry=[shapely.geometry.Point(0,0)])
df2 = geopandas.GeoDataFrame(geometry=[
    shapely.geometry.Point(0,0),
    shapely.geometry.Point(0,0),
    shapely.geometry.GeometryCollection(),
])
```

Currently, the following join
```python
geopandas.sjoin(df2, df1)
```
produces
<table border="1" class="dataframe">  <thead>    <tr style="text-align: right;">      <th></th>      <th>geometry</th>      <th>index_right</th>    </tr>  </thead>  <tbody>    <tr>      <th>0</th>      <td>POINT (0.00000 0.00000)</td>      <td>0</td>    </tr>    <tr>      <th>1</th>      <td>POINT (0.00000 0.00000)</td>      <td>0</td>    </tr>  </tbody></table>

If I drop the first row
```python
geopandas.sjoin(df2.iloc[1:], df1)
```
it produces:
<table border="1" class="dataframe">  <thead>    <tr style="text-align: right;">      <th></th>      <th>geometry</th>      <th>index_right</th>    </tr>  </thead>  <tbody>    <tr>      <th>1</th>      <td>POINT (0.00000 0.00000)</td>      <td>0</td>    </tr>  </tbody></table>


If I drop all but the last row 
```python
geopandas.sjoin(df2.iloc[2:], df1)
```
it errors, but for consistency with the above, I would expect:

<table border="1" class="dataframe">  <thead>    <tr style="text-align: right;">      <th></th>      <th>geometry</th>      <th>index_right</th>    </tr>  </thead>  <tbody>     </tbody></table>


That makes sense. Just keep in mind that it would be different for `how=left`. Would you like to make a PR fixing this?
Sure, I would be happy to make a PR. I suspect that a fix may also run into #1307, anything I should keep in mind?
Fixing both would be great, thanks! I can't think of anything else than making sure each of the `how` options is taken care of at the moment.



Resolving patch:
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -75,14 +75,15 @@ def sjoin(
         )
 
     # Attempt to re-use spatial indexes, otherwise generate the spatial index
-    # for the longer dataframe
+    # for the longer dataframe. If we are joining to an empty dataframe,
+    # don't bother generating the index.
     if right_df._sindex_generated or (
         not left_df._sindex_generated and right_df.shape[0] > left_df.shape[0]
     ):
-        tree_idx = right_df.sindex
+        tree_idx = right_df.sindex if len(left_df) > 0 else None
         tree_idx_right = True
     else:
-        tree_idx = left_df.sindex
+        tree_idx = left_df.sindex if len(right_df) > 0 else None
         tree_idx_right = False
 
     # the rtree spatial index only allows limited (numeric) index types, but an
@@ -120,8 +121,10 @@ def sjoin(
 
     r_idx = np.empty((0, 0))
     l_idx = np.empty((0, 0))
-    # get rtree spatial index
-    if tree_idx_right:
+    # get rtree spatial index. If tree_idx does not exist, it is due to either a
+    # failure to generate the index (e.g., if the column is empty), or the
+    # other dataframe is empty so it wasn't necessary to generate it.
+    if tree_idx_right and tree_idx:
         idxmatch = left_df.geometry.apply(lambda x: x.bounds).apply(
             lambda x: list(tree_idx.intersection(x)) if not x == () else []
         )
@@ -130,7 +133,7 @@ def sjoin(
         if idxmatch.shape[0] > 0:
             r_idx = np.concatenate(idxmatch.values)
             l_idx = np.concatenate([[i] * len(v) for i, v in idxmatch.iteritems()])
-    else:
+    elif not tree_idx_right and tree_idx:
         # tree_idx_df == 'left'
         idxmatch = right_df.geometry.apply(lambda x: x.bounds).apply(
             lambda x: list(tree_idx.intersection(x)) if not x == () else []


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
@@ -186,7 +186,7 @@ def test_left(self, op, dfs):
         assert_frame_equal(res, exp)
 
     def test_empty_join(self):
-        # Check empty joins
+        # Check joins resulting in empty gdfs.
         polygons = geopandas.GeoDataFrame(
             {
                 "col2": [1, 2],
@@ -204,6 +204,32 @@ def test_empty_join(self):
         empty = sjoin(not_in, polygons, how="inner", op="intersects")
         assert empty.empty
 
+    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
+    @pytest.mark.parametrize(
+        "empty",
+        [
+            GeoDataFrame(geometry=[GeometryCollection(), GeometryCollection()]),
+            GeoDataFrame(geometry=GeoSeries()),
+        ],
+    )
+    def test_join_with_empty(self, op, empty):
+        # Check joins with empty geometry columns/dataframes.
+        polygons = geopandas.GeoDataFrame(
+            {
+                "col2": [1, 2],
+                "geometry": [
+                    Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
+                    Polygon([(1, 0), (2, 0), (2, 1), (1, 1)]),
+                ],
+            }
+        )
+        result = sjoin(empty, polygons, how="left", op=op)
+        assert result.index_right.isnull().all()
+        result = sjoin(empty, polygons, how="right", op=op)
+        assert result.index_left.isnull().all()
+        result = sjoin(empty, polygons, how="inner", op=op)
+        assert result.empty
+
     @pytest.mark.parametrize("dfs", ["default-index", "string-index"], indirect=True)
     def test_sjoin_invalid_args(self, dfs):
         index, df1, df2, expected = dfs