Repository: ibis-project/ibis

Issue:
bug: The translate method of strings is not working as expected for pandas
### What happened?

The following code raises a TypeError exception:
```python

import pandas as pd

import ibis

ibis.options.interactive = True
ibis.set_backend("pandas")

df = pd.DataFrame({
    'fruit': ['apple', 'banana', 'orange', 'kiwi', 'mango'],
    'to_column': ['red', 'ted', 'pet', 'bed', 'sed']
})

table = ibis.memtable(df)

result = table.fruit.translate('abc', table.to_column).execute()
print(result)
```

Note that using a column for `Translate` is not [documented](https://ibis-project.org/api/expressions/strings/?h=translate#ibis.expr.types.strings.StringValue.translate) nor implemented for DuckDB, but it is implemented in [here](https://github.com/ibis-project/ibis/blob/master/ibis/backends/pandas/execution/strings.py#L345) for pandas. 

What would be the solution? Delete these methods or fix them? 

### What version of ibis are you using?

dev

### What backend(s) are you using, if any?

pandas

### Relevant log output

```sh
Traceback (most recent call last):
  File "/Users/daniel.mesejo/PycharmProjects/ibis/try_substr.py", line 15, in <module>
    result = table.fruit.translate('abc', table.to_column).execute()
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/expr/types/core.py", line 303, in execute
    return self._find_backend(use_default=True).execute(
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/__init__.py", line 299, in execute
    return execute_and_reset(node, params=params, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/core.py", line 482, in execute_and_reset
    result = execute(
  File "/Users/daniel.mesejo/opt/anaconda3/envs/ibis-dev/lib/python3.10/site-packages/multipledispatch/dispatcher.py", line 278, in __call__
    return func(*args, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/trace.py", line 136, in traced_func
    return func(*args, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/core.py", line 428, in main_execute
    return execute_with_scope(
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/core.py", line 218, in execute_with_scope
    result = execute_until_in_scope(
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/trace.py", line 136, in traced_func
    return func(*args, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/core.py", line 350, in execute_until_in_scope
    result = execute_node(
  File "/Users/daniel.mesejo/opt/anaconda3/envs/ibis-dev/lib/python3.10/site-packages/multipledispatch/dispatcher.py", line 278, in __call__
    return func(*args, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/trace.py", line 136, in traced_func
    return func(*args, **kwargs)
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/execution/strings.py", line 336, in execute_series_translate_scalar_series
    table = to_string.map(lambda y, x=from_string: str.maketrans(x=x, y=y))
  File "/Users/daniel.mesejo/opt/anaconda3/envs/ibis-dev/lib/python3.10/site-packages/pandas/core/series.py", line 4393, in map
    new_values = self._map_values(arg, na_action=na_action)
  File "/Users/daniel.mesejo/opt/anaconda3/envs/ibis-dev/lib/python3.10/site-packages/pandas/core/base.py", line 924, in _map_values
    new_values = map_f(values, mapper)
  File "pandas/_libs/lib.pyx", line 2834, in pandas._libs.lib.map_infer
  File "/Users/daniel.mesejo/PycharmProjects/ibis/ibis/backends/pandas/execution/strings.py", line 336, in <lambda>
    table = to_string.map(lambda y, x=from_string: str.maketrans(x=x, y=y))
TypeError: str.maketrans() takes no keyword arguments
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@mesejo Thanks for the issue!

I think we should try to avoid deleting methods without a strong justification or equivalent functionality.

Is the fix for the pandas backend as simple as removing the caller's explicit keyword arguments?
Removing the caller's explicit keyword arguments makes the function run, but I don't know if the output makes sense. For the original code, it produces:

```
0     apple
1    banana
2    orange
3      kiwi
4     mango
Name: Translate(fruit, 'abc', to_column), dtype: object
```
it also passes the `pd.Series`:

```
table = to_string.map(lambda y, x=from_string: str.maketrans(x, y))
```

to [Series.str.translate](https://pandas.pydata.org/docs/reference/api/pandas.Series.str.translate.html), the expected input is a dictionary made by [str.make_trans](https://docs.python.org/3/library/stdtypes.html#str.maketrans).

Just as a side note, translate with two strings as input works:

```python
result = table.fruit.translate('abc', 'efg').execute()
print(result)
```
**Output**
```
0     epple
1    fenene
2    orenge
3      kiwi
4     mengo
Name: Translate(fruit, 'abc', 'efg'), dtype: object
```

Resolving patch:
diff --git a/ibis/backends/pandas/execution/strings.py b/ibis/backends/pandas/execution/strings.py
--- a/ibis/backends/pandas/execution/strings.py
+++ b/ibis/backends/pandas/execution/strings.py
@@ -55,16 +55,17 @@ def execute_string_substring_int_series(op, data, start, length, **kwargs):
 def execute_substring_series_series(op, data, start, length, **kwargs):
     end = start + length
 
-    def iterate(value, start_iter=start.values.flat, end_iter=end.values.flat):
-        begin = next(start_iter)
-        end = next(end_iter)
-        if (begin is not None and pd.isnull(begin)) or (
-            end is not None and pd.isnull(end)
-        ):
-            return None
-        return value[begin:end]
-
-    return data.map(iterate)
+    return pd.Series(
+        [
+            None
+            if (begin is not None and pd.isnull(begin))
+            or (stop is not None and pd.isnull(stop))
+            else value[begin:stop]
+            for value, begin, stop in zip(data, start.values, end.values)
+        ],
+        dtype=data.dtype,
+        name=data.name,
+    )
 
 
 @execute_node.register(ops.Strip, pd.Series)
@@ -111,16 +112,6 @@ def execute_string_upper(op, data, **kwargs):
     return data.str.upper()
 
 
-@execute_node.register(ops.StartsWith, pd.Series)
-def execute_startswith(op, data, start, **kwargs):
-    return data.str.startswith(start)
-
-
-@execute_node.register(ops.EndsWith, pd.Series)
-def execute_endswith(op, data, end, **kwargs):
-    return data.str.endswith(end)
-
-
 @execute_node.register(ops.Capitalize, (pd.Series, str))
 def execute_string_capitalize(op, data, **kwargs):
     return getattr(data, "str", data).capitalize()
@@ -211,8 +202,8 @@ def sql_like_to_regex(pattern: str, escape: str | None = None) -> str:
 
 @execute_node.register(ops.StringSQLLike, pd.Series, str, (str, type(None)))
 def execute_string_like_series_string(op, data, pattern, escape, **kwargs):
-    new_pattern = re.compile(sql_like_to_regex(pattern, escape=escape))
-    return data.map(lambda x, pattern=new_pattern: pattern.search(x) is not None)
+    new_pattern = sql_like_to_regex(pattern, escape=escape)
+    return data.str.contains(new_pattern, regex=True)
 
 
 @execute_node.register(ops.StringSQLLike, SeriesGroupBy, str, str)
@@ -275,12 +266,12 @@ def execute_series_regex_search_gb(op, data, pattern, **kwargs):
 
 @execute_node.register(ops.StartsWith, pd.Series, str)
 def execute_series_starts_with(op, data, pattern, **kwargs):
-    return data.map(lambda x, p=pattern: x.startswith(p))
+    return data.str.startswith(pattern)
 
 
 @execute_node.register(ops.EndsWith, pd.Series, str)
 def execute_series_ends_with(op, data, pattern, **kwargs):
-    return data.map(lambda x, p=pattern: x.endswith(p))
+    return data.str.endswith(pattern)
 
 
 @execute_node.register(ops.RegexExtract, pd.Series, str, integer_types)
@@ -328,23 +319,34 @@ def execute_series_regex_replace_gb(op, data, pattern, replacement, **kwargs):
 
 @execute_node.register(ops.Translate, pd.Series, pd.Series, pd.Series)
 def execute_series_translate_series_series(op, data, from_string, to_string, **kwargs):
-    to_string_iter = iter(to_string)
-    table = from_string.apply(
-        lambda x, y: str.maketrans(x, y=next(y)), args=(to_string_iter,)
+    tables = [
+        str.maketrans(source, target) for source, target in zip(from_string, to_string)
+    ]
+    return pd.Series(
+        [string.translate(table) for string, table in zip(data, tables)],
+        dtype=data.dtype,
+        name=data.name,
     )
-    return data.str.translate(table)
 
 
 @execute_node.register(ops.Translate, pd.Series, pd.Series, str)
 def execute_series_translate_series_scalar(op, data, from_string, to_string, **kwargs):
-    table = from_string.map(lambda x, y=to_string: str.maketrans(x=x, y=y))
-    return data.str.translate(table)
+    tables = [str.maketrans(source, to_string) for source in from_string]
+    return pd.Series(
+        [string.translate(table) for string, table in zip(data, tables)],
+        dtype=data.dtype,
+        name=data.name,
+    )
 
 
 @execute_node.register(ops.Translate, pd.Series, str, pd.Series)
 def execute_series_translate_scalar_series(op, data, from_string, to_string, **kwargs):
-    table = to_string.map(lambda y, x=from_string: str.maketrans(x=x, y=y))
-    return data.str.translate(table)
+    tables = [str.maketrans(from_string, target) for target in to_string]
+    return pd.Series(
+        [string.translate(table) for string, table in zip(data, tables)],
+        dtype=data.dtype,
+        name=data.name,
+    )
 
 
 @execute_node.register(ops.Translate, pd.Series, str, str)


Test patch:
diff --git a/ibis/backends/pandas/tests/execution/conftest.py b/ibis/backends/pandas/tests/execution/conftest.py
--- a/ibis/backends/pandas/tests/execution/conftest.py
+++ b/ibis/backends/pandas/tests/execution/conftest.py
@@ -32,6 +32,8 @@ def df():
             'float64_as_strings': ['100.01', '234.23', '-999.34'],
             'int64_as_strings': list(map(str, range(1, 4))),
             'strings_with_space': [' ', 'abab', 'ddeeffgg'],
+            'translate_from_strings': ['rmz', 'abc', 'ghj'],
+            'translate_to_strings': ['lns', 'ovk', 'jfr'],
             'int64_with_zeros': [0, 1, 0],
             'float64_with_zeros': [1.0, 0.0, 1.0],
             'float64_positive': [1.0, 2.0, 1.0],
diff --git a/ibis/backends/pandas/tests/execution/test_strings.py b/ibis/backends/pandas/tests/execution/test_strings.py
--- a/ibis/backends/pandas/tests/execution/test_strings.py
+++ b/ibis/backends/pandas/tests/execution/test_strings.py
@@ -128,3 +128,38 @@ def test_string_ops(t, df, case_func, expected_func):
 def test_sql_like_to_regex(pattern, expected):
     result = sql_like_to_regex(pattern, escape='^')
     assert result == f'^{expected}$'
+
+
+@pytest.mark.parametrize(
+    ('from_func', 'to_func', 'from_str', 'to_str'),
+    [
+        param(
+            lambda s: s.translate_from_strings,
+            lambda s: s.translate_to_strings,
+            "rmzabcghj",
+            "lnsovkjfr",
+            id='from_series_to_series',
+        ),
+        param(
+            lambda s: "abc",
+            lambda s: s.translate_to_strings,
+            "abc",
+            "ovk",
+            id='from_string_to_series',
+        ),
+        param(
+            lambda s: s.translate_from_strings,
+            lambda s: "ovk",
+            "abcg",
+            "ovko",
+            id='from_series_to_string',
+        ),
+    ],
+)
+def test_translate(
+    t, df, from_func: callable, to_func: callable, from_str: str, to_str: str
+):
+    result = t.strings_with_space.translate(from_func(t), to_func(t)).execute()
+    table = str.maketrans(from_str, to_str)
+    series = df.strings_with_space.str.translate(table)
+    tm.assert_series_equal(result, series, check_names=False)