Repository: ibis-project/ibis

Issue:
feat(pandas): support `substr` with `length=None`
The `substr` function provides a default value for the `length` parameter (`None`), and [the docs](https://ibis-project.org/docs/3.0.2/api/expressions/strings/#ibis.expr.types.strings.StringValue.substr) say `length` is optional

However the Pandas backend doesn't have an implementation for `substr` without `length`

```
import pandas as pd

import ibis

backend = ibis.backends.pandas.Backend()
conn = backend.connect({})

table = conn.from_dataframe(pd.DataFrame({
    'key': [1, 2, 3],
    'value': ['foo', 'bar', 'baz'],
}), 't1')

table = table.mutate(
    value2=table['value'].substr(2)
)

table.execute()
```

```
NotImplementedError: Could not find signature for execute_node: <Substring, Series, int, NoneType>
```

I was only able to test this on `3.0.0`, but I did a quick look at [the latest Pandas backend code for `Substring`](https://github.com/ibis-project/ibis/blob/993400c35d8f87d1c0a7c22f1355e6dde885f1cf/ibis/backends/pandas/execution/strings.py#L23-L55) and I think the behavior is the same

Discussion:
github-actions[bot]: ## Test Results
       39 files         39 suites   1h 20m 59s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests")
10 769 tests   8 364 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 2 402 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests")   3 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")
39 536 runs  30 244 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 9 282 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 10 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

For more details on these failures, see [this check](https://github.com/ibis-project/ibis/runs/8964769375).

Results for commit 79642d9a.

[test-results]:data:application/gzip;base64,H4sIAO7YTmMC/1XNTQ6DIBCG4as0rLsYhhGhl2kUJCH1p0FYNb17x6iYLr9nknc+IsRxWMXjpuz9JtYS87V8SV2Oy8ybTLMJH/N2ltDqup9rcY7RKE2XveKbDQmwWujiuLUrDCktiQVYUpn3x43SxzzDCpCo2h62aPCkoyvPzF/XLdMUMw/RWk3obddK6G0A3w8OrKFgEZm9RS/JIYL4/gC6CveuFgEAAA==


Patch:
diff --git a/ibis/backends/dask/execution/strings.py b/ibis/backends/dask/execution/strings.py
index 0b471eac0..dbd187391 100644
--- a/ibis/backends/dask/execution/strings.py
+++ b/ibis/backends/dask/execution/strings.py
@@ -54,7 +54,7 @@ DASK_DISPATCH_TYPES: TypeRegistrationDict = {
             (
                 dd.Series,
                 integer_types,
-                integer_types,
+                (type(None), *integer_types),
             ),
             execute_substring_int_int,
         ),
diff --git a/ibis/backends/pandas/execution/strings.py b/ibis/backends/pandas/execution/strings.py
index f507b001e..dd8b37daa 100644
--- a/ibis/backends/pandas/execution/strings.py
+++ b/ibis/backends/pandas/execution/strings.py
@@ -21,9 +21,14 @@ def execute_string_length_series(op, data, **kwargs):
     return data.str.len().astype('int32')
 
 
-@execute_node.register(ops.Substring, pd.Series, integer_types, integer_types)
+@execute_node.register(
+    ops.Substring, pd.Series, integer_types, (type(None), *integer_types)
+)
 def execute_substring_int_int(op, data, start, length, **kwargs):
-    return data.str[start : start + length]
+    if length is None:
+        return data.str[start:]
+    else:
+        return data.str[start : start + length]
 
 
 @execute_node.register(ops.Substring, pd.Series, pd.Series, integer_types)
diff --git a/ibis/expr/builders.py b/ibis/expr/builders.py
index 91fe8f996..c42eb95bc 100644
--- a/ibis/expr/builders.py
+++ b/ibis/expr/builders.py
@@ -66,7 +66,10 @@ class SimpleCaseBuilder(TypedCaseBuilder):
         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
index e5c56d615..265577ee0 100644
--- a/ibis/expr/operations/logical.py
+++ b/ibis/expr/operations/logical.py
@@ -63,8 +63,8 @@ class Comparison(Binary):
         """
         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
index d234e4da0..c2dd83741 100644
--- 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/backends/tests/test_string.py b/ibis/backends/tests/test_string.py
index 668d08cee..948c39ff6 100644
--- a/ibis/backends/tests/test_string.py
+++ b/ibis/backends/tests/test_string.py
@@ -204,6 +204,12 @@ def test_string_col_is_unicode(alltypes, df):
             lambda t: t.date_string_col.str[2:5],
             id='substr',
         ),
+        param(
+            lambda t: t.date_string_col.substr(2),
+            lambda t: t.date_string_col.str[2:],
+            id='substr-start-only',
+            marks=pytest.mark.notimpl(["datafusion", "polars"]),
+        ),
         param(
             lambda t: t.date_string_col.left(2),
             lambda t: t.date_string_col.str[:2],
diff --git a/ibis/tests/expr/test_value_exprs.py b/ibis/tests/expr/test_value_exprs.py
index 6cd36fb1a..d798a62e7 100644
--- 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)