Repository: ibis-project/ibis

Issue:
bug: using `endswith` on an enum column in MySQL crashes on `execute` 
### What happened?

If you use `endswith` on an enum column in MySQL (and maybe others), it will crash trying to map the return value into a value of the source enum. The return value is a boolean, so I'm not sure what is happening to make it want to map it back into an enum value (which are strings).

```
conn.raw_sql(r'''
create table if not exists datatypes (
    id int unsigned,
    enum_sml_c enum('small', 'medium', 'large')
);''')

conn.raw_sql(r'''
insert into datatypes (id, enum_sml_c) values (0, 'small');
''')

tbl = conn.table('datatypes')
tbl.enum_sml_c.endswith('m').execute()
```

### What version of ibis are you using?

5.1.0

### What backend(s) are you using, if any?

MySQL (actually the SingleStoreDB variant which is based on MySQL)

### Relevant log output

```sh
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/sql/sqltypes.py:1651, in Enum._object_value_for_elem(self, elem)
   1650 try:
-> 1651     return self._object_lookup[elem]
   1652 except KeyError as err:

KeyError: 0

The above exception was the direct cause of the following exception:

LookupError                               Traceback (most recent call last)
Cell In[14], line 1
----> 1 tbl.enum_sml_c.endswith('m').execute()

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/ibis/expr/types/core.py:303, in Expr.execute(self, limit, timecontext, params, **kwargs)
    276 def execute(
    277     self,
    278     limit: int | str | None = 'default',
   (...)
    281     **kwargs: Any,
    282 ):
    283     """Execute an expression against its backend if one exists.
    284 
    285     Parameters
   (...)
    301         Keyword arguments
    302     """
--> 303     return self._find_backend(use_default=True).execute(
    304         self, limit=limit, timecontext=timecontext, params=params, **kwargs
    305     )

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/ibis/backends/base/sql/__init__.py:254, in BaseSQLBackend.execute(self, expr, params, limit, **kwargs)
    251 self._register_in_memory_tables(expr)
    253 with self._safe_raw_sql(sql, **kwargs) as cursor:
--> 254     result = self.fetch_from_cursor(cursor, schema)
    256 if hasattr(getattr(query_ast, 'dml', query_ast), 'result_handler'):
    257     result = query_ast.dml.result_handler(result)

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/ibis/backends/base/sql/alchemy/__init__.py:157, in BaseAlchemyBackend.fetch_from_cursor(self, cursor, schema)
    154 def fetch_from_cursor(self, cursor, schema: sch.Schema) -> pd.DataFrame:
    155     import pandas as pd
--> 157     df = pd.DataFrame.from_records(cursor, columns=schema.names, coerce_float=True)
    158     df = schema.apply_to(df)
    159     if not df.empty and geospatial_supported:

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/pandas/core/frame.py:2225, in DataFrame.from_records(cls, data, index, exclude, columns, coerce_float, nrows)
   2222     return cls()
   2224 try:
-> 2225     first_row = next(data)
   2226 except StopIteration:
   2227     return cls(index=index, columns=columns)

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/engine/result.py:1006, in Result.__next__(self)
   1005 def __next__(self):
-> 1006     return self._next_impl()

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/engine/result.py:640, in ResultInternal._next_impl(self)
    639 def _next_impl(self):
--> 640     row = self._onerow_getter(self)
    641     if row is _NO_ROW:
    642         raise StopIteration()

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/engine/result.py:461, in ResultInternal._onerow_getter.<locals>.onerow(self)
    459     return _NO_ROW
    460 else:
--> 461     row = make_row(row) if make_row else row
    462     if post_creational_filter:
    463         row = post_creational_filter(row)

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/sql/sqltypes.py:1770, in Enum.result_processor.<locals>.process(value)
   1767 if parent_processor:
   1768     value = parent_processor(value)
-> 1770 value = self._object_value_for_elem(value)
   1771 return value

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/dialects/mysql/enumerated.py:94, in ENUM._object_value_for_elem(self, elem)
     92     return elem
     93 else:
---> 94     return super(ENUM, self)._object_value_for_elem(elem)

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/sql/sqltypes.py:1653, in Enum._object_value_for_elem(self, elem)
   1651     return self._object_lookup[elem]
   1652 except KeyError as err:
-> 1653     util.raise_(
   1654         LookupError(
   1655             "'%s' is not among the defined enum values. "
   1656             "Enum name: %s. Possible values: %s"
   1657             % (
   1658                 elem,
   1659                 self.name,
   1660                 langhelpers.repr_tuple_names(self.enums),
   1661             )
   1662         ),
   1663         replace_context=err,
   1664     )

File ~/.pyenv/versions/3.11.3/lib/python3.11/site-packages/sqlalchemy/util/compat.py:211, in raise_(***failed resolving arguments***)
    208     exception.__cause__ = replace_context
    210 try:
--> 211     raise exception
    212 finally:
    213     # credit to
    214     # https://cosmicpercolator.com/2016/01/13/exception-leaks-in-python-2-and-3/
    215     # as the __traceback__ object creates a cycle
    216     del exception, replace_context, from_, with_traceback

LookupError: '0' is not among the defined enum values. Enum name: None. Possible values: small, medium, large
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@kesmit13 Great to hear from you again, been a while!

Thanks for the bug report, I can reproduce locally with the plain MySQL backend.

Slated for 6.0!

Resolving patch:
diff --git a/ibis/backends/mysql/registry.py b/ibis/backends/mysql/registry.py
--- a/ibis/backends/mysql/registry.py
+++ b/ibis/backends/mysql/registry.py
@@ -176,12 +176,20 @@ def compiles_mysql_trim(element, compiler, **kw):
         ),
         # LIKE in mysql is case insensitive
         ops.StartsWith: fixed_arity(
-            lambda arg, start: arg.op("LIKE BINARY")(sa.func.concat(start, "%")), 2
+            lambda arg, start: sa.type_coerce(
+                arg.op("LIKE BINARY")(sa.func.concat(start, "%")), sa.BOOLEAN()
+            ),
+            2,
         ),
         ops.EndsWith: fixed_arity(
-            lambda arg, end: arg.op("LIKE BINARY")(sa.func.concat("%", end)), 2
+            lambda arg, end: sa.type_coerce(
+                arg.op("LIKE BINARY")(sa.func.concat("%", end)), sa.BOOLEAN()
+            ),
+            2,
+        ),
+        ops.RegexSearch: fixed_arity(
+            lambda x, y: sa.type_coerce(x.op('REGEXP')(y), sa.BOOLEAN()), 2
         ),
-        ops.RegexSearch: fixed_arity(lambda x, y: x.op('REGEXP')(y), 2),
         ops.RegexExtract: fixed_arity(_regex_extract, 3),
         # math
         ops.Log: fixed_arity(lambda arg, base: sa.func.log(base, arg), 2),


Test patch:
diff --git a/ibis/backends/mysql/tests/test_client.py b/ibis/backends/mysql/tests/test_client.py
--- a/ibis/backends/mysql/tests/test_client.py
+++ b/ibis/backends/mysql/tests/test_client.py
@@ -1,12 +1,16 @@
 from datetime import date
+from operator import methodcaller
 
 import pandas as pd
 import pandas.testing as tm
 import pytest
+import sqlalchemy as sa
 from pytest import param
+from sqlalchemy.dialects import mysql
 
 import ibis
 import ibis.expr.datatypes as dt
+from ibis.util import gen_name
 
 MYSQL_TYPES = [
     ("tinyint", dt.int8),
@@ -130,3 +134,35 @@ def test_zero_timestamp_data(con):
         }
     )
     tm.assert_frame_equal(result, expected)
+
+
+@pytest.fixture(scope="module")
+def enum_t(con):
+    name = gen_name("mysql_enum_test")
+    t = sa.Table(
+        name, sa.MetaData(), sa.Column("sml", mysql.ENUM("small", "medium", "large"))
+    )
+    with con.begin() as bind:
+        t.create(bind=bind)
+        bind.execute(t.insert().values(sml="small"))
+
+    yield con.table(name)
+    con.drop_table(name, force=True)
+
+
+@pytest.mark.parametrize(
+    ("expr_fn", "expected"),
+    [
+        (methodcaller("startswith", "s"), pd.Series([True], name="sml")),
+        (methodcaller("endswith", "m"), pd.Series([False], name="sml")),
+        (methodcaller("re_search", "mall"), pd.Series([True], name="sml")),
+        (methodcaller("lstrip"), pd.Series(["small"], name="sml")),
+        (methodcaller("rstrip"), pd.Series(["small"], name="sml")),
+        (methodcaller("strip"), pd.Series(["small"], name="sml")),
+    ],
+    ids=["startswith", "endswith", "re_search", "lstrip", "rstrip", "strip"],
+)
+def test_enum_as_string(enum_t, expr_fn, expected):
+    expr = expr_fn(enum_t.sml).name("sml")
+    res = expr.execute()
+    tm.assert_series_equal(res, expected)