Repository: ibis-project/ibis

Issue:
bug(pandas): pandas backend does not support series == datetime.date() operations
Pandas backend throws a `NotImplementedError` when checking equality of a column/`pd.Series` against a `datetime.date` object. Works with `datetime.datetime`, and works with BigQuery backend.

MWE:
```
import ibis
import datetime as dt
import pandas as pd

test_df = pd.DataFrame({
            'id': ['1', '2', '3', '4'],
            'date_column': [
                dt.date(2021, 1, 1), dt.date(2021, 1, 1), 
                dt.date(2021, 4, 1), dt.date(2021, 4, 1)]
        })

conn = ibis.pandas.connect({'sample_df': test_df})
table = conn.table('sample_df')

(table['date_column'] == dt.date(2021, 1, 1)).execute()
```


Discussion:
Thanks for the report and the MWE! Slated for 3.2.0.
xref #4290 

Resolving patch:
diff --git a/ibis/backends/base/sql/registry/main.py b/ibis/backends/base/sql/registry/main.py
--- a/ibis/backends/base/sql/registry/main.py
+++ b/ibis/backends/base/sql/registry/main.py
@@ -227,6 +227,11 @@ def hash(translator, expr):
         raise NotImplementedError(how)
 
 
+def concat(translator, expr):
+    joined_args = ', '.join(map(translator.translate, expr.op().arg))
+    return f"concat({joined_args})"
+
+
 binary_infix_ops = {
     # Binary operations
     ops.Add: binary_infix.binary_infix_op('+'),
@@ -304,7 +309,7 @@ def hash(translator, expr):
     ops.Count: aggregate.reduction('count'),
     ops.CountDistinct: aggregate.count_distinct,
     # string operations
-    ops.StringConcat: fixed_arity('concat', 2),
+    ops.StringConcat: concat,
     ops.StringLength: unary('length'),
     ops.StringAscii: unary('ascii'),
     ops.Lowercase: unary('lower'),
diff --git a/ibis/backends/dask/execution/generic.py b/ibis/backends/dask/execution/generic.py
--- a/ibis/backends/dask/execution/generic.py
+++ b/ibis/backends/dask/execution/generic.py
@@ -27,6 +27,7 @@
     register_types_to_dispatcher,
 )
 from ibis.backends.pandas.core import (
+    date_types,
     integer_types,
     numeric_types,
     simple_types,
@@ -34,6 +35,7 @@
 )
 from ibis.backends.pandas.execution import constants
 from ibis.backends.pandas.execution.generic import (
+    _execute_binary_op_impl,
     execute_between,
     execute_cast_series_array,
     execute_cast_series_generic,
@@ -314,15 +316,14 @@ def execute_not_scalar_or_series(op, data, **kwargs):
 @execute_node.register(ops.Comparison, dd.Series, timestamp_types)
 @execute_node.register(ops.Comparison, timestamp_types, dd.Series)
 def execute_binary_op(op, left, right, **kwargs):
-    op_type = type(op)
-    try:
-        operation = constants.BINARY_OPERATIONS[op_type]
-    except KeyError:
-        raise NotImplementedError(
-            f'Binary operation {op_type.__name__} not implemented'
-        )
-    else:
-        return operation(left, right)
+    return _execute_binary_op_impl(op, left, right, **kwargs)
+
+
+@execute_node.register(ops.Comparison, dd.Series, date_types)
+def execute_binary_op_date_right(op, left, right, **kwargs):
+    return _execute_binary_op_impl(
+        op, dd.to_datetime(left), pd.to_datetime(right), **kwargs
+    )
 
 
 @execute_node.register(ops.Binary, ddgb.SeriesGroupBy, ddgb.SeriesGroupBy)
diff --git a/ibis/backends/pandas/execution/generic.py b/ibis/backends/pandas/execution/generic.py
--- a/ibis/backends/pandas/execution/generic.py
+++ b/ibis/backends/pandas/execution/generic.py
@@ -25,6 +25,7 @@
 from ibis.backends.pandas.client import PandasTable
 from ibis.backends.pandas.core import (
     boolean_types,
+    date_types,
     execute,
     fixed_width_types,
     floating_types,
@@ -762,6 +763,18 @@ def execute_not_bool(_, data, **kwargs):
     return not data
 
 
+def _execute_binary_op_impl(op, left, right, **_):
+    op_type = type(op)
+    try:
+        operation = constants.BINARY_OPERATIONS[op_type]
+    except KeyError:
+        raise NotImplementedError(
+            f'Binary operation {op_type.__name__} not implemented'
+        )
+    else:
+        return operation(left, right)
+
+
 @execute_node.register(ops.Binary, pd.Series, pd.Series)
 @execute_node.register(
     (ops.NumericBinary, ops.LogicalBinary, ops.Comparison),
@@ -784,17 +797,16 @@ def execute_not_bool(_, data, **kwargs):
 @execute_node.register(ops.Multiply, integer_types, str)
 @execute_node.register(ops.Multiply, str, integer_types)
 @execute_node.register(ops.Comparison, pd.Series, timestamp_types)
-@execute_node.register(ops.Comparison, timestamp_types, pd.Series)
+@execute_node.register(ops.Comparison, timedelta_types, pd.Series)
 def execute_binary_op(op, left, right, **kwargs):
-    op_type = type(op)
-    try:
-        operation = constants.BINARY_OPERATIONS[op_type]
-    except KeyError:
-        raise NotImplementedError(
-            f'Binary operation {op_type.__name__} not implemented'
-        )
-    else:
-        return operation(left, right)
+    return _execute_binary_op_impl(op, left, right, **kwargs)
+
+
+@execute_node.register(ops.Comparison, pd.Series, date_types)
+def execute_binary_op_date(op, left, right, **kwargs):
+    return _execute_binary_op_impl(
+        op, pd.to_datetime(left), pd.to_datetime(right), **kwargs
+    )
 
 
 @execute_node.register(ops.Binary, SeriesGroupBy, SeriesGroupBy)


Test patch:
diff --git a/ibis/backends/tests/test_aggregation.py b/ibis/backends/tests/test_aggregation.py
--- a/ibis/backends/tests/test_aggregation.py
+++ b/ibis/backends/tests/test_aggregation.py
@@ -495,7 +495,7 @@ def test_approx_median(alltypes):
             L(":") + ":",
             "::",
             id="expr",
-            marks=mark.notyet(["duckdb", "impala", "mysql", "pyspark"]),
+            marks=mark.notyet(["duckdb", "mysql", "pyspark"]),
         ),
     ],
 )
diff --git a/ibis/backends/tests/test_string.py b/ibis/backends/tests/test_string.py
--- a/ibis/backends/tests/test_string.py
+++ b/ibis/backends/tests/test_string.py
@@ -264,19 +264,19 @@ def test_string_col_is_unicode(alltypes, df):
             lambda t: t.string_col + t.date_string_col,
             lambda t: t.string_col + t.date_string_col,
             id='concat_columns',
-            marks=pytest.mark.notimpl(["datafusion", "impala"]),
+            marks=pytest.mark.notimpl(["datafusion"]),
         ),
         param(
             lambda t: t.string_col + 'a',
             lambda t: t.string_col + 'a',
             id='concat_column_scalar',
-            marks=pytest.mark.notimpl(["datafusion", "impala"]),
+            marks=pytest.mark.notimpl(["datafusion"]),
         ),
         param(
             lambda t: 'a' + t.string_col,
             lambda t: 'a' + t.string_col,
             id='concat_scalar_column',
-            marks=pytest.mark.notimpl(["datafusion", "impala"]),
+            marks=pytest.mark.notimpl(["datafusion"]),
         ),
     ],
 )
diff --git a/ibis/backends/tests/test_temporal.py b/ibis/backends/tests/test_temporal.py
--- a/ibis/backends/tests/test_temporal.py
+++ b/ibis/backends/tests/test_temporal.py
@@ -870,3 +870,48 @@ def test_big_timestamp(con):
     result = con.execute(value)
     expected = datetime.datetime(2419, 10, 11, 10, 10, 25)
     assert result == expected
+
+
+DATE = datetime.date(2010, 11, 1)
+
+
+def build_date_col(t):
+    return (
+        t.year.cast("string")
+        + "-"
+        + t.month.cast("string").lpad(2, "0")
+        + "-"
+        + (t.int_col + 1).cast("string").lpad(2, "0")
+    ).cast("date")
+
+
+@pytest.mark.notimpl(["datafusion"])
+@pytest.mark.notyet(["impala"], reason="impala doesn't support dates")
+@pytest.mark.parametrize(
+    ("left_fn", "right_fn"),
+    [
+        param(build_date_col, lambda _: DATE, id="column_date"),
+        param(lambda _: DATE, build_date_col, id="date_column"),
+    ],
+)
+def test_timestamp_date_comparison(backend, alltypes, df, left_fn, right_fn):
+    left = left_fn(alltypes)
+    right = right_fn(alltypes)
+    expr = left == right
+    result = expr.execute().rename("result")
+    expected = (
+        pd.to_datetime(
+            (
+                df.year.astype(str)
+                .add("-")
+                .add(df.month.astype(str).str.rjust(2, "0"))
+                .add("-")
+                .add(df.int_col.add(1).astype(str).str.rjust(2, "0"))
+            ),
+            format="%Y-%m-%d",
+            exact=True,
+        )
+        .eq(pd.Timestamp(DATE))
+        .rename("result")
+    )
+    backend.assert_series_equal(result, expected)