Repository: ibis-project/ibis

Issue:
bug: missing parens in SQL with IS NULL
### What happened?

A regression happened with the merge of the sqlglot branch:

```python
import ibis

ibis.options.interactive = True

t = ibis.memtable({"x": [1, 2, None], "y": [3, None, None]})
a = t.x.isnull() == t.y.isnull()
display(a)
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ Equals(IsNull(x), IsNull(y)) ┃
# ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
# │ boolean                      │
# ├──────────────────────────────┤
# │ False                        │
# │ True                         │
# │ True                         │
# └──────────────────────────────┘
display(ibis.to_sql(a))
# SELECT
#   "t0"."x" IS NULL = "t0"."y" IS NULL AS "Equals(IsNull(x), IsNull(y))"
# FROM "ibis_pandas_memtable_g4rdczpy3fb2jcns5sa5nxu7by" AS "t0"

# This is getting interpreted as
# ("t0"."x" IS NULL = "t0"."y") IS NULL

# The correct SQL should be
a._find_backend(use_default=True).sql(
    """
SELECT
  ("t0"."x" IS NULL) = ("t0"."y" IS NULL) AS "Equals(IsNull(x), IsNull(y))"
FROM "ibis_pandas_memtable_g4rdczpy3fb2jcns5sa5nxu7by" AS "t0"
"""
)
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ Equals(IsNull(x), IsNull(y)) ┃
# ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
# │ boolean                      │
# ├──────────────────────────────┤
# │ True                         │
# │ False                        │
# │ True                         │
# └──────────────────────────────┘
```

### What version of ibis are you using?

main, d587166e303bdf4ea99453f9de8a8227e421f697

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

duckdb, though probably order of operations is the same in all backends

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/ibis/backends/base/sqlglot/compiler.py b/ibis/backends/base/sqlglot/compiler.py
--- a/ibis/backends/base/sqlglot/compiler.py
+++ b/ibis/backends/base/sqlglot/compiler.py
@@ -320,6 +320,8 @@ class SQLGlotCompiler(abc.ABC):
         ops.IntervalSubtract,
     )
 
+    NEEDS_PARENS = BINARY_INFIX_OPS + (ops.IsNull,)
+
     def __init__(self) -> None:
         self.agg = AggGen(aggfunc=self._aggregate)
         self.f = FuncGen()
@@ -1191,7 +1193,7 @@ def visit_Aggregate(self, op, *, parent, groups, metrics):
 
     @classmethod
     def _add_parens(cls, op, sg_expr):
-        if isinstance(op, cls.BINARY_INFIX_OPS):
+        if isinstance(op, cls.NEEDS_PARENS):
             return paren(sg_expr)
         return sg_expr
 


Test patch:
diff --git a/ibis/backends/impala/tests/snapshots/test_sql/test_is_parens/isnull/out.sql b/ibis/backends/impala/tests/snapshots/test_sql/test_is_parens/isnull/out.sql
--- a/ibis/backends/impala/tests/snapshots/test_sql/test_is_parens/isnull/out.sql
+++ b/ibis/backends/impala/tests/snapshots/test_sql/test_is_parens/isnull/out.sql
@@ -3,4 +3,8 @@ SELECT
   `t0`.`b`
 FROM `table` AS `t0`
 WHERE
-  `t0`.`a` IS NULL = `t0`.`b` IS NULL
\ No newline at end of file
+  (
+    `t0`.`a` IS NULL
+  ) = (
+    `t0`.`b` IS NULL
+  )
\ No newline at end of file
diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py
--- a/ibis/backends/tests/test_generic.py
+++ b/ibis/backends/tests/test_generic.py
@@ -1846,3 +1846,15 @@ def test_select_mutate_with_dict(backend):
 
     expr = t.select({"a": ibis.literal(1.0)}).limit(1)
     backend.assert_frame_equal(result, expected)
+
+
+@pytest.mark.broken(["mssql", "oracle"], reason="incorrect syntax")
+def test_isnull_equality(con, backend, monkeypatch):
+    monkeypatch.setattr(ibis.options, "default_backend", con)
+    t = ibis.memtable({"x": ["a", "b", None], "y": ["c", None, None], "z": [1, 2, 3]})
+    expr = t.mutate(out=t.x.isnull() == t.y.isnull()).order_by("z").select("out")
+    result = expr.to_pandas()
+
+    expected = pd.DataFrame({"out": [True, False, True]})
+
+    backend.assert_frame_equal(result, expected)