fix(filter): make NOT and ne include rows where the field is unset

NOT (col = v) and col <> v are NULL when col is NULL, so negation
dropped rows whose column is unset — a conclusion with no session is
not "some other session", but was excluded anyway. IS NOT TRUE and
IS DISTINCT FROM behave identically when no NULL is involved.

Adds a row-count test, since this class of bug builds and executes
cleanly, and documents {"ne": null} for excluding unset fields.
This commit is contained in:
Vineeth Voruganti 2026-07-28 17:50:25 -04:00
parent 81e7090d70
commit 59f82944c5
4 changed files with 268 additions and 4 deletions

View File

@ -192,6 +192,83 @@ sessions = honcho.sessions(filters={
```
</CodeGroup>
### Negation and Unset Fields
Some fields can be unset. A conclusion drawn across a whole workspace has no
session, so its `session_id` is null.
`NOT` and `ne` include those rows. A conclusion with no session is not part of
some particular session, so excluding that session keeps it in the result:
<CodeGroup>
```python Python
# Every conclusion except the ones in this session — including
# workspace-level conclusions, which belong to no session at all
conclusions = peer.conclusions.list(filters={
"NOT": [
{"session_id": "session-123"}
]
})
# Equivalent
conclusions = peer.conclusions.list(filters={
"session_id": {"ne": "session-123"}
})
```
```typescript TypeScript
(async () => {
// Every conclusion except the ones in this session — including
// workspace-level conclusions, which belong to no session at all
const conclusions = await peer.conclusions.list({
filters: { NOT: [{ session_id: "session-123" }] }
});
// Equivalent
const same = await peer.conclusions.list({
filters: { session_id: { ne: "session-123" } }
});
})();
```
</CodeGroup>
To exclude a value **and** require the field to be set, add a null check.
`{"ne": null}` matches rows where the field has any value:
<CodeGroup>
```python Python
# In some session, just not this one — excludes conclusions with no session
conclusions = peer.conclusions.list(filters={
"AND": [
{"session_id": {"ne": "session-123"}},
{"session_id": {"ne": None}}
]
})
```
```typescript TypeScript
(async () => {
// In some session, just not this one — excludes conclusions with no session
const conclusions = await peer.conclusions.list({
filters: {
AND: [
{ session_id: { ne: "session-123" } },
{ session_id: { ne: null } }
]
}
});
})();
```
</CodeGroup>
Positive conditions work the other way around: an unset field matches nothing,
so `{"session_id": "session-123"}` and `{"session_id": {"contains": "abc"}}`
never return rows whose `session_id` is null. Use `{"session_id": None}` to
match those rows specifically.
Fields that are always populated — `metadata`, which defaults to `{}`, and
`created_at` — are unaffected by any of this.
### Combining Logical Operators
Create sophisticated queries by combining different logical operators:
@ -673,6 +750,50 @@ bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"})
```
</CodeGroup>
## Value Types
A filter value has to be usable against the field it targets. Honcho validates
this before running the query and returns a `422` with an explanation when it
doesn't hold, rather than failing mid-query or quietly returning nothing.
| Field | Accepts |
| --- | --- |
| Text — `peer_id`, `session_id`, `id`, `content` | Strings |
| Numeric — `token_count` | Numbers, or numeric strings like `"5"`. Exact for integers of any size |
| Timestamps — `created_at` | ISO 8601 strings such as `"2026-01-01"` or `"2026-01-01T12:00:00Z"` |
| Boolean — `is_active` | `true` / `false` |
| `metadata` | An object, matched by containment |
| Fields with fixed values — `level` | One of the documented values |
Two consequences worth knowing:
- **Booleans must be real booleans.** `{"is_active": True}` filters; the string
`{"is_active": "true"}` is rejected.
- **Fixed-value fields are checked.** `{"level": "explicit"}` filters;
`{"level": "typo"}` is rejected instead of returning an empty list, so a
misspelling doesn't look like "no results".
The same rules apply however the value is wrapped — bare, under an operator, or
inside an `in` list — so `{"level": "explicit"}`,
`{"level": {"ne": "explicit"}}` and `{"level": {"in": ["explicit"]}}` all
validate identically.
An empty `in` list matches nothing:
<CodeGroup>
```python Python
# Returns no results — an empty allowlist excludes everything
messages = session.messages(filters={"peer_id": {"in": []}})
```
```typescript TypeScript
(async () => {
// Returns no results — an empty allowlist excludes everything
const messages = await session.messages({ filters: { peer_id: { in: [] } } });
})();
```
</CodeGroup>
## Error Handling
Handle filter errors gracefully:

View File

@ -5,7 +5,7 @@ from logging import getLogger
from typing import Any, TypeVar, get_args
from typing import cast as typing_cast
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, or_
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import Numeric
@ -425,8 +425,15 @@ def _build_filter_conditions(
_depth=_depth + 1,
)
if sub_condition is not None:
# `IS NOT TRUE` rather than `NOT`: under SQL's three-valued
# logic a comparison against a NULL column is NULL, and `NOT
# NULL` is NULL, so plain negation drops rows whose column is
# unset — even though an unset column does not match what is
# being excluded. NOT [{"session_id": "abc"}] must include
# documents that have no session, since those are not "abc".
# Composes over compound sub-conditions: (a AND b) IS NOT TRUE.
not_conditions.append(
not_(sub_condition)
sub_condition.is_not(True)
) # Apply NOT to each condition individually
if not_conditions:
conditions.append(and_(*not_conditions)) # Then AND them together
@ -767,7 +774,11 @@ def _build_comparison_conditions(
elif operator == "lt":
condition = column < casted_value
elif operator == "ne":
condition = column != casted_value
# IS DISTINCT FROM, not <>: `NULL <> 'abc'` is NULL, so plain
# inequality drops rows whose column is unset. Identical to <>
# whenever no NULL is involved, and keeps `ne` agreeing with the
# NOT operator instead of quietly returning a different row set.
condition = column.is_distinct_from(casted_value)
elif operator == "in":
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
# Handle wildcard in iterable - if present, matches everything, so no condition needed

View File

@ -1381,3 +1381,81 @@ class TestConclusionRoutes:
# Verify the conclusion has null session_id
conclusion = next(c for c in data["items"] if c["id"] == created_id)
assert conclusion["session_id"] is None
@pytest.mark.asyncio
async def test_negation_includes_conclusions_with_no_session(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Negation must not silently drop workspace-level conclusions.
A conclusion with no session is not "some other session", so excluding
that session has to leave it in the result. Under SQL's three-valued
logic a comparison against NULL is NULL, which would drop the row.
This is only visible by counting returned rows — the filter builds and
executes cleanly either way.
"""
test_workspace, test_peer = sample_data
test_peer2 = models.Peer(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add(test_peer2)
await db_session.flush()
test_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
other_session = models.Session(
name=str(generate_nanoid()), workspace_name=test_workspace.name
)
db_session.add_all([test_session, other_session])
await db_session.commit()
await self._create_collection(
db_session, test_workspace.name, test_peer.name, test_peer2.name
)
scoped = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Scoped to a session",
session_name=test_session.name,
)
workspace_level = models.Document(
workspace_name=test_workspace.name,
observer=test_peer.name,
observed=test_peer2.name,
content="Not scoped to any session",
session_name=None,
)
db_session.add_all([scoped, workspace_level])
await db_session.commit()
def contents(filters: dict[str, object]) -> set[str]:
response = client.post(
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
json={"filters": filters},
)
assert response.status_code == 200, response.text
return {item["content"] for item in response.json()["items"]}
both = {"Scoped to a session", "Not scoped to any session"}
# NOT and ne agree, and both keep the session-less conclusion.
assert contents({"NOT": [{"session_id": other_session.name}]}) == both
assert contents({"session_id": {"ne": other_session.name}}) == both
# Requiring the field to be set is how you narrow to sessioned rows.
assert contents(
{
"AND": [
{"session_id": {"ne": other_session.name}},
{"session_id": {"ne": None}},
]
}
) == {"Scoped to a session"}

View File

@ -129,7 +129,7 @@ def test_ne_string_on_text_column_compares_as_string():
"""Regression: numeric operators float()-cast on every column type, so a
string inequality on a text column was rejected as a bad number."""
stmt = apply_filter(select(Document), Document, {"session_id": {"ne": "abc"}})
assert "session_name !=" in str(stmt)
assert "session_name IS DISTINCT FROM" in str(stmt)
def test_numeric_operator_still_validates_on_numeric_column():
@ -208,6 +208,60 @@ def test_incompatible_operand_types_are_rejected(model: Any, filters: dict[str,
apply_filter(select(model), model, filters)
def _where(model: Any, filters: dict[str, Any]) -> str:
stmt = apply_filter(select(model), model, filters)
return str(stmt.compile(dialect=psycopg_dialect.dialect())).split("WHERE")[-1]
def test_not_is_null_safe():
"""`NOT (col = v)` is NULL when col is NULL, so plain negation drops rows
whose column is unset — even though an unset column is not `v`."""
where = _where(Document, {"NOT": [{"session_id": "abc"}]})
assert "IS NOT true" in where
def test_ne_is_null_safe():
where = _where(Document, {"session_id": {"ne": "abc"}})
assert "IS DISTINCT FROM" in where
def test_not_is_null_safe_over_a_compound_condition():
"""Negation has to survive nesting, not just single comparisons."""
where = _where(
Document, {"NOT": [{"AND": [{"session_id": "a"}, {"level": "explicit"}]}]}
)
assert "IS NOT true" in where
assert "AND" in where
def test_not_is_null_safe_over_contains():
where = _where(Document, {"NOT": [{"session_id": {"contains": "x"}}]})
assert "ILIKE" in where
assert "IS NOT true" in where
def test_ne_null_still_renders_is_not_null():
"""The null operand is intercepted before the operator dispatch, so this
path is unchanged by null-safe `ne`."""
assert "IS NOT NULL" in _where(Document, {"session_id": {"ne": None}})
@pytest.mark.parametrize(
("filters", "expected"),
[
({"session_id": "abc"}, "session_name ="),
({"session_id": {"contains": "x"}}, "ILIKE"),
],
)
def test_positive_predicates_are_unchanged(filters: dict[str, Any], expected: str):
"""A NULL column does not equal or contain anything, so positive predicates
correctly exclude those rows and must keep their plain operators."""
where = _where(Document, filters)
assert expected in where
assert "IS NOT true" not in where
assert "IS DISTINCT FROM" not in where
# --- Invariants over the whole DSL -------------------------------------------
#
# The filter body is arbitrary client JSON. Enumerating bad shapes one at a time