Repository: ibis-project/ibis

Issue:
bug(duckdb): percent signs aren't escaped correctly in `.case()`
### What happened?

EDIT: I originally filed this as a repr bug, but it actually is a bug with .case()

```python
t = ibis.examples.penguins.fetch()
cased = ibis.case().when(t.sex == "male", r"%").else_("").end()
added = r"%" + _.sex
t.select("sex", added=added, cased=cased)
```

results in (note the double %%, should be a single %)
```
┏━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ sex    ┃ added   ┃ cased  ┃
┡━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ string │ string  │ string │
├────────┼─────────┼────────┤
│ male   │ %male   │ %%     │
│ female │ %female │ ~      │
│ female │ %female │ ~      │
│ NULL   │ %       │ ~      │
│ female │ %female │ ~      │
│ male   │ %male   │ %%     │
│ female │ %female │ ~      │
│ male   │ %male   │ %%     │
│ NULL   │ %       │ ~      │
│ NULL   │ %       │ ~      │
│ …      │ …       │ …      │
└────────┴─────────┴────────┘
```

The problem is that this is getting compiled to:

```sql
SELECT
  t0.sex,
  CONCAT(COALESCE(CAST('%' AS TEXT), ''), COALESCE(CAST(t0.sex AS TEXT), '')) AS added,
  CASE WHEN (
    t0.sex = 'male'
  ) THEN '%%' ELSE '' END AS cased
FROM _ibis_examples_penguins_3bvfvrlqvvfxtohz4nsbvfdxca AS t0
```

Using .ifelse() works fine.

### What version of ibis are you using?

6.1.0

### What backend(s) are you using, if any?

duckdb

### Relevant log output

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
This is due to the patch made [here](https://github.com/ibis-project/ibis/pull/5544) where case statements are compiled with bound literals to work around a bug in duckdb < 0.7.0. I spent some time playing around with sqlalchemy to see if we could fix this behavior, but haven't been able to. Perhaps @cpcloud knows a trick here.

Since the case statement parametrization issue was fixed in duckdb 0.7.0 (released in Feb 2023) we _could_ potentially remove this patch entirely, reverting back to the base case statement compiler which won't result in the escaping behavior here. Alternatively I suspect the upcoming sqlglot compiler (#6996) may be able to work around this issue in a cleaner way. SQLAlchemy is powerful, but the layers make it a bit complicated to debug :/.
lol what a circle we've made 🙃:

1. notice upstream problem
2. add workaround for problem
3. upstream problem is fixed
4. now our workaround is the problem

Could we make that patch conditional depending on `duckdb.__version__ < 0.7.0`?

Resolving patch:
diff --git a/ibis/backends/druid/__init__.py b/ibis/backends/druid/__init__.py
--- a/ibis/backends/druid/__init__.py
+++ b/ibis/backends/druid/__init__.py
@@ -62,6 +62,9 @@ def do_connect(
         # workaround a broken pydruid `has_table` implementation
         engine.dialect.has_table = self._has_table
 
+        # don't double percent signs
+        engine.dialect.identifier_preparer._double_percents = False
+
     @staticmethod
     def _new_sa_metadata():
         meta = sa.MetaData()
diff --git a/ibis/backends/druid/datatypes.py b/ibis/backends/druid/datatypes.py
--- a/ibis/backends/druid/datatypes.py
+++ b/ibis/backends/druid/datatypes.py
@@ -54,6 +54,11 @@ def _smallint(element, compiler, **kw):
     return "SMALLINT"
 
 
+@compiles(DruidString, "druid")
+def _string(element, compiler, **kw):
+    return "VARCHAR"
+
+
 class DruidType(AlchemyType):
     dialect = "hive"
 
diff --git a/ibis/backends/duckdb/registry.py b/ibis/backends/duckdb/registry.py
--- a/ibis/backends/duckdb/registry.py
+++ b/ibis/backends/duckdb/registry.py
@@ -4,10 +4,8 @@
 from functools import partial
 from typing import TYPE_CHECKING, Any
 
-import duckdb
 import numpy as np
 import sqlalchemy as sa
-from packaging.version import parse as vparse
 from sqlalchemy.ext.compiler import compiles
 from sqlalchemy.sql.functions import GenericFunction
 from toolz.curried import flip
@@ -23,9 +21,6 @@
     reduction,
     try_cast,
 )
-from ibis.backends.base.sql.alchemy.registry import (
-    _translate_case as _base_translate_case,
-)
 from ibis.backends.postgres.registry import (
     _array_index,
     _array_slice,
@@ -44,8 +39,6 @@
     for op in operation_registry.keys() - geospatial_functions.keys()
 }
 
-_SUPPORTS_MAPS = vparse(duckdb.__version__) >= vparse("0.8.0")
-
 
 def _round(t, op):
     arg, digits = op.args
@@ -224,26 +217,6 @@ def _struct_column(t, op):
     )
 
 
-def _simple_case(t, op):
-    return _translate_case(t, op, value=t.translate(op.base))
-
-
-def _searched_case(t, op):
-    return _translate_case(t, op, value=None)
-
-
-def _translate_case(t, op, *, value):
-    return sa.literal_column(
-        str(
-            _base_translate_case(t, op, value=value).compile(
-                dialect=sa.dialects.registry.load("duckdb")(),
-                compile_kwargs=dict(literal_binds=True),
-            )
-        ),
-        type_=t.get_sqla_type(op.dtype),
-    )
-
-
 @compiles(array_map, "duckdb")
 def compiles_list_apply(element, compiler, **kw):
     *args, signature, result = map(partial(compiler.process, **kw), element.clauses)
@@ -460,8 +433,6 @@ def _try_cast(t, op):
         ops.ArrayStringJoin: fixed_arity(
             lambda sep, arr: sa.func.array_aggr(arr, sa.text("'string_agg'"), sep), 2
         ),
-        ops.SearchedCase: _searched_case,
-        ops.SimpleCase: _simple_case,
         ops.StartsWith: fixed_arity(sa.func.prefix, 2),
         ops.EndsWith: fixed_arity(sa.func.suffix, 2),
         ops.Argument: lambda _, op: sa.literal_column(op.name),
@@ -477,11 +448,9 @@ def _try_cast(t, op):
             lambda arg, key: sa.func.array_length(sa.func.element_at(arg, key)) != 0, 2
         ),
         ops.MapLength: unary(sa.func.cardinality),
-        ops.MapKeys: unary(sa.func.map_keys) if _SUPPORTS_MAPS else _map_keys,
-        ops.MapValues: unary(sa.func.map_values) if _SUPPORTS_MAPS else _map_values,
-        ops.MapMerge: (
-            fixed_arity(sa.func.map_concat, 2) if _SUPPORTS_MAPS else _map_merge
-        ),
+        ops.MapKeys: unary(sa.func.map_keys),
+        ops.MapValues: unary(sa.func.map_values),
+        ops.MapMerge: fixed_arity(sa.func.map_concat, 2),
         ops.Hash: unary(sa.func.hash),
         ops.Median: reduction(sa.func.median),
         ops.First: reduction(sa.func.first),


Test patch:
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
@@ -1051,3 +1051,25 @@ def test_levenshtein(con, right):
     expr = left.levenshtein(right)
     result = con.execute(expr)
     assert result == 3
+
+
+@pytest.mark.notimpl(["datafusion"], raises=com.OperationNotDefinedError)
+@pytest.mark.notyet(
+    ["mssql"],
+    reason="doesn't allow boolean expressions in select statements",
+    raises=sa.exc.OperationalError,
+)
+@pytest.mark.broken(
+    ["oracle"],
+    reason="sqlalchemy converts True to 1, which cannot be used in CASE WHEN statement",
+    raises=sa.exc.DatabaseError,
+)
+@pytest.mark.parametrize(
+    "expr",
+    [
+        param(ibis.case().when(True, "%").end(), id="case"),
+        param(ibis.ifelse(True, "%", ibis.NA), id="ifelse"),
+    ],
+)
+def test_no_conditional_percent_escape(con, expr):
+    assert con.execute(expr) == "%"