Skip to content

Commit 3687587

Browse files
earayuclaude
andcommitted
feat(vectorstore): task #61 P1-V vector adapter family — capability + filter Or guard + retrieve defense-in-depth
Closes task #83 per PM @不穷 dispatch (msg=29c9e753). Folds 4 P1-V items from task #61 spec v1 § 2.3 into a single PR: P1-V1 — collection init failure contract documentation ------------------------------------------------------ ``ensure_collection`` Protocol docstring now spells out the cross- adapter contract (idempotent / race-safe / fail-loud / cache-not- poisoned-on-failure). Both adapters already implement these behaviours; the documentation closes the spec drift gap so future implementers have a checklist. P1-V2 — batch upsert atomicity capability declaration ----------------------------------------------------- New :class:`VectorBackendCapabilities` frozen dataclass on the base module declares static per-backend behaviour flags. Each ``VectorStoreConnector`` subclass exposes an instance via the ``BACKEND_CAPABILITIES`` class-level attribute: * ``PgvectorVectorStoreConnector.BACKEND_CAPABILITIES.supports_atomic_batch_upsert = True`` (PGVector wraps bulk INSERT ON CONFLICT in ``engine.begin()`` — mid-batch failure rolls back the whole batch). * ``QdrantVectorStoreConnector.BACKEND_CAPABILITIES.supports_atomic_batch_upsert = False`` (Qdrant ``client.upsert(points, wait=True)`` is best-effort per-point — partial writes possible on mid-batch failure). ``upsert`` Protocol docstring now points at the capability flag so callers know to chunk + verify on backends that declare ``False``. P1-V3 — filter Or empty-parts guard ----------------------------------- ``Or.__post_init__`` already rejects empty ``parts`` at DSL construction. Both adapter translators now also guard at the translator boundary so a future refactor that bypasses the constructor (e.g. ``object.__setattr__(or_node, "parts", ())`` on the frozen dataclass, or a ``dataclasses.replace`` with empty parts) can't silently degrade to a vacuous "match everything" disjunction: * ``aperag/vectorstore/pgvector_connector.py:_SqlFilter._walk`` — raises ``UnsupportedFilterError`` on empty post-walk parts. * ``aperag/vectorstore/qdrant_connector.py:_translate_filter`` — raises ``UnsupportedFilterError`` on empty post-prune subs (so ``rest.Filter(should=[])`` — which Qdrant treats as match-all — is unreachable). P1-V4 — Qdrant legacy mode defense-in-depth ------------------------------------------- ``QdrantVectorStoreConnector.retrieve`` now applies the same ``TENANT_PAYLOAD_KEY`` filter in **both** multitenant and legacy modes, but with a backwards-compatible "no payload key → pass through" branch so legacy-only rows that don't carry the payload key keep working: * In multitenant mode: filter is the primary tenant-isolation layer (unchanged behaviour). * In legacy mode: collection-name isolation is the primary layer; the new payload-level filter is belt-and-braces against tooling drift / migration mistakes that could plant a stray foreign-tenant row in a legacy collection. The new ``BACKEND_CAPABILITIES.supports_legacy_mode`` flag declares which adapter supports the legacy layout (PGVector ``False``, Qdrant ``True``) so callers can tell the difference machine- readably. Tests ----- * ``tests/unit_test/vectorstore/test_backend_capabilities.py`` (new) — pins shape + per-flag values for each adapter. Coordinates with cuiwenbo task #87 P1-D3 collection metadata Pydantic projection so the static capability matrix stays consistent across PRs. * ``tests/unit_test/vectorstore/test_pgvector_translator.py`` and ``test_qdrant_filter_translation.py`` — pin the new Or empty-parts guard with frozen-dataclass-bypass coverage. * ``tests/unit_test/vectorstore/test_qdrant_multitenancy_integration.py`` — new ``test_retrieve_legacy_mode_filters_stray_foreign_payload`` exercises the P1-V4 belt-and-braces filter on a real ``:memory:`` Qdrant client: legacy-mode rows without payload key pass through (backward compat), own-tenant payload passes, foreign-tenant payload is dropped. Local: ``uv run pytest tests/unit_test/vectorstore/`` → **156 passed, 10 skipped, 1 warning**. Spec / scope alignment ---------------------- * task #61 spec v1 § 2.3 P1-V1 → ensure_collection contract doc ✅ * task #61 spec v1 § 2.3 P1-V2 → BACKEND_CAPABILITIES.supports_atomic_batch_upsert ✅ * task #61 spec v1 § 2.3 P1-V3 → Or empty-parts guard ✅ * task #61 spec v1 § 2.3 P1-V4 → retrieve defense-in-depth + supports_legacy_mode ✅ * Lesson #14 multi-iteration cleanup — legacy mode flagged via ``supports_legacy_mode`` so a future PR can drop the mode entirely once telemetry confirms zero production usage ✅ * Lesson #17 backend 收敛 contract — capability declaration is the backend-side contract that lets callers (FE / API / MCP) read a single source of truth instead of forking on backend type ✅ Follow-ups (NOT in this PR) --------------------------- * task #84 P1-G1+G2 graph store boundary tests — ziang * task #85 P1-D1 e2e shape matrix — huangzhangshu * task #86 P1-D2 Helm Nebula first-class — Planetegg * task #87 P1-D3 collection metadata vector_backend projection — cuiwenbo + dongdong (consumes ``BACKEND_CAPABILITIES`` values) * task #88 P2-S1+S2 batch alias resolution — Bryce after this PR Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 52fb68e commit 3687587

7 files changed

Lines changed: 359 additions & 11 deletions

File tree

aperag/vectorstore/base.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646

4747
import math
4848
from abc import ABC, abstractmethod
49-
from typing import Any, Dict, List, Sequence
49+
from dataclasses import dataclass
50+
from typing import Any, ClassVar, Dict, List, Sequence
5051

5152
from aperag.vectorstore.dto import (
5253
QueryRequest,
@@ -185,6 +186,48 @@ def denormalize_threshold_to_native(distance: str, normalized: float) -> float:
185186
raise ValueError(f"Unknown distance metric: {distance!r}")
186187

187188

189+
@dataclass(frozen=True)
190+
class VectorBackendCapabilities:
191+
"""Static capability flags for a vector store backend (task #61 P1-V2 / P1-V3 / P1-V4).
192+
193+
Per task-61 spec v1 § 2.3 「允许差异但显式 declaration」: not every
194+
backend supports every operation atomically / identically, so
195+
callers (FE / API / MCP / capability-aware optimizers) need a
196+
machine-readable declaration of what each adapter is actually
197+
capable of, instead of guessing from the backend name.
198+
199+
These are **static** declarations — they describe what the backend
200+
*can* do, independent of any runtime probe / fallback logic. A
201+
runtime degradation surface (e.g. "PG connection pool exhausted →
202+
graph search degraded to fulltext-only") is a separate concern and
203+
intentionally NOT covered here (see architect msg=3163bb4b).
204+
205+
Each adapter exposes its capabilities via the
206+
:attr:`VectorStoreConnector.BACKEND_CAPABILITIES` class-level
207+
attribute. Callers (e.g. cuiwenbo task #87 P1-D3 collection
208+
metadata Pydantic projection) read this static declaration and
209+
surface it on the API.
210+
"""
211+
212+
#: P1-V2 — does ``upsert(points)`` apply the entire batch
213+
#: atomically? PGVector wraps the INSERT ON CONFLICT in a
214+
#: ``engine.begin()`` transaction so a mid-batch failure rolls back
215+
#: the whole batch (``True``). Qdrant's ``client.upsert(points,
216+
#: wait=True)`` is best-effort per-point — a mid-batch failure can
217+
#: leave some points written and others not (``False``). Callers
218+
#: that need atomic semantics must chunk + verify on Qdrant; on
219+
#: PGVector the semantics come for free.
220+
supports_atomic_batch_upsert: bool
221+
222+
#: P1-V4 — does the backend support a "legacy" non-multitenant
223+
#: physical layout (one collection per tenant, no payload-level
224+
#: tenant filter)? Qdrant supports both legacy and multitenant
225+
#: modes (``True``); PGVector is multitenant-only (``False``).
226+
#: Legacy mode is preserved for migration rollback compatibility
227+
#: only — new collections always use the multitenant layout.
228+
supports_legacy_mode: bool
229+
230+
188231
class VectorStoreConnector(ABC):
189232
"""Abstract contract for per-tenant vector storage.
190233
@@ -194,6 +237,11 @@ class VectorStoreConnector(ABC):
194237
rest; unknown keys must never be a hard error.
195238
"""
196239

240+
#: Static capability flags for this backend (task #61 P1-V2/V3/V4).
241+
#: Each concrete subclass overrides with its actual values; see
242+
#: :class:`VectorBackendCapabilities` for the per-flag contract.
243+
BACKEND_CAPABILITIES: ClassVar[VectorBackendCapabilities]
244+
197245
def __init__(self, ctx: Dict[str, Any], **_kwargs: Any) -> None:
198246
self.ctx = ctx
199247

@@ -214,9 +262,22 @@ def ensure_collection(self) -> None:
214262
"""Idempotently make sure the physical storage (Qdrant collection,
215263
pgvector table, …) exists for this connector's shape.
216264
217-
Must be safe to call from many connectors concurrently: typical
218-
implementations use ``CREATE IF NOT EXISTS`` / ``collection_exists
219-
? no-op : create`` with module-level deduping caches.
265+
Cross-adapter contract (task #61 P1-V1):
266+
267+
* **Idempotent** — repeat calls after first success are a no-op,
268+
gated through the per-process ``_ENSURED_*`` cache.
269+
* **Race-safe** — concurrent calls from multiple processes /
270+
connectors must not all fail when the underlying CREATE
271+
collides. PGVector relies on ``CREATE IF NOT EXISTS``; Qdrant
272+
treats "already exists" responses on ``create_collection`` as
273+
success.
274+
* **Fail-loud** — any other failure (missing privilege, bad
275+
config, transient DB outage) raises so the caller sees the
276+
error rather than silently leaving an unusable connector.
277+
* **Cache-not-poisoned-on-failure** — failed runs MUST NOT
278+
populate the ``_ENSURED_*`` cache; the next call retries.
279+
Otherwise a transient failure during boot would wedge the
280+
connector for the rest of the process lifetime.
220281
"""
221282

222283
@abstractmethod
@@ -242,8 +303,22 @@ def upsert(self, points: Sequence[VectorPoint]) -> List[str]:
242303
243304
Must inject the tenant guard into each point's storage so later
244305
searches / deletes can filter by it. Returns the ids written (in
245-
input order). Raises on write failure — callers treat the batch
246-
as atomic per-point.
306+
input order). Raises on write failure.
307+
308+
**Batch atomicity** is **backend-specific** and declared on
309+
:attr:`BACKEND_CAPABILITIES.supports_atomic_batch_upsert`
310+
(task #61 P1-V2):
311+
312+
* PGVector wraps the bulk INSERT ON CONFLICT in
313+
``engine.begin()`` so a mid-batch failure rolls back the
314+
entire batch (``supports_atomic_batch_upsert=True``).
315+
* Qdrant's ``client.upsert(points, wait=True)`` is best-effort
316+
per-point — a mid-batch failure can leave a partial write
317+
(``supports_atomic_batch_upsert=False``).
318+
319+
Callers that need atomic-batch semantics must read the
320+
capability flag and chunk + verify when ``False``; on
321+
``True``-declaring backends the semantics come for free.
247322
"""
248323

249324
@abstractmethod

aperag/vectorstore/pgvector_connector.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373

7474
from aperag.vectorstore.base import (
7575
UnsupportedFilterError,
76+
VectorBackendCapabilities,
7677
VectorStoreConnector,
7778
denormalize_threshold_to_native,
7879
normalize_score,
@@ -254,6 +255,21 @@ def _walk(self, flt: VectorFilter) -> str:
254255
return "(" + " AND ".join(parts) + ")"
255256
if isinstance(flt, Or):
256257
parts = [self._walk(p) for p in flt.parts]
258+
# task #61 P1-V3 defense-in-depth: ``Or.__post_init__``
259+
# already rejects empty ``parts`` at DSL construction so
260+
# this list is normally non-empty. The translator-level
261+
# guard catches future refactors that bypass the DSL
262+
# constructor (e.g. ``dataclasses.replace(or_node, parts=())``)
263+
# before they reach pgvector. An empty Or in SQL would
264+
# collapse to ``()`` which is a syntax error — but in
265+
# principle could degrade to a vacuous "always true" via
266+
# some future translator change. Symmetric with the Qdrant
267+
# ``Or`` translator guard for cross-adapter parity.
268+
if not parts:
269+
raise UnsupportedFilterError(
270+
"pgvector: Or filter has zero translatable parts; "
271+
"an empty Or is a vacuous disjunction (task #61 P1-V3)."
272+
)
257273
return "(" + " OR ".join(parts) + ")"
258274
if isinstance(flt, Not):
259275
return f"NOT ({self._walk(flt.inner)})"
@@ -325,6 +341,17 @@ def _vector_literal(vec: Sequence[float]) -> str:
325341
class PgvectorVectorStoreConnector(VectorStoreConnector):
326342
"""pgvector implementation of ``VectorStoreConnector``."""
327343

344+
#: Static capability declaration (task #61 P1-V2 / P1-V4).
345+
#: ``upsert`` wraps the bulk INSERT ON CONFLICT in a SQLAlchemy
346+
#: ``engine.begin()`` transaction so a mid-batch failure rolls back
347+
#: the whole batch — atomic. Legacy mode is not supported on
348+
#: pgvector (would require one table per tenant; defeats the point
349+
#: of sharing PG with the main ApeRAG DB).
350+
BACKEND_CAPABILITIES = VectorBackendCapabilities(
351+
supports_atomic_batch_upsert=True,
352+
supports_legacy_mode=False,
353+
)
354+
328355
def __init__(self, ctx: Dict[str, Any], **kwargs: Any) -> None:
329356
super().__init__(ctx, **kwargs)
330357

aperag/vectorstore/qdrant_connector.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151

5252
from aperag.vectorstore.base import (
5353
UnsupportedFilterError,
54+
VectorBackendCapabilities,
5455
VectorStoreConnector,
5556
denormalize_threshold_to_native,
5657
normalize_score,
@@ -271,6 +272,21 @@ def _translate_filter(flt: Optional[VectorFilter]) -> Optional[rest.Filter]:
271272
if isinstance(flt, Or):
272273
subs = [_translate_filter(p) for p in flt.parts]
273274
subs = [s for s in subs if s is not None]
275+
# task #61 P1-V3 defense-in-depth: ``Or.__post_init__`` already
276+
# rejects empty ``parts`` at DSL construction, so this list is
277+
# normally non-empty. The translator-level guard catches future
278+
# refactors that bypass the DSL constructor (e.g.
279+
# ``dataclasses.replace(or_node, parts=())``) before they reach
280+
# Qdrant. Without this, ``rest.Filter(should=[])`` is a vacuous
281+
# disjunction that Qdrant treats as "match everything" — a
282+
# silent data-correctness footgun. Cross-adapter parity with
283+
# the pgvector ``_SqlFilter._walk`` Or-empty guard.
284+
if not subs:
285+
raise UnsupportedFilterError(
286+
"qdrant: Or filter has zero translatable parts after pruning; "
287+
"an empty Or is a vacuous disjunction that would match every "
288+
"point in the collection (task #61 P1-V3)."
289+
)
274290
return rest.Filter(should=subs)
275291
if isinstance(flt, Not):
276292
sub = _translate_filter(flt.inner)
@@ -416,6 +432,18 @@ def _extract_vector(p: Any) -> Optional[List[float]]:
416432
class QdrantVectorStoreConnector(VectorStoreConnector):
417433
"""Qdrant implementation of ``VectorStoreConnector``."""
418434

435+
#: Static capability declaration (task #61 P1-V2 / P1-V4).
436+
#: Qdrant's ``client.upsert(points, wait=True)`` is best-effort
437+
#: per-point — a mid-batch failure can leave a partial write — so
438+
#: ``supports_atomic_batch_upsert=False``. The legacy
439+
#: (``multitenant=False``) layout is supported, where each ApeRAG
440+
#: collection gets its own physical Qdrant collection; new
441+
#: deployments default to multitenant.
442+
BACKEND_CAPABILITIES = VectorBackendCapabilities(
443+
supports_atomic_batch_upsert=False,
444+
supports_legacy_mode=True,
445+
)
446+
419447
def __init__(self, ctx: Dict[str, Any], **kwargs: Any) -> None:
420448
super().__init__(ctx, **kwargs)
421449

@@ -713,11 +741,27 @@ def retrieve(
713741
)
714742
for p in raw
715743
]
716-
# Represent "no vector requested" as empty list rather than None
717-
# to keep VectorPoint.__post_init__ happy (vector must be list).
718-
if not self.multitenant:
719-
return out
720-
return [p for p in out if p.payload.get(TENANT_PAYLOAD_KEY) == self._tenant.id]
744+
# task #61 P1-V4 defense-in-depth: apply the same payload-level
745+
# tenant filter to BOTH multitenant and legacy modes. In
746+
# multitenant mode the filter is the primary tenant-isolation
747+
# layer (the physical collection is shared). In legacy mode
748+
# the primary isolation is the per-tenant physical collection
749+
# name (``collection_name == tenant_id``) — this connector is
750+
# already bound to a tenant-specific physical collection, so
751+
# foreign-tenant ids cannot return cross-tenant data — but the
752+
# ``TENANT_PAYLOAD_KEY in payload`` short-circuit means legacy
753+
# rows that don't carry the payload key still pass through
754+
# unchanged, while a stray multitenant-style row that ended up
755+
# in a legacy collection (e.g. via tooling drift) would be
756+
# filtered out. Lesson #14 multi-iteration cleanup family —
757+
# legacy mode is preserved for migration rollback only; a
758+
# future PR can drop the mode entirely once telemetry confirms
759+
# zero production usage.
760+
return [
761+
p
762+
for p in out
763+
if TENANT_PAYLOAD_KEY not in p.payload or p.payload.get(TENANT_PAYLOAD_KEY) == self._tenant.id
764+
]
721765

722766
# ================================================================ delete
723767
def delete(self, ids: Sequence[str]) -> None:
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2026 ApeCloud, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Static capability declaration tests (task #61 P1-V2 / P1-V4).
16+
17+
Each :class:`VectorStoreConnector` subclass declares its
18+
``BACKEND_CAPABILITIES`` class-level attribute so callers
19+
(API / FE / capability-aware optimizers) can read a machine-readable
20+
declaration of what the adapter actually does — instead of guessing
21+
from the backend name.
22+
23+
These tests pin the static values so:
24+
25+
1. A future refactor that drops a flag declaration on a concrete
26+
subclass fails fast (e.g. removing ``BACKEND_CAPABILITIES`` from
27+
:class:`PgvectorVectorStoreConnector` makes the flag undefined).
28+
2. The cross-adapter capability matrix surfaces in code review as a
29+
single test file — consumers (cuiwenbo task #87 P1-D3 collection
30+
metadata Pydantic projection) read these values verbatim, so any
31+
change to the behaviour they describe must update both the adapter
32+
docstring + this test in the same PR.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
from aperag.vectorstore.base import VectorBackendCapabilities, VectorStoreConnector
38+
from aperag.vectorstore.pgvector_connector import PgvectorVectorStoreConnector
39+
from aperag.vectorstore.qdrant_connector import QdrantVectorStoreConnector
40+
41+
# ---------------------------------------------------------------------
42+
# Shape — ensure both adapters declare the attribute and it's the
43+
# right type.
44+
# ---------------------------------------------------------------------
45+
46+
47+
def test_pgvector_declares_backend_capabilities():
48+
caps = PgvectorVectorStoreConnector.BACKEND_CAPABILITIES
49+
assert isinstance(caps, VectorBackendCapabilities)
50+
51+
52+
def test_qdrant_declares_backend_capabilities():
53+
caps = QdrantVectorStoreConnector.BACKEND_CAPABILITIES
54+
assert isinstance(caps, VectorBackendCapabilities)
55+
56+
57+
def test_abstract_base_does_not_set_concrete_capabilities():
58+
""":class:`VectorStoreConnector` is abstract — only concrete
59+
subclasses declare a value. Keeping the base class assignment
60+
absent means a future subclass that forgets to declare gets a
61+
``AttributeError`` at the call site, not a silent default."""
62+
# ``BACKEND_CAPABILITIES`` is a ``ClassVar`` annotation on the base
63+
# class without a value, so it doesn't actually exist on the base.
64+
assert "BACKEND_CAPABILITIES" not in VectorStoreConnector.__dict__
65+
66+
67+
# ---------------------------------------------------------------------
68+
# Capability matrix values — pinned by spec § 2.3 + task #83 P1-V*
69+
# implementation. cuiwenbo task #87 P1-D3 reads these values for the
70+
# collection metadata Pydantic projection, so changes here must be
71+
# coordinated with that PR.
72+
# ---------------------------------------------------------------------
73+
74+
75+
def test_pgvector_supports_atomic_batch_upsert():
76+
"""PGVector wraps the bulk INSERT ON CONFLICT in
77+
``engine.begin()`` so a mid-batch failure rolls back the entire
78+
batch (task #61 P1-V2)."""
79+
assert PgvectorVectorStoreConnector.BACKEND_CAPABILITIES.supports_atomic_batch_upsert is True
80+
81+
82+
def test_qdrant_does_not_support_atomic_batch_upsert():
83+
"""Qdrant ``client.upsert(points, wait=True)`` is best-effort
84+
per-point — a mid-batch failure can leave some points written and
85+
others not (task #61 P1-V2). Callers needing atomic-batch
86+
semantics must chunk + verify."""
87+
assert QdrantVectorStoreConnector.BACKEND_CAPABILITIES.supports_atomic_batch_upsert is False
88+
89+
90+
def test_pgvector_does_not_support_legacy_mode():
91+
"""PGVector is multitenant-only — a per-tenant table layout would
92+
require dropping the shared-PG topology entirely (task #61 P1-V4)."""
93+
assert PgvectorVectorStoreConnector.BACKEND_CAPABILITIES.supports_legacy_mode is False
94+
95+
96+
def test_qdrant_supports_legacy_mode():
97+
"""Qdrant supports both legacy (``multitenant=False``,
98+
one-collection-per-tenant) and multitenant
99+
(``multitenant=True``, shared-collection + payload filter)
100+
layouts, controlled by the ``multitenant`` ctx flag (task #61
101+
P1-V4). New deployments default to multitenant."""
102+
assert QdrantVectorStoreConnector.BACKEND_CAPABILITIES.supports_legacy_mode is True

tests/unit_test/vectorstore/test_pgvector_translator.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,32 @@ class Bogus:
153153
_translate_filter(Bogus()) # type: ignore[arg-type]
154154

155155

156+
def test_translate_or_with_zero_translatable_parts_raises():
157+
"""Pinned by task #61 P1-V3: an Or filter that ends up with zero
158+
translatable parts (after the construction guard is bypassed) MUST
159+
raise rather than degrade to a vacuous "always-true" SQL fragment.
160+
161+
``Or.__post_init__`` already rejects empty ``parts`` at construction;
162+
we exercise the translator-level defense-in-depth path by
163+
constructing the dataclass directly. Cross-adapter parity with the
164+
Qdrant translator's identical guard.
165+
"""
166+
import dataclasses
167+
168+
from aperag.vectorstore.base import UnsupportedFilterError
169+
from aperag.vectorstore.filters import Eq, Or
170+
171+
# ``object.__setattr__`` works around the frozen dataclass so we
172+
# can simulate a downstream caller that built an Or via
173+
# ``dataclasses.replace`` with empty ``parts``.
174+
or_node = Or(parts=(Eq(key="k", value="v"),))
175+
object.__setattr__(or_node, "parts", ())
176+
assert dataclasses.is_dataclass(or_node)
177+
178+
with pytest.raises(UnsupportedFilterError, match="zero translatable parts"):
179+
_translate_filter(or_node)
180+
181+
156182
# ---------------------------------------------------------------------------
157183
# vector literal
158184
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)