Skip to content

Fix local mode filters cross-matching booleans and integers - #1259

Merged
joein merged 4 commits into
qdrant:devfrom
winklemad:fix/local-filter-bool-int-cross-match
Jul 21, 2026
Merged

joein merged 4 commits into
qdrant:devfrom
winklemad:fix/local-filter-bool-int-cross-match

Conversation

@winklemad

Copy link
Copy Markdown
Contributor

Description

Local mode (QdrantClient(":memory:")) cross-matches booleans and integers in payload filters, because Python treats bool as a subclass of int (True == 1, False == 0) while Qdrant keeps them as distinct payload value types.

With id=1 -> {"n": 1} (int) and id=2 -> {"n": True} (bool):

MatchValue(value=1)     # local: [1, 2]   server: [1]
MatchValue(value=True)  # local: [1, 2]   server: [2]
Range(gte=1, lte=1)     # local: [1, 2]   server: [1]   (bool is not numeric)

MatchAny, MatchExcept and group-by are affected the same way. The server never cross-matches: its ValueVariants enum keeps Integer and Bool distinct, and booleans are not part of numeric range conditions.

Fix

  • Add a small type-aware equality helper values_match(a, b) that returns False when exactly one of the two is a bool, and use it in MatchValue / MatchAny / MatchExcept.
  • Exclude booleans from check_range (an explicit isinstance(value, bool) short-circuits to no match).

Both live in the shared qdrant_client/local/payload_filters.py, so the fix covers sync and async local mode (the async engine reuses the same collection).

Test

Adds test_bool_and_int_filters_do_not_cross_match in tests/test_in_memory.py covering MatchValue (1 / True / 0 / False), MatchAny, and Range — each now returns only the correctly-typed points, matching the server. Verified against dev; the full tests/test_in_memory.py suite passes and ruff format --line-length 99 (0.4.3) is clean.

Python treats bool as a subclass of int (True == 1, False == 0), but Qdrant
keeps booleans and integers as distinct payload value types. Local mode
compared them with a plain `==` / `in` / `isinstance(value, (int, float))`, so:

- MatchValue(value=1) matched a payload of True, and MatchValue(value=True)
  matched a payload of 1 (same for 0 / False)
- MatchAny / MatchExcept cross-matched the same way
- Range matched booleans as if they were 0 / 1

The server never cross-matches these (its ValueVariants keeps Integer and Bool
distinct, and booleans are not numeric for range conditions). Add a type-aware
equality helper used by the value-match conditions, and exclude booleans from
range checks. Adds an in-memory regression test.
@netlify

netlify Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit 95935ba
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a5fc10d43197a0008c99e2a
😎 Deploy Preview https://deploy-preview-1259--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 Jul 10, 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

Run ID: 2877d2e1-8312-429f-b5a0-790706c4b5d1

📥 Commits

Reviewing files that changed from the base of the PR and between a270fb9 and 95935ba.

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

📝 Walkthrough

Walkthrough

Local payload filtering now rejects booleans in range checks and uses type-aware comparisons for exact, Any, and Except matches. Congruence tests create payloads containing boolean, integer, and float values, then compare local and remote scroll results across multiple filter conditions and client modes.

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

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preventing local-mode filter cross-matching between booleans and integers.
Description check ✅ Passed The description is directly about the same filter type-mismatch bug and the intended fix and tests.
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 (1)
tests/test_in_memory.py (1)

325-330: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a MatchExcept assertion.

check_match now routes MatchExcept through values_match, but this test only exercises MatchValue, MatchAny, and Range. A MatchExcept case (e.g. except_=[1] should exclude n=1 but keep n=True) would lock in the boolean/integer separation for that changed branch as well.

💚 Proposed additional assertion
     assert matched_ids(models.FieldCondition(key="n", match=models.MatchAny(any=[1, 0]))) == [1, 3]
+    assert matched_ids(
+        models.FieldCondition(key="n", match=models.MatchExcept(**{"except": [1]}))
+    ) == [2, 3, 4]
     assert matched_ids(models.FieldCondition(key="n", range=models.Range(gte=1, lte=1))) == [1]
🤖 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/test_in_memory.py` around lines 325 - 330, Add a MatchExcept assertion
in the existing in-memory matching test near the other FieldCondition cases,
verifying that except_=[1] excludes the integer value 1 while retaining the
boolean value True, thereby covering the values_match path and boolean/integer
distinction.
🤖 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 `@tests/test_in_memory.py`:
- Around line 325-330: Add a MatchExcept assertion in the existing in-memory
matching test near the other FieldCondition cases, verifying that except_=[1]
excludes the integer value 1 while retaining the boolean value True, thereby
covering the values_match path and boolean/integer distinction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a00b34e5-632d-42d6-beb0-f0a75901dbfa

📥 Commits

Reviewing files that changed from the base of the PR and between b4713cd and b0e3f1e.

📒 Files selected for processing (2)
  • qdrant_client/local/payload_filters.py
  • tests/test_in_memory.py

MatchExcept also routes through values_match, so assert that except=[1]
keeps the True payload (bool is not the integer 1).

@eeshsaxena eeshsaxena 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.

This looks correct and the root cause is right - bool subclassing int (so True == 1 and True in [1]) is exactly what makes the old value == condition.value cross-match. I reproduced all the affected paths on main:

check_match(MatchValue(value=1), True)   -> True   # should be False
check_match(MatchValue(value=True), 1)   -> True   # should be False
check_match(MatchAny(any=[1, 2]), True)  -> True   # should be False
check_range(Range(gt=0), True)           -> True   # should be False

and confirmed the real matches still pass (MatchValue(1) vs 1, MatchValue(True) vs True).

A couple of things I checked that are worth noting for confidence:

  • Array payloads are covered. check_condition flattens list-valued payloads and calls check_match(condition.match, v) per element (line 199), so routing everything through values_match also fixes the array case ([True] no longer matching MatchValue(1)), not just scalar payloads.
  • Range via the interface wrapper is covered too, since check_range_interface delegates to the patched check_range.
  • MatchExcept flips correctly: not any(values_match(...)) means a True payload is no longer wrongly excluded by an except_=[1].

One small suggestion: the added test covers MatchValue, but since the same values_match helper now backs MatchAny/MatchExcept and the separate check_range guard, a couple of extra asserts would lock in those paths against regressions - e.g. MatchAny(any=[1]) vs True, MatchExcept(except_=[1]) vs True (should keep the point), and a Range vs True. Entirely optional.

Nice, well-scoped fix. Deferring to the maintainers.

Lock the single-value MatchAny path and the check_range bool guard against
regressions, in addition to the existing combined-condition coverage.
@winklemad

Copy link
Copy Markdown
Contributor Author

Thanks @eeshsaxena — appreciate the careful review and reproducing the paths. Added the isolated asserts in a270fb9: MatchAny(any=[1]) vs the True payload now returns only the int point, and Range(gt=0) — which True would satisfy numerically if bools weren't excluded — returns only the int point too, so the single-value MatchAny path and the check_range guard are each locked in. MatchExcept(except=[1]) keeping the True point was already asserted. Full tests/test_in_memory.py passes and ruff 0.4.3 is clean.

@joein

joein commented Jul 21, 2026

Copy link
Copy Markdown
Member

Hey @winklemad

Thanks for pointing it out and fixing it!

Thank you @eeshsaxena for the review 👍

I'll take a look at it

One problem is that we need to compare local mode implementation against the remote mode.
For that we have congruence_tests, and the test should be added there instead of test in memory.

I'll address it

@joein
joein self-requested a review July 21, 2026 19:17
@joein
joein merged commit 532997a into qdrant:dev Jul 21, 2026
8 checks passed
joein added a commit that referenced this pull request Aug 4, 2026
* Fix local mode filters cross-matching booleans and integers

Python treats bool as a subclass of int (True == 1, False == 0), but Qdrant
keeps booleans and integers as distinct payload value types. Local mode
compared them with a plain `==` / `in` / `isinstance(value, (int, float))`, so:

- MatchValue(value=1) matched a payload of True, and MatchValue(value=True)
  matched a payload of 1 (same for 0 / False)
- MatchAny / MatchExcept cross-matched the same way
- Range matched booleans as if they were 0 / 1

The server never cross-matches these (its ValueVariants keeps Integer and Bool
distinct, and booleans are not numeric for range conditions). Add a type-aware
equality helper used by the value-match conditions, and exclude booleans from
range checks. Adds an in-memory regression test.

* Cover MatchExcept in the bool/int cross-match test

MatchExcept also routes through values_match, so assert that except=[1]
keeps the True payload (bool is not the integer 1).

* Add isolated MatchAny and range asserts to the bool/int cross-match test

Lock the single-value MatchAny path and the check_range bool guard against
regressions, in addition to the existing combined-condition coverage.

* fix: handle floats in cross-match local mode filters, add congruence tests

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
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.

3 participants