Repository: ibis-project/ibis

Issue:
feat: better tracebacks for type comparison errors
I have an expression something like this, but with more comparisons and more operations, generally (so harder to visually parse)
```python
expr = basics.join(ratings, basics.tconst == ratings.tconst).filter(
                    [_.titleType == "movie", _.numVotes > 10, _.averageRating > 3.5]
                    )
```

When I wrote that, I didn't know that `numVotes` was actually a string column, so I get this error:

```python
         64 if not rlz.comparable(left, right):
    ---> 65     raise TypeError(
         66         f'Arguments with datatype {left.output_dtype} and '
         67         f'{right.output_dtype} are not comparable'
         68     )
         69 super().__init__(left=left, right=right)
    
    TypeError: Arguments with datatype string and int8 are not comparable
```

which is definitely valid, but even if I scroll all the way to the top of the traceback, all I'm going to find is that the offending line is the _single line_:

```python
----> 1 expr = basics.join(ratings, basics.tconst == ratings.tconst).filter(
      2     [_.titleType == "movie", _.numVotes > 10, _.averageRating > 3.5]
      3 )
```

So I'm thinking we can add in the names of these expressions to the error message.
I think we can probably iterate a bit on how the information is presented, but I think even without the exact logical operation that led to this error, giving the user a pointer to which expression names are involved should save a bunch of time when working interactively.

```python
         64 if not rlz.comparable(left, right):
    ---> 65     raise TypeError(
         66         f'Arguments "{left.name}" ({left.output_dtype}) and '
         67         f'"{right.name}" ({right.output_dtype}) are not comparable'
         68     )
         69 super().__init__(left=left, right=right)
    
    TypeError: Arguments "numVotes" (string) and "10" (int8) are not comparable
```

Are there cases where the expression would be unnamed?  Is it weird to have the literal `"10"` presented as the argument name?


Discussion:
Expressions always have names now, we generate something like `Add(x, 1)` for example for `x + 1`. Perhaps for literals we can make the error message reflect that by special casing them?
Very in favor of improving these messages.

Resolving patch:
diff --git a/ibis/expr/builders.py b/ibis/expr/builders.py
--- a/ibis/expr/builders.py
+++ b/ibis/expr/builders.py
@@ -66,7 +66,10 @@ def when(self, case_expr, result_expr):
         result_expr = rlz.any(result_expr)
 
         if not rlz.comparable(self.base, case_expr):
-            raise TypeError('Base expression and passed case are not ' 'comparable')
+            raise TypeError(
+                f'Base expression {rlz._arg_type_error_format(self.base)} and '
+                f'case {rlz._arg_type_error_format(case_expr)} are not comparable'
+            )
 
         cases = list(self.cases)
         cases.append(case_expr)
diff --git a/ibis/expr/operations/logical.py b/ibis/expr/operations/logical.py
--- a/ibis/expr/operations/logical.py
+++ b/ibis/expr/operations/logical.py
@@ -63,8 +63,8 @@ def __init__(self, left, right):
         """
         if not rlz.comparable(left, right):
             raise TypeError(
-                f'Arguments with datatype {left.output_dtype} and '
-                f'{right.output_dtype} are not comparable'
+                f'Arguments {rlz._arg_type_error_format(left)} and '
+                f'{rlz._arg_type_error_format(right)} are not comparable'
             )
         super().__init__(left=left, right=right)
 
@@ -116,13 +116,13 @@ class Between(Value):
     def __init__(self, arg, lower_bound, upper_bound):
         if not rlz.comparable(arg, lower_bound):
             raise TypeError(
-                f'Argument with datatype {arg.output_dtype} and lower bound '
-                f'with datatype {lower_bound.output_dtype} are not comparable'
+                f'Arguments {rlz._arg_type_error_format(arg)} and '
+                f'{rlz._arg_type_error_format(lower_bound)} are not comparable'
             )
         if not rlz.comparable(arg, upper_bound):
             raise TypeError(
-                f'Argument with datatype {arg.output_dtype} and upper bound '
-                f'with datatype {upper_bound.output_dtype} are not comparable'
+                f'Arguments {rlz._arg_type_error_format(arg)} and '
+                f'{rlz._arg_type_error_format(upper_bound)} are not comparable'
             )
         super().__init__(arg=arg, lower_bound=lower_bound, upper_bound=upper_bound)
 
diff --git a/ibis/expr/rules.py b/ibis/expr/rules.py
--- a/ibis/expr/rules.py
+++ b/ibis/expr/rules.py
@@ -585,3 +585,12 @@ def window_from(table_ref, win, **kwargs):
         if not isinstance(order_var.output_dtype, dt.Timestamp):
             raise com.IbisInputError(error_msg)
     return win
+
+
+def _arg_type_error_format(op):
+    from ibis.expr.operations.generic import Literal
+
+    if isinstance(op, Literal):
+        return f"Literal({op.value}):{op.output_dtype}"
+    else:
+        return f"{op.name}:{op.output_dtype}"


Test patch:
diff --git a/ibis/tests/expr/test_value_exprs.py b/ibis/tests/expr/test_value_exprs.py
--- a/ibis/tests/expr/test_value_exprs.py
+++ b/ibis/tests/expr/test_value_exprs.py
@@ -1623,3 +1623,29 @@ def test_numpy_ufuncs_dont_cast_columns():
     x = np.int64(1)
     for expr in [t.a + x, x + t.a]:
         assert expr.equals(t.a + ibis.literal(x))
+
+
+@pytest.mark.parametrize(
+    'operation',
+    [
+        operator.lt,
+        operator.gt,
+        operator.ge,
+        operator.le,
+        operator.eq,
+        operator.ne,
+    ],
+)
+def test_logical_comparison_rlz_incompatible_error(table, operation):
+    with pytest.raises(TypeError) as exc_info:
+        operation(table.b, "foo")
+
+    assert "b:int16 and Literal(foo):string" in str(exc_info.value)
+
+
+def test_case_rlz_incompatible_error(table):
+    case, result = "foo", table.a
+    with pytest.raises(TypeError) as exc_info:
+        table.g.case().when(case == result, result).end()
+
+    assert "a:int8 and Literal(foo):string" in str(exc_info.value)