From 876c3b8cd4aa9369b66e1df83f8d6caef9749b0f Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:36:58 +0800 Subject: [PATCH 1/2] fix(local): skip bool payload values in order_by to match server semantics --- qdrant_client/local/order_by.py | 6 +++ qdrant_client/local/tests/test_order_by.py | 60 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 qdrant_client/local/tests/test_order_by.py diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index c695bae85..2dd96abc6 100644 --- a/qdrant_client/local/order_by.py +++ b/qdrant_client/local/order_by.py @@ -15,6 +15,12 @@ def to_order_value(value: str | datetime | OrderValue | None) -> OrderValue | No return None # check if OrderValue + # bool is a subclass of int in Python, but bools are never order values on + # the server: order-by reads exclusively from the numeric index, where bool + # payloads have no entries, and OrderValue::try_from only accepts integers + # and floats. The REST model agrees (OrderValue is StrictInt | StrictFloat). + if isinstance(value, bool): + return None if isinstance(value, (int, float)): return value diff --git a/qdrant_client/local/tests/test_order_by.py b/qdrant_client/local/tests/test_order_by.py new file mode 100644 index 000000000..502f4d667 --- /dev/null +++ b/qdrant_client/local/tests/test_order_by.py @@ -0,0 +1,60 @@ +from qdrant_client.http import models +from qdrant_client.local.order_by import to_order_value +from qdrant_client.local.qdrant_local import QdrantLocal + +COLLECTION_NAME = "test_order_by_bool" + + +def make_client(points: list[models.PointStruct]) -> QdrantLocal: + client = QdrantLocal(":memory:") + client.create_collection(COLLECTION_NAME, vectors_config={}) + client.upsert(COLLECTION_NAME, points=points) + return client + + +def bool_mixed_points() -> list[models.PointStruct]: + return [ + models.PointStruct(id=1, vector={}, payload={"k": True}), + models.PointStruct(id=2, vector={}, payload={"k": 1}), + models.PointStruct(id=3, vector={}, payload={"k": False}), + models.PointStruct(id=4, vector={}, payload={"k": 0}), + ] + + +def test_to_order_value_rejects_bool(): + """`isinstance(True, int)` is True, but a bool payload is never an OrderValue. + + The server reads order-by values exclusively from the numeric index + (`filtered_read_by_index_ordered` / `filtered_read_by_value_stream` via + `numeric_index_for`), where bool payloads have no entries, and + `OrderValue::try_from(Value)` only accepts `as_i64` / `as_f64` (both None + for `Bool`). The REST model agrees: `OrderValue` is `StrictInt | StrictFloat` + and rejects bools at validation. + """ + assert to_order_value(True) is None + assert to_order_value(False) is None + assert to_order_value(1) == 1 + assert to_order_value(0) == 0 + assert to_order_value(1.5) == 1.5 + assert to_order_value(None) is None + + +def test_scroll_order_by_skips_bool_points(): + """Bool points carry no ordering value, so they are left out, not ordered as 0/1. + + Pre-fix this crashes local mode with a pydantic ValidationError, because the + bool order value fails `Record.order_value` (`StrictInt | StrictFloat`) validation. + """ + client = make_client(bool_mixed_points()) + records, _ = client.scroll(COLLECTION_NAME, limit=10, order_by=models.OrderBy(key="k")) + assert [(r.id, r.order_value) for r in records] == [(4, 0), (2, 1)] + + +def test_scroll_order_by_desc_skips_bool_points(): + client = make_client(bool_mixed_points()) + records, _ = client.scroll( + COLLECTION_NAME, + limit=10, + order_by=models.OrderBy(key="k", direction=models.Direction.DESC), + ) + assert [(r.id, r.order_value) for r in records] == [(2, 1), (4, 0)] From c1109628866dd04cadc4cd18d9c140dedf0000f7 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 10 Sep 2026 16:23:33 +0700 Subject: [PATCH 2/2] refactor: move tests to congruence, remove excess comments --- qdrant_client/local/order_by.py | 4 -- qdrant_client/local/tests/test_order_by.py | 60 ------------------- .../congruence_tests/test_scroll_order_by.py | 31 ++++++++++ 3 files changed, 31 insertions(+), 64 deletions(-) delete mode 100644 qdrant_client/local/tests/test_order_by.py diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index 2dd96abc6..00f6b0c0c 100644 --- a/qdrant_client/local/order_by.py +++ b/qdrant_client/local/order_by.py @@ -15,10 +15,6 @@ def to_order_value(value: str | datetime | OrderValue | None) -> OrderValue | No return None # check if OrderValue - # bool is a subclass of int in Python, but bools are never order values on - # the server: order-by reads exclusively from the numeric index, where bool - # payloads have no entries, and OrderValue::try_from only accepts integers - # and floats. The REST model agrees (OrderValue is StrictInt | StrictFloat). if isinstance(value, bool): return None if isinstance(value, (int, float)): diff --git a/qdrant_client/local/tests/test_order_by.py b/qdrant_client/local/tests/test_order_by.py deleted file mode 100644 index 502f4d667..000000000 --- a/qdrant_client/local/tests/test_order_by.py +++ /dev/null @@ -1,60 +0,0 @@ -from qdrant_client.http import models -from qdrant_client.local.order_by import to_order_value -from qdrant_client.local.qdrant_local import QdrantLocal - -COLLECTION_NAME = "test_order_by_bool" - - -def make_client(points: list[models.PointStruct]) -> QdrantLocal: - client = QdrantLocal(":memory:") - client.create_collection(COLLECTION_NAME, vectors_config={}) - client.upsert(COLLECTION_NAME, points=points) - return client - - -def bool_mixed_points() -> list[models.PointStruct]: - return [ - models.PointStruct(id=1, vector={}, payload={"k": True}), - models.PointStruct(id=2, vector={}, payload={"k": 1}), - models.PointStruct(id=3, vector={}, payload={"k": False}), - models.PointStruct(id=4, vector={}, payload={"k": 0}), - ] - - -def test_to_order_value_rejects_bool(): - """`isinstance(True, int)` is True, but a bool payload is never an OrderValue. - - The server reads order-by values exclusively from the numeric index - (`filtered_read_by_index_ordered` / `filtered_read_by_value_stream` via - `numeric_index_for`), where bool payloads have no entries, and - `OrderValue::try_from(Value)` only accepts `as_i64` / `as_f64` (both None - for `Bool`). The REST model agrees: `OrderValue` is `StrictInt | StrictFloat` - and rejects bools at validation. - """ - assert to_order_value(True) is None - assert to_order_value(False) is None - assert to_order_value(1) == 1 - assert to_order_value(0) == 0 - assert to_order_value(1.5) == 1.5 - assert to_order_value(None) is None - - -def test_scroll_order_by_skips_bool_points(): - """Bool points carry no ordering value, so they are left out, not ordered as 0/1. - - Pre-fix this crashes local mode with a pydantic ValidationError, because the - bool order value fails `Record.order_value` (`StrictInt | StrictFloat`) validation. - """ - client = make_client(bool_mixed_points()) - records, _ = client.scroll(COLLECTION_NAME, limit=10, order_by=models.OrderBy(key="k")) - assert [(r.id, r.order_value) for r in records] == [(4, 0), (2, 1)] - - -def test_scroll_order_by_desc_skips_bool_points(): - client = make_client(bool_mixed_points()) - records, _ = client.scroll( - COLLECTION_NAME, - limit=10, - order_by=models.OrderBy(key="k", direction=models.Direction.DESC), - ) - assert [(r.id, r.order_value) for r in records] == [(2, 1), (4, 0)] diff --git a/tests/congruence_tests/test_scroll_order_by.py b/tests/congruence_tests/test_scroll_order_by.py index 5e7c42f80..6dfdb3a39 100644 --- a/tests/congruence_tests/test_scroll_order_by.py +++ b/tests/congruence_tests/test_scroll_order_by.py @@ -145,3 +145,34 @@ def test_scroll_duplicated_values(): ) compare_client_results(grpc_client, http_client, scroll_all_integer_arrays) compare_client_results(local_client, http_client, scroll_all_integer_arrays) + + +def scroll_all_bools_and_ints(client: QdrantBase) -> list[models.Record]: + return scroll_all_with_key(client, "bool_and_int") + + +def test_scroll_bool_values_are_skipped(): + local_client = init_local() + http_client = init_remote() + grpc_client = init_remote(prefer_grpc=True) + + fixture_points = [ + models.PointStruct(id=1, vector=[], payload={"bool_and_int": True}), + models.PointStruct(id=2, vector=[], payload={"bool_and_int": 1}), + models.PointStruct(id=3, vector=[], payload={"bool_and_int": False}), + models.PointStruct(id=4, vector=[], payload={"bool_and_int": 0}), + models.PointStruct(id=5, vector=[], payload={"bool_and_int": [True, 7]}), + ] + init_client(http_client, fixture_points, vectors_config={}) + init_client(local_client, fixture_points, vectors_config={}) + + http_client.create_payload_index( + COLLECTION_NAME, "bool_and_int", models.PayloadSchemaType.INTEGER, wait=True + ) + + # all points are stored, only their ordering values differ + assert len(http_client.scroll(COLLECTION_NAME, limit=10)[0]) == len(fixture_points) + assert len(local_client.scroll(COLLECTION_NAME, limit=10)[0]) == len(fixture_points) + + compare_client_results(grpc_client, http_client, scroll_all_bools_and_ints) + compare_client_results(local_client, http_client, scroll_all_bools_and_ints)