Repository: ibis-project/ibis

Issue:
docs: raise warning or error on incorrect NULL check in 'having' clause
### Is your feature request related to a problem?

## Problem description

SQL and Python developers can be frustrated if they, by force of habit, use a `is None` or a similar expression in the `having` parameter of the `aggregate` method. In that case, they will get neither error nor warning, and the result will be most likely not what they intended.

Given this example code below, the `HAVING` clause in the generated SQL will be:
```sql
HAVING
  CAST(FALSE AS BOOLEAN)
```

The query will work and will return the result (an empty data frame) which was not what was intended.
If this clause is part of an `OR` expression, then the issue will be even harder to detect, as this clause will have no effect.
In addition, using a similar pattern in the `filter` method will raise an error. So this might be also a case of inconsistent behavior.

## Example code

```python
"""Demonstrate no warning and wrong results for `having=t["foo"].sum() is None` """

import ibis
from ibis import _

import pandas as pd

# -------- data --------
produce = ibis.memtable(
    pd.DataFrame({
        "category": ["fruit", "fruit", "berry", "veg"],
        "item": ["apple", "orange", "raspberry", "potato"],
        "price": [2.5, 3.0, 7.0, None],
    })
)

if __name__ == '__main__':
    expr = (
        produce
        # .filter(_["price"] is None)  # error is raised
        .filter(_["price"].isnull())  # works as intended
        .aggregate(
            _["price"].sum(),
            by="category",
            having=_["price"].sum() is None  # no error or warning, wrong results
            # having=_["price"].sum() == ibis.null  # no error or warning, wrong results
            # having=_["price"].sum().isnull()  # works as expected
        )
    )

    print(ibis.to_sql(expr))
    print(expr.execute())

```

### Describe the solution you'd like

An error or at least a warning is to be raised if the described pattern is used.is 

### What version of ibis are you running?

5.0.0

### What backend(s) are you using, if any?

Pandas, but also observed the same in BigQuery.

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@dimitri-b Hi 👋🏻, and thanks for the issue!

It looks like this might be hard to detect, since when a user writes `x is None` ibis only sees `True` or `False`.

One option is to warn when a user gives the Python bool object `True` or `False` and to silence that warning by passing `ibis.literal(True or False)`.
> One option is to warn when a user gives the Python bool object `True` or `False` and to silence that warning by passing `ibis.literal(True or False)`.

I think that's a great idea. Essentially, if the user really intends to use a boolean - it has to be an Ibis literal, otherwise, they'll get a warning and maybe a suggestion on the proper way of evaluating values in columns. In my example case, the user is not aware that they are implicitly giving Ibis a boolean literal.


While I empathize with the issue here, I'm not sure how we can do this thoroughly without warning basically anywhere there's a boolean literal in an expression (outside of calling `ibis.literal` with a `bool`) and creating a large amount of warning spam, only to be addressed by making users write more code.

Since the problem is not with ibis, but is a bit of a Python foot gun for new Python users who know SQL and an understandable misunderstanding of how the Python `is` operator works I don't know if the additional work to plumb this through the library and potential warning spam is worth it.
I definitely agree that a lot of warning spam is not a reasonable 'price' to pay for these guardrails.

Can Ibis differentiate between a real boolean literal in Python code (i.e. `False`) and an expression that is guaranteed to evaluate to `False` (i.e. `1 == 2`)? 
I suspect that the latter is the case here, i.e. the problematic clause checks if the Column object is the same object as the `None` object, but the intention was to check Column's **elements** for NULL values.
If Ibis can tell the difference, then the warning can be raised for the 'expression' cases, **only if used with the `having=` parameter** in the `aggregate` function. The rationale is the assumption that such expressions are most likely not what the user intended.

The concern here is that this mistake is easy to make and hard to detect, and the code looks like it's doing the right thing.

> Can Ibis differentiate between a real boolean literal in Python code (i.e. False) and an expression that is guaranteed to evaluate to False (i.e. 1 == 2)?

No, ibis cannot tell the difference.

Short of parsing source code, no other Python library can tell difference between those two expressions.
Just to give a concrete example:

```
In [1]: (1 == 2) is False
Out[1]: True
```

Not only are `(1 == 2)` and `False` equal, but they are pointing to the same exact object in memory.

Another way to think about this is that any Python function accepting a Python `bool` has no idea what other code produced that `bool`: it could be a user typing `False`, a function call, or any other valid Python code whose result is a `bool`.
Fair enough. That leaves only the documentation. I think it would be good to add some cautionary notes in the relevant places.

Resolving patch:
diff --git a/ibis/expr/types/groupby.py b/ibis/expr/types/groupby.py
--- a/ibis/expr/types/groupby.py
+++ b/ibis/expr/types/groupby.py
@@ -108,6 +108,8 @@ def aggregate(self, metrics=None, **kwds):
     def having(self, expr: ir.BooleanScalar) -> GroupedTable:
         """Add a post-aggregation result filter `expr`.
 
+        !!! warning "Expressions like `x is None` return `bool` and **will not** generate a SQL comparison to `NULL`"
+
         Parameters
         ----------
         expr
diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py
--- a/ibis/expr/types/relations.py
+++ b/ibis/expr/types/relations.py
@@ -853,6 +853,8 @@ def aggregate(
         having
             Post-aggregation filters. The shape requirements are the same
             `metrics`, but the output type for `having` is `boolean`.
+
+            !!! warning "Expressions like `x is None` return `bool` and **will not** generate a SQL comparison to `NULL`"
         kwargs
             Named aggregate expressions
 


Test patch: