Skip to content

fix: support FieldCondition.is_empty/is_null in local mode - #1308

Open
sainikhiljuluri wants to merge 2 commits into
qdrant:devfrom
sainikhiljuluri:fix/local-mode-field-condition-is-empty-is-null
Open

fix: support FieldCondition.is_empty/is_null in local mode#1308
sainikhiljuluri wants to merge 2 commits into
qdrant:devfrom
sainikhiljuluri:fix/local-mode-field-condition-is-empty-is-null

Conversation

@sainikhiljuluri

Copy link
Copy Markdown

All Submissions:

  • Contributions should target the dev branch. Did you create your branch from dev?
  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?

Changes to Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

The problem

FieldCondition carries is_empty and is_null — the shorthand syntax for
IsEmptyCondition / IsNullCondition, described in the generated models as
"alternative syntax for is_empty: 'field_name'". In local mode they are ignored
silently, and the failure is total rather than partial:

client = QdrantClient(":memory:")
# ... 5 points, only some of which have a "reports" value

flt = models.Filter(must=[models.FieldCondition(key="reports", is_empty=True)])
client.scroll("c", scroll_filter=flt)      # -> []  (server: the 3 empty ones)

flt = models.Filter(must_not=[models.FieldCondition(key="reports", is_empty=True)])
client.scroll("c", scroll_filter=flt)      # -> every point in the collection

So must matches nothing and must_not matches everything — with no warning and
no NotImplementedError. It reaches scroll, count, query_points, facet
and delete(filter=...), which means a delete guarded by must_not=[...] can
clear a whole local collection while doing almost nothing against a server.

Root cause

check_condition()'s FieldCondition branch only ever inspected match,
range, geo_bounding_box, geo_radius, values_count and geo_polygon. A
condition carrying is_empty=/is_null= matches none of those ifs, falls out
of the elif body and hits the trailing return False at the end of the
function — evaluating to False for every point.

The fix

Two small helpers plus a branch in check_condition:

  • a value is empty when it is null or an empty array; a key holding no
    value counts as empty but not null
  • a value is null when it is null or an array containing a null
  • for a key resolving to several values (a[].b), any one of them satisfying the
    condition is a match — so one point can satisfy both is_empty=True and
    is_empty=False

A condition that also carries values_count is left to the existing
values_count branch, so nothing about its behaviour changes.

Values are read with flat=False, like the two neighbouring IsEmptyCondition
and IsNullCondition branches. That is load-bearing rather than stylistic:
{"field": []} flattens to None, which would otherwise collapse the
empty-array case into the no-value case.

How this was tested

The semantics above were established by running the queries against a real
qdrant/qdrant:dev (1.18.3-dev) container, not by reading core
— an earlier
revision of this PR was written from the Rust source and got the values_count
interaction wrong in a way only the live server revealed.

Risk and compatibility

No public API, signature, default or return-shape change — this is local-mode
internals, and the async local client picks it up unchanged. The only behaviour
that changes is for conditions that are currently broken. In principle someone
could be relying on must_not=[FieldCondition(is_empty=True)] matching
everything, but that is the bug rather than a contract.

Two things worth your judgement

1. Payload indexes. Local mode does not model them (it warns as much), so
where the server's answer depends on whether a field is indexed, local mode can
only mirror one path. This PR mirrors the unindexed payload-scan path, which is
what the surrounding local-mode branches already do and what the congruence
fixtures exercise.

That matters in one visible place: on an unindexed field the server does not
treat is_null and IsNullCondition as interchangeable — the verbose condition
tests the values a key resolves to, so an array holding a null is not itself
null, while the shorthand looks inside it:

payload {"field": [None, 1]}
  FieldCondition(key="field", is_null=True)   -> matches
  IsNullCondition(key="field")                -> does not match

With a keyword index on the field, both match. is_empty and
IsEmptyCondition agree either way. Since the models call the two forms
alternative syntax, this looks like it may be a core inconsistency rather than
intended — happy to flip the client whenever core settles it, and the congruence
test means you would hear about it either way.

2. Deliberately out of scope. check_condition evaluates match / range /
geo before values_count, whereas the server prefers values_count. That
predates this PR and is unchanged by it, so I have not touched it. Glad to send a
separate PR if you want it fixed.


Found by comparing local mode against server behaviour rather than from a
reported issue, so there is no linked issue. AI-assisted: an agent wrote the
patch and tests, and every behavioural claim here was verified against a running
Qdrant container.

`FieldCondition` carries `is_empty` and `is_null` as the shorthand syntax for
`IsEmptyCondition` / `IsNullCondition`, and both are wired up for REST and gRPC.
Local mode's `check_condition()` never inspected them, so a `FieldCondition`
carrying `is_empty=`/`is_null=` matched none of the `if` branches and fell
through to the trailing `return False`.

The condition was therefore False for every point, silently: `must` matched
nothing and `must_not` matched everything, with no warning and no
`NotImplementedError`. That affects scroll, count, query_points, facet and
delete(filter=...).

Behaviour was established by running the queries against qdrant/qdrant:dev
rather than by reading core, and is pinned by a congruence test:

- a value is empty when it is null or an empty array; a key holding no value
  counts as empty but not null
- a value is null when it is null or an array containing a null
- for a key resolving to several values, any one of them satisfying the
  condition is a match, so one point can satisfy both `is_empty=True` and
  `is_empty=False`

A condition that also carries `values_count` is left to the existing
`values_count` branch, so its behaviour is unchanged.

Note that on a field without a payload index the server does not treat
`is_null` and `IsNullCondition` as interchangeable, even though the generated
models describe them as alternative syntax: the verbose condition tests the
values a key resolves to, so an array holding a null is not itself null, while
the shorthand looks inside it. Local mode does not model payload indexes, so it
mirrors the unindexed behaviour here, as the surrounding branches already do.

Values are extracted with `flat=False`, like the neighbouring
`IsEmptyCondition`/`IsNullCondition` branches. It is load-bearing:
`{"field": []}` flattens to `None`, which would otherwise collapse the
empty-array case into the no-value case.
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit cfc6b95
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a7002b84ce23800086a596d
😎 Deploy Preview https://deploy-preview-1308--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 16236e03-1a6f-4101-a4a2-70183998e554

📥 Commits

Reviewing files that changed from the base of the PR and between a2b7489 and cfc6b95.

📒 Files selected for processing (1)
  • tests/congruence_tests/test_is_empty_is_null.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/congruence_tests/test_is_empty_is_null.py

📝 Walkthrough

Walkthrough

The change adds local evaluation for FieldCondition.is_empty and is_null. It handles missing keys, nested values, lists, and values_count precedence. It adds unit tests and local/remote congruence tests for shorthand and verbose conditions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • qdrant/qdrant-client#1303: Modifies FieldCondition handling and the values_count branch in qdrant_client/local/payload_filters.py.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the added local-mode support for FieldCondition.is_empty and FieldCondition.is_null.
Description check ✅ Passed The description directly explains the bug, implementation, tests, compatibility impact, and scope of the changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
qdrant_client/local/payload_filters.py (1)

246-258: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the is_empty/is_null evaluation into a helper function.

This block mixes precedence gating, missing-key handling, and per-value evaluation inside check_condition. Extract it into a dedicated helper that returns bool | None (None meaning "fall through to match/range/values_count handling"). This keeps check_condition flatter and easier to follow.

♻️ Proposed refactor
+def evaluate_is_empty_is_null(
+    condition: models.FieldCondition, payload: dict[str, Any]
+) -> bool | None:
+    """Evaluate `is_empty` / `is_null` for a FieldCondition. Returns `None` when the
+    caller should fall through to match/range/values_count handling instead."""
+    if condition.values_count is not None:
+        return None
+    if condition.is_empty is None and condition.is_null is None:
+        return None
+    raw_values = value_by_key(payload, condition.key, flat=False)
+    if not raw_values:
+        # nothing stored under the key: the server counts that as empty, not null
+        if condition.is_empty is not None:
+            return condition.is_empty
+        return not condition.is_null
+    if condition.is_empty is not None:
+        return any(check_is_empty_value(condition.is_empty, v) for v in raw_values)
+    return any(check_is_null_value(condition.is_null, v) for v in raw_values)
+
+
 elif isinstance(condition, models.FieldCondition):
-        if condition.values_count is None and (
-            condition.is_empty is not None or condition.is_null is not None
-        ):
-            # values_count keeps its own branch below, this must not shadow it
-            raw_values = value_by_key(payload, condition.key, flat=False)
-            if not raw_values:
-                # nothing stored under the key: the server counts that as empty, not null
-                if condition.is_empty is not None:
-                    return condition.is_empty
-                return not condition.is_null
-            if condition.is_empty is not None:
-                return any(check_is_empty_value(condition.is_empty, v) for v in raw_values)
-            return any(check_is_null_value(condition.is_null, v) for v in raw_values)
+        empty_or_null = evaluate_is_empty_is_null(condition, payload)
+        if empty_or_null is not None:
+            return empty_or_null
         values = value_by_key(payload, condition.key)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qdrant_client/local/payload_filters.py` around lines 246 - 258, Extract the
is_empty/is_null branch from check_condition into a dedicated helper returning
bool | None, with None when condition.values_count is set or neither predicate
applies. Move the existing missing-key behavior and per-value checks unchanged
into that helper, then have check_condition delegate to it and return
immediately only for non-None results.

Source: Linters/SAST tools

tests/congruence_tests/test_is_empty_is_null.py (1)

75-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider exercising more than scroll in the congruence loop.

The PR objective states this bug affected scroll, count, query, facet, and delete, but this test only compares scroll results. All these operations share the same check_filter/check_condition logic, so scroll coverage substantially validates the fix. Add at least one more operation (for example count) to the comparison loop to directly confirm parity for the other operations named in the PR objective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/congruence_tests/test_is_empty_is_null.py` around lines 75 - 85, Extend
the congruence loop in the test around compare_client_results to exercise at
least one additional filter operation, such as count, alongside scroll. Reuse
the same local/remote comparison pattern and each filter so parity is directly
validated for another operation sharing the filter-checking logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@qdrant_client/local/payload_filters.py`:
- Around line 246-258: Extract the is_empty/is_null branch from check_condition
into a dedicated helper returning bool | None, with None when
condition.values_count is set or neither predicate applies. Move the existing
missing-key behavior and per-value checks unchanged into that helper, then have
check_condition delegate to it and return immediately only for non-None results.

In `@tests/congruence_tests/test_is_empty_is_null.py`:
- Around line 75-85: Extend the congruence loop in the test around
compare_client_results to exercise at least one additional filter operation,
such as count, alongside scroll. Reuse the same local/remote comparison pattern
and each filter so parity is directly validated for another operation sharing
the filter-checking logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d44ce851-e244-4c59-814d-28495a49bc40

📥 Commits

Reviewing files that changed from the base of the PR and between 399449a and a2b7489.

📒 Files selected for processing (3)
  • qdrant_client/local/payload_filters.py
  • qdrant_client/local/tests/test_payload_filters.py
  • tests/congruence_tests/test_is_empty_is_null.py

The conditions reach every filtered operation, so compare a second one that
shares the filter path rather than only scroll.
@sainikhiljuluri

Copy link
Copy Markdown
Author

Thanks for the review. Took one of the two:

Compare more than scroll — done in cfc6b95. The loop now compares count() alongside scroll() for all 19 filters, so the parity claim covers a second operation on the same filter path rather than resting on scroll alone. Verified green against qdrant/qdrant:dev (1.18.3-dev).

Extract the is_empty/is_null evaluation into a helper — skipping this one. check_condition deliberately handles every condition type inline in one if/elif chain, and the neighbouring IsEmptyCondition, IsNullCondition, HasIdCondition and NestedCondition branches are all written that way. Pulling just this branch out into a bool | None-returning helper would make it the odd one out, and the tri-state return is harder to follow than the guard it would replace. Happy to do it if a maintainer prefers the extracted shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant