Repository: pandas-dev/pandas
Pull request: BUG: merging an Integer EA rasises
- [ ] closes #23020 
- [ ] 1 test added
- [ ] whatsnew entry


Problem statement:
BUG: merging on an Integer EA raises
```
In [1]: df = pd.DataFrame({'A': pd.Series([1, 2, np.nan], dtype='Int64'), 'B': 1})
```

Merging with an EA included is ok
```
In [2]: pd.merge(df, df, on='B')
Out[2]: 
   A_x  B  A_y
0    1  1    1
1    1  1    2
2    1  1  NaN
3    2  1    1
4    2  1    2
5    2  1  NaN
6  NaN  1    1
7  NaN  1    2
8  NaN  1  NaN

In [3]: pd.merge(df, df, on='B').dtypes
Out[3]: 
A_x    Int64
B      int64
A_y    Int64
dtype: object
```

On the EA column is not Ok
```
In [4]: pd.merge(df, df, on='A').dtypes
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-4-e77608ca3973> in <module>()
----> 1 pd.merge(df, df, on='A').dtypes

~/pandas/pandas/core/reshape/merge.py in merge(left, right, how, on, left_on, right_on, left_index, right_index, sort, suffixes, copy, indicator, validate)
     61                          copy=copy, indicator=indicator,
     62                          validate=validate)
---> 63     return op.get_result()
     64 
     65 

~/pandas/pandas/core/reshape/merge.py in get_result(self)
    562                 self.left, self.right)
    563 
--> 564         join_index, left_indexer, right_indexer = self._get_join_info()
    565 
    566         ldata, rdata = self.left._data, self.right._data

~/pandas/pandas/core/reshape/merge.py in _get_join_info(self)
    771         else:
    772             (left_indexer,
--> 773              right_indexer) = self._get_join_indexers()
    774 
    775             if self.right_index:

~/pandas/pandas/core/reshape/merge.py in _get_join_indexers(self)
    750                                   self.right_join_keys,
    751                                   sort=self.sort,
--> 752                                   how=self.how)
    753 
    754     def _get_join_info(self):

~/pandas/pandas/core/reshape/merge.py in _get_join_indexers(left_keys, right_keys, sort, how, **kwargs)
   1120 
   1121     # get left & right join labels and num. of levels at each location
-> 1122     llab, rlab, shape = map(list, zip(* map(fkeys, left_keys, right_keys)))
   1123 
   1124     # get flat i8 keys from label lists

~/pandas/pandas/core/reshape/merge.py in _factorize_keys(lk, rk, sort)
   1554     elif is_int_or_datetime_dtype(lk) and is_int_or_datetime_dtype(rk):
   1555         klass = libhashtable.Int64Factorizer
-> 1556         lk = ensure_int64(com.values_from_object(lk))
   1557         rk = ensure_int64(com.values_from_object(rk))
   1558     else:   1557         rk = ensure_int64(com.values_from_object(rk))
   1558     else:

~/pandas/pandas/_libs/algos_common_helper.pxi in pandas._libs.algos.ensure_int64()

ValueError: cannot convert float NaN to integer

```

Hints from issue discussion:
this doesn't look that hard to fix actually, need an

``if is_extension_array_dtype`` clause here, then maybe some logic to defer to the interface to prepare for the factorizing

Commit message: Updates

* generic release note
* removed _values_for_factorize changes
* made tests depend on just the first two
* do the factorization first

Patch to review:
diff --git a/doc/source/whatsnew/v0.24.0.rst b/doc/source/whatsnew/v0.24.0.rst
index 0bd695f5a40ea..7a6d7fed6fae8 100644
--- a/doc/source/whatsnew/v0.24.0.rst
+++ b/doc/source/whatsnew/v0.24.0.rst
@@ -1051,6 +1051,7 @@ update the ``ExtensionDtype._metadata`` tuple to match the signature of your
 - :meth:`DataFrame.stack` no longer converts to object dtype for DataFrames where each column has the same extension dtype. The output Series will have the same dtype as the columns (:issue:`23077`).
 - :meth:`Series.unstack` and :meth:`DataFrame.unstack` no longer convert extension arrays to object-dtype ndarrays. Each column in the output ``DataFrame`` will now have the same dtype as the input (:issue:`23077`).
 - Bug when grouping :meth:`Dataframe.groupby()` and aggregating on ``ExtensionArray`` it was not returning the actual ``ExtensionArray`` dtype (:issue:`23227`).
+- Bug in :func:`pandas.merge` when merging on an extension array-backed column (:issue:`23020`).
 - A default repr for :class:`pandas.api.extensions.ExtensionArray` is now provided (:issue:`23601`).
 
 .. _whatsnew_0240.api.incompatibilities:
diff --git a/pandas/core/reshape/merge.py b/pandas/core/reshape/merge.py
index e6e6c1c99b509..f642a5b64fb66 100644
--- a/pandas/core/reshape/merge.py
+++ b/pandas/core/reshape/merge.py
@@ -17,9 +17,10 @@
 from pandas.core.dtypes.common import (
     ensure_float64, ensure_int64, ensure_object, is_array_like, is_bool,
     is_bool_dtype, is_categorical_dtype, is_datetime64_dtype,
-    is_datetime64tz_dtype, is_datetimelike, is_dtype_equal, is_float_dtype,
-    is_int64_dtype, is_integer, is_integer_dtype, is_list_like, is_number,
-    is_numeric_dtype, needs_i8_conversion)
+    is_datetime64tz_dtype, is_datetimelike, is_dtype_equal,
+    is_extension_array_dtype, is_float_dtype, is_int64_dtype, is_integer,
+    is_integer_dtype, is_list_like, is_number, is_numeric_dtype,
+    needs_i8_conversion)
 from pandas.core.dtypes.missing import isnull, na_value_for_dtype
 
 from pandas import Categorical, DataFrame, Index, MultiIndex, Series, Timedelta
@@ -1593,6 +1594,15 @@ def _factorize_keys(lk, rk, sort=True):
         lk = lk.values
         rk = rk.values
 
+    elif (is_extension_array_dtype(lk.dtype) and
+          is_extension_array_dtype(rk.dtype) and
+          lk.dtype == rk.dtype and
+          not is_categorical_dtype(lk)):
+        # Categorical handled separately, since unordered categories can
+        # may need to be recoded.
+        lk, _ = lk._values_for_factorize()
+        rk, _ = rk._values_for_factorize()
+
     # if we exactly match in categories, allow us to factorize on codes
     if (is_categorical_dtype(lk) and
             is_categorical_dtype(rk) and
diff --git a/pandas/tests/extension/base/reshaping.py b/pandas/tests/extension/base/reshaping.py
index 42e481d974295..ee22ffb3ccf97 100644
--- a/pandas/tests/extension/base/reshaping.py
+++ b/pandas/tests/extension/base/reshaping.py
@@ -173,6 +173,38 @@ def test_merge(self, data, na_value):
                  dtype=data.dtype)})
         self.assert_frame_equal(res, exp[['ext', 'int1', 'key', 'int2']])
 
+    def test_merge_on_extension_array(self, data):
+        # GH 23020
+        a, b = data[:2]
+        key = type(data)._from_sequence([a, b], dtype=data.dtype)
+
+        df = pd.DataFrame({"key": key, "val": [1, 2]})
+        result = pd.merge(df, df, on='key')
+        expected = pd.DataFrame({"key": key,
+                                 "val_x": [1, 2],
+                                 "val_y": [1, 2]})
+        self.assert_frame_equal(result, expected)
+
+        # order
+        result = pd.merge(df.iloc[[1, 0]], df, on='key')
+        expected = expected.iloc[[1, 0]].reset_index(drop=True)
+        self.assert_frame_equal(result, expected)
+
+    def test_merge_on_extension_array_duplicates(self, data):
+        # GH 23020
+        a, b = data[:2]
+        key = type(data)._from_sequence([a, b, a], dtype=data.dtype)
+        df1 = pd.DataFrame({"key": key, "val": [1, 2, 3]})
+        df2 = pd.DataFrame({"key": key, "val": [1, 2, 3]})
+
+        result = pd.merge(df1, df2, on='key')
+        expected = pd.DataFrame({
+            "key": key.take([0, 0, 0, 0, 1]),
+            "val_x": [1, 1, 3, 3, 2],
+            "val_y": [1, 3, 1, 3, 2],
+        })
+        self.assert_frame_equal(result, expected)
+
     @pytest.mark.parametrize("columns", [
         ["A", "B"],
         pd.MultiIndex.from_tuples([('A', 'a'), ('A', 'b')],
diff --git a/pandas/tests/reshape/merge/test_merge.py b/pandas/tests/reshape/merge/test_merge.py
index 94e180f9328d6..751b76c1e20ad 100644
--- a/pandas/tests/reshape/merge/test_merge.py
+++ b/pandas/tests/reshape/merge/test_merge.py
@@ -1326,6 +1326,16 @@ def test_merging_with_bool_or_int_cateorical_column(self, category_column,
             CDT(categories, ordered=ordered))
         assert_frame_equal(expected, result)
 
+    def test_merge_on_int_array(self):
+        # GH 23020
+        df = pd.DataFrame({'A': pd.Series([1, 2, np.nan], dtype='Int64'),
+                           'B': 1})
+        result = pd.merge(df, df, on='A')
+        expected = pd.DataFrame({'A': pd.Series([1, 2, np.nan], dtype='Int64'),
+                                 'B_x': 1,
+                                 'B_y': 1})
+        assert_frame_equal(result, expected, check_dtype=True)
+
 
 @pytest.fixture
 def left_df():


Code review comments:
diff_hunk: @@ -1593,6 +1594,15 @@ def _factorize_keys(lk, rk, sort=True):
         lk = lk.values
         rk = rk.values
 
+    elif (is_extension_array_dtype(lk.dtype) and
path: pandas/core/reshape/merge.py
text: @user1:
maybe should do this after the categorical check?

@user2:
The categorical check is already in the main `if` block.

I'm going to move that up to the same if / elif as the datetimetz, and the new extension dtype check.