Repository: ibis-project/ibis

Issue:
bug: x.isin(<contains NULLs>) result depends on backend
### What happened?

```python
t1 = ibis.memtable({"x": [1, 2]})
e = t1.x.isin([1, None])
print(ibis.duckdb.connect().execute(e))
print(ibis.sqlite.connect().execute(e))
print(ibis.polars.connect().execute(e))
print(ibis.pandas.connect().execute(e))
```
yields

0    True
1    None
Name: InValues(x), dtype: object
0    True
1    None
Name: InValues(x), dtype: object
0     True
1    False
Name: InValues(x), dtype: bool
0    True
1    None
Name: InValues(x), dtype: object

If a needle isn't present in the haystack, and the haystack contains NULL, then
for some backends this returns False, and for others NULL.

I think it is pretty standard SQL to return null. But this isn't what I expected to happen, I actually expected
pandas semantics. IDK which we want, but they should be the same, and this should be documented.

### What version of ibis are you using?

main

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

varies

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:
It looks like most (all?) backends return `NULL` and polars might be the odd one out here.

Resolving patch:
diff --git a/ibis/expr/types/generic.py b/ibis/expr/types/generic.py
--- a/ibis/expr/types/generic.py
+++ b/ibis/expr/types/generic.py
@@ -498,6 +498,8 @@ def between(
     def isin(self, values: Value | Sequence[Value]) -> ir.BooleanValue:
         """Check whether this expression's values are in `values`.
 
+        `NULL` values are propagated in the output. See examples for details.
+
         Parameters
         ----------
         values
@@ -567,6 +569,40 @@ def isin(self, values: Value | Sequence[Value]) -> ir.BooleanValue:
         │ True          │
         │ False         │
         └───────────────┘
+
+        `NULL` behavior
+
+        >>> t = ibis.memtable({"x": [1, 2]})
+        >>> t.x.isin([1, None])
+        ┏━━━━━━━━━━━━━┓
+        ┃ InValues(x) ┃
+        ┡━━━━━━━━━━━━━┩
+        │ boolean     │
+        ├─────────────┤
+        │ True        │
+        │ NULL        │
+        └─────────────┘
+        >>> t = ibis.memtable({"x": [1, None, 2]})
+        >>> t.x.isin([1])
+        ┏━━━━━━━━━━━━━┓
+        ┃ InValues(x) ┃
+        ┡━━━━━━━━━━━━━┩
+        │ boolean     │
+        ├─────────────┤
+        │ True        │
+        │ NULL        │
+        │ False       │
+        └─────────────┘
+        >>> t.x.isin([3])
+        ┏━━━━━━━━━━━━━┓
+        ┃ InValues(x) ┃
+        ┡━━━━━━━━━━━━━┩
+        │ boolean     │
+        ├─────────────┤
+        │ False       │
+        │ NULL        │
+        │ False       │
+        └─────────────┘
         """
         from ibis.expr.types import ArrayValue
 


Test patch:
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
@@ -987,7 +987,7 @@ def test_memtable_construct_from_polars(backend, con, lazy):
         ([("a", "1.0")], ["d", "e"], ["d", "e"]),
     ],
 )
-def test_memtable_column_naming(backend, con, monkeypatch, df, columns, expected):
+def test_memtable_column_naming(con, monkeypatch, df, columns, expected):
     monkeypatch.setattr(ibis.options, "default_backend", con)
 
     t = ibis.memtable(df, columns=columns)
@@ -1003,7 +1003,7 @@ def test_memtable_column_naming(backend, con, monkeypatch, df, columns, expected
         ([("a", "1.0")], ["d"]),
     ],
 )
-def test_memtable_column_naming_mismatch(backend, con, monkeypatch, df, columns):
+def test_memtable_column_naming_mismatch(con, monkeypatch, df, columns):
     monkeypatch.setattr(ibis.options, "default_backend", con)
 
     with pytest.raises(ValueError):
@@ -1899,9 +1899,7 @@ def test_isnull_equality(con, backend, monkeypatch):
 @pytest.mark.broken(
     ["druid"],
     raises=PyDruidProgrammingError,
-    reason=(
-        "Query could not be planned. SQL query requires ordering a table by time column"
-    ),
+    reason="Query could not be planned. SQL query requires ordering a table by time column",
 )
 def test_subsequent_overlapping_order_by(con, backend, alltypes, df):
     ts = alltypes.order_by(ibis.desc("id")).order_by("id")
@@ -2020,3 +2018,24 @@ def test_topk_counts_null(con):
     tkf = tk.filter(_.x.isnull())[1]
     result = con.to_pyarrow(tkf)
     assert result[0].as_py() == 1
+
+
+@pytest.mark.notyet(
+    "clickhouse",
+    raises=AssertionError,
+    reason="ClickHouse returns False for x.isin([None])",
+)
+@pytest.mark.notimpl(
+    ["pandas", "dask"],
+    raises=AssertionError,
+    reason="null isin semantics are not implemented for pandas or dask",
+)
+@pytest.mark.never(
+    "mssql",
+    raises=AssertionError,
+    reason="mssql doesn't support null isin semantics in a projection because there is no bool type",
+)
+def test_null_isin_null_is_null(con):
+    t = ibis.memtable({"x": [1]})
+    expr = t.x.isin([None])
+    assert pd.isna(con.to_pandas(expr).iat[0])