Repository: ibis-project/ibis

Issue:
bug(duckdb): struct field access on CASE generates SQL that duckdb cannot parse
### What happened?

OK, I'm really getting into the weeds here, this is a weird little case 😄 

```python
import ibis

s = ibis.struct({"a": 3})
x = ibis.case().when(s.a.isnull(), s).end()
y = x.a
print(ibis.to_sql(y))
# SELECT
#   CASE
#     WHEN (
#       {'a': CAST(3 AS TINYINT)}."a"
#     ) IS NULL
#     THEN {'a': CAST(3 AS TINYINT)}
#     ELSE CAST(NULL AS STRUCT(a TINYINT))
#   END."a" AS "a"
y.execute()  # Parser error due to the END."a"
```

### What version of ibis are you using?

main

### 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:
> OK, I'm really getting into the weeds here

Keep going! Apparently we lack test coverage here and there.
Yeah this is definitely a bug somewhere. Given the precedence of `CASE` in other expressions like arithmetic it's surprising that this fails to parse in the database itself.
This seems to be an oversight in the duckdb parser: parenthesizing the `CASE` statement works.

Looking at another backend that supports structs, trino has no issues with the unparenthesized `CASE` expression.

BigQuery and Snowflake also have no problems here.

Resolving patch:
diff --git a/ibis/backends/duckdb/compiler.py b/ibis/backends/duckdb/compiler.py
--- a/ibis/backends/duckdb/compiler.py
+++ b/ibis/backends/duckdb/compiler.py
@@ -11,11 +11,7 @@
 import ibis.common.exceptions as com
 import ibis.expr.datatypes as dt
 import ibis.expr.operations as ops
-from ibis.backends.sql.compiler import (
-    NULL,
-    STAR,
-    SQLGlotCompiler,
-)
+from ibis.backends.sql.compiler import NULL, STAR, SQLGlotCompiler, paren
 from ibis.backends.sql.datatypes import DuckDBType
 
 _INTERVAL_SUFFIXES = {
@@ -401,3 +397,11 @@ def visit_HexDigest(self, op, *, arg, how):
 
     def visit_StringConcat(self, op, *, arg):
         return reduce(lambda x, y: sge.DPipe(this=x, expression=y), arg)
+
+    def visit_StructField(self, op, *, arg, field):
+        if not isinstance(op.arg, (ops.Field, sge.Struct)):
+            # parenthesize anything that isn't a simple field access
+            return sge.Dot(
+                this=paren(arg), expression=sg.to_identifier(field, quoted=self.quoted)
+            )
+        return super().visit_StructField(op, arg=arg, field=field)


Test patch:
diff --git a/ibis/backends/tests/test_struct.py b/ibis/backends/tests/test_struct.py
--- a/ibis/backends/tests/test_struct.py
+++ b/ibis/backends/tests/test_struct.py
@@ -10,6 +10,11 @@
 
 import ibis
 import ibis.expr.datatypes as dt
+from ibis.backends.tests.errors import (
+    PsycoPg2InternalError,
+    PsycoPg2SyntaxError,
+    Py4JJavaError,
+)
 
 pytestmark = [
     pytest.mark.never(["mysql", "sqlite", "mssql"], reason="No struct support"),
@@ -133,3 +138,19 @@ def test_collect_into_struct(alltypes):
     val = result.val
     assert len(val.loc[result.group == "0"].iat[0]["key"]) == 730
     assert len(val.loc[result.group == "1"].iat[0]["key"]) == 730
+
+
+@pytest.mark.notimpl(
+    ["postgres"], reason="struct literals not implemented", raises=PsycoPg2SyntaxError
+)
+@pytest.mark.notimpl(
+    ["risingwave"],
+    reason="struct literals not implemented",
+    raises=PsycoPg2InternalError,
+)
+@pytest.mark.notimpl(["flink"], raises=Py4JJavaError, reason="not implemented in ibis")
+def test_field_access_after_case(con):
+    s = ibis.struct({"a": 3})
+    x = ibis.case().when(True, s).else_(ibis.struct({"a": 4})).end()
+    y = x.a
+    assert con.to_pandas(y) == 3