Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/qql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def run_query(
secret: str | None = None,
default_model: str | None = None,
verify: bool | str = True,
prefer_grpc: bool = False,
grpc_port: int = 6334,
) -> ExecutionResult:
"""One-shot convenience function kept for backward compatibility.

Expand All @@ -61,5 +63,7 @@ def run_query(
secret=secret,
default_model=default_model,
verify=verify,
prefer_grpc=prefer_grpc,
grpc_port=grpc_port,
) as conn:
return conn.run_query(query)
12 changes: 6 additions & 6 deletions src/qql/ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ class QuantizationConfig:
class SearchWith:
"""Query-time search params supported by Qdrant SearchParams."""
hnsw_ef: int | None = None
exact: bool = False
acorn: bool = False
indexed_only: bool = False
exact: bool | None = None
acorn: bool | None = None
indexed_only: bool | None = None
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check all references to exact/acorn/indexed_only fields in SearchWith

rg -n -C3 'with_clause\.(exact|acorn|indexed_only)' --type=py

Repository: pavanjava/qql

Length of output: 5734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show SearchWith definition around the reported lines
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

# Show executor section building SearchParams
sed -n '1300,1405p' src/qql/executor.py | nl -ba

# Show parser around merging/propagating with_clause options
sed -n '740,860p' src/qql/parser.py | nl -ba

# Search for explicit parsing of indexed_only/acorn/exact values
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith definition) ==="
sed -n '1,120p' src/qql/ast_nodes.py | nl -ba | sed -n '1,120p'

echo "=== src/qql/executor.py (SearchParams construction) ==="
sed -n '1300,1405p' src/qql/executor.py | nl -ba

echo "=== src/qql/parser.py (with_clause merging) ==="
sed -n '740,860p' src/qql/parser.py | nl -ba

echo "=== src/qql/parser.py (mentions of indexed_only/acorn/exact) ==="
rg -n "indexed_only|acorn|exact" src/qql/parser.py | head -n 200

Repository: pavanjava/qql

Length of output: 171


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/ast_nodes.py (SearchWith fields around lines 20-50) ==="
sed -n '1,120p' src/qql/ast_nodes.py | cat -n | sed -n '20,70p'

echo "=== src/qql/ast_nodes.py (SearchWith definition area) ==="
rg -n "class SearchWith|SearchWith\s*\(" src/qql/ast_nodes.py

echo "=== src/qql/executor.py (SearchParams construction around 1320-1370) ==="
sed -n '1310,1370p' src/qql/executor.py | cat -n

echo "=== src/qql/parser.py (with_clause merging around 760-825) ==="
sed -n '760,830p' src/qql/parser.py | cat -n

echo "=== src/qql/parser.py (lines mentioning exact/acorn/indexed_only) ==="
rg -n "exact\s*=|acorn\s*=|indexed_only\s*=|\.exact\b|\.acorn\b|\.indexed_only\b|parsed_with\.(exact|acorn|indexed_only)|with_clause\.(exact|acorn|indexed_only)" src/qql/parser.py

Repository: pavanjava/qql

Length of output: 9807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/qql/parser.py (parse_with_clause around 1360-1450) ==="
sed -n '1340,1460p' src/qql/parser.py | cat -n

echo "=== tests for indexed_only false / acorn false ==="
rg -n "indexed_only:\s*false|acorn:\s*false" tests -S || true

echo "=== tests for indexed_only false / acorn false (with_clause keywords) ==="
rg -n "indexed_only.*false|acorn.*false" tests/test_parser.py -S || true

Repository: pavanjava/qql

Length of output: 5912


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== rg: SearchWith( constructions in src/qql/parser.py ==="
rg -n "SearchWith\(" src/qql/parser.py

echo "=== rg: SearchWith( constructions in src/qql/**/*.py ==="
rg -n "SearchWith\(" src/qql

echo "=== rg: _build_search_params call sites / with_clause usage ==="
rg -n "_build_search_params\(" -S src/qql/executor.py src/qql -S

Repository: pavanjava/qql

Length of output: 1503


Fix SearchWith.exact/acorn/indexed_only handling of false vs None end-to-end

  • src/qql/ast_nodes.py now defines SearchWith.exact, SearchWith.acorn, and SearchWith.indexed_only as bool | None = None, but src/qql/parser.py::_parse_with_clause initializes exact/acorn/indexed_only to False and always returns booleans—so omitting those keys in WITH { ... } yields False (not None).
  • src/qql/parser.py merges with_clause values using parsed_with.<field> or with_clause.<field>, which makes explicit false unable to override a previously-set true.
  • src/qql/executor.py::_build_search_params treats indexed_only and acorn as True if ... else None, so false and None both become “unset” for Qdrant (while exact is passed through directly).

Adjust parsing/merging/executor mapping to use is not None / coalescing semantics so None remains “unset” and false remains an explicit disable for all three fields.

🤖 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 `@src/qql/ast_nodes.py` around lines 28 - 30, Parser currently initializes
exact/acorn/indexed_only to False and merges with "or", which loses explicit
false; update src/qql/parser.py::_parse_with_clause to initialize
SearchWith.exact/acorn/indexed_only as None (not False) and change merging logic
to coalesce using "if parsed_with.<field> is not None then parsed_with.<field>
else with_clause.<field>" (or equivalent is not None check) instead of "or" so
explicit False wins; also update src/qql/executor.py::_build_search_params to
map these three fields by checking "is not None" and passing True/False through
as-is (return value = field if field is not None else None) so False remains an
explicit disable for exact, acorn, and indexed_only.

quantization: "QuantizationSearchWith | None" = None
mmr_diversity: float | None = None
mmr_candidates: int | None = None
Expand Down Expand Up @@ -99,7 +99,7 @@ class CompareExpr:
"""field op literal — covers =, !=, >, >=, <, <="""
field: str
op: str # one of: "=", "!=", ">", ">=", "<", "<="
value: str | int | float | bool
value: str | int | float | bool | None


@dataclass(frozen=True)
Expand All @@ -114,14 +114,14 @@ class BetweenExpr:
class InExpr:
"""field IN (v1, v2, ...)"""
field: str
values: tuple[str | int | float | bool, ...]
values: tuple[str | int | float | bool | None, ...]


@dataclass(frozen=True)
class NotInExpr:
"""field NOT IN (v1, v2, ...)"""
field: str
values: tuple[str | int | float | bool, ...]
values: tuple[str | int | float | bool | None, ...]


@dataclass(frozen=True)
Expand Down
55 changes: 50 additions & 5 deletions src/qql/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import sys
from typing import Any

import click
from prompt_toolkit import PromptSession
Expand All @@ -18,6 +19,20 @@
console = Console()
err_console = Console(stderr=True)


def _client_kwargs_from_cfg(cfg: QQLConfig) -> dict[str, Any]:
"""Build QdrantClient keyword arguments from a QQLConfig."""
kwargs: dict[str, Any] = {
"url": cfg.url,
"api_key": cfg.secret,
"verify": cfg.verify,
}
if cfg.prefer_grpc:
kwargs["prefer_grpc"] = True
kwargs["grpc_port"] = cfg.grpc_port
return kwargs


HELP_TEXT = """
[bold cyan]QQL — Qdrant Query Language[/bold cyan]

Expand Down Expand Up @@ -185,11 +200,26 @@ def main(ctx: click.Context) -> None:
type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True),
help="Path to a custom CA certificate bundle (PEM).",
)
@click.option(
"--prefer-grpc",
is_flag=True,
default=False,
help="Connect via gRPC transport instead of HTTP.",
)
@click.option(
"--grpc-port",
type=int,
default=6334,
show_default=True,
help="gRPC port of the Qdrant instance.",
)
def connect(
url: str,
secret: str | None,
verify: bool,
ca_cert: str | None,
prefer_grpc: bool,
grpc_port: int,
) -> None:
"""Connect to a Qdrant instance and launch the QQL shell."""
from qdrant_client import QdrantClient
Expand All @@ -201,16 +231,31 @@ def connect(

console.print(f"Connecting to [bold]{url}[/bold]...")

client_kwargs: dict[str, Any] = {
"url": url,
"api_key": secret,
"verify": verify_val,
}
if prefer_grpc:
client_kwargs["prefer_grpc"] = True
client_kwargs["grpc_port"] = grpc_port

client = None
try:
client = QdrantClient(url=url, api_key=secret, verify=verify_val)
client = QdrantClient(**client_kwargs)
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
if client is not None:
client.close()
sys.exit(1)
else:
client.close()
Comment on lines 244 to 253

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Client not closed on connection test failure.

When the connection test at line 244 succeeds, client.close() is called at line 250. However, if client.get_collections() at line 245 raises an exception, the except block at line 246 prints an error and exits, but the client connection is never closed.

🔒 Proposed fix to ensure client is closed
     try:
         client = QdrantClient(**client_kwargs)
         client.get_collections()
     except Exception as e:
+        client.close()
         err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
         sys.exit(1)
     else:
         client.close()

Alternatively, use a context manager or try/finally.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
client = QdrantClient(url=url, api_key=secret, verify=verify_val)
client = QdrantClient(**client_kwargs)
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
sys.exit(1)
else:
client.close()
client = None
try:
client = QdrantClient(**client_kwargs)
client.get_collections()
except Exception as e:
if client:
client.close()
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
sys.exit(1)
else:
client.close()
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 246-246: Do not catch blind exception: Exception

(BLE001)

🤖 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 `@src/qql/cli.py` around lines 243 - 250, The QdrantClient created in the try
block may not be closed if client.get_collections() raises; ensure the client is
always closed by restructuring to either use a context manager for QdrantClient
or add a try/finally that calls client.close() after creating the client (i.e.,
create client = QdrantClient(**client_kwargs) then in finally call
client.close()), keeping the error logging in the except block and sys.exit(1)
after the finally.


cfg = QQLConfig(url=url, secret=secret, verify=verify_val)
cfg = QQLConfig(
url=url, secret=secret, verify=verify_val,
prefer_grpc=prefer_grpc, grpc_port=grpc_port,
)
save_config(cfg)
console.print("[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
_launch_repl(cfg)
Expand Down Expand Up @@ -252,7 +297,7 @@ def execute(file: str, stop_on_error: bool) -> None:
sys.exit(1)

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client = QdrantClient(**_client_kwargs_from_cfg(cfg))
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
Expand Down Expand Up @@ -310,7 +355,7 @@ def dump(collection: str, output: str, batch_size: int) -> None:
sys.exit(1)

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client = QdrantClient(**_client_kwargs_from_cfg(cfg))
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
Expand Down Expand Up @@ -343,7 +388,7 @@ def _launch_repl(cfg: QQLConfig) -> None:
from qdrant_client import QdrantClient

try:
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
client = QdrantClient(**_client_kwargs_from_cfg(cfg))
client.get_collections()
except Exception as e:
err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")
Expand Down
4 changes: 4 additions & 0 deletions src/qql/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class QQLConfig:
default_dense_vector_name: str = DEFAULT_DENSE_VECTOR_NAME
default_sparse_vector_name: str = DEFAULT_SPARSE_VECTOR_NAME
verify: bool | str = True
prefer_grpc: bool = False
grpc_port: int = 6334


def save_config(cfg: QQLConfig) -> None:
Expand All @@ -45,6 +47,8 @@ def load_config() -> QQLConfig | None:
"default_sparse_vector_name", DEFAULT_SPARSE_VECTOR_NAME
),
verify=data.get("verify", True),
prefer_grpc=data.get("prefer_grpc", False),
grpc_port=data.get("grpc_port", 6334),
)


Expand Down
18 changes: 17 additions & 1 deletion src/qql/connection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import Any

from .config import DEFAULT_MODEL, QQLConfig
from .executor import Executor, ExecutionResult
from .lexer import Lexer
Expand Down Expand Up @@ -52,6 +54,8 @@ def __init__(
secret: str | None = None,
default_model: str | None = None,
verify: bool | str = True,
prefer_grpc: bool = False,
grpc_port: int = 6334,
) -> None:
"""Create a connection to a Qdrant instance.

Expand All @@ -64,6 +68,8 @@ def __init__(
verify: SSL certificate verification. Set to ``False`` to skip
verification for self-signed/internal certificates, or pass
a path to a custom CA bundle (default: ``True``).
prefer_grpc: Whether to connect via fast gRPC transport.
grpc_port: The gRPC port of Qdrant instance (default: 6334).
"""
from qdrant_client import QdrantClient

Expand All @@ -72,8 +78,18 @@ def __init__(
secret=secret,
default_model=default_model or DEFAULT_MODEL,
verify=verify,
prefer_grpc=prefer_grpc,
grpc_port=grpc_port,
)
self._client = QdrantClient(url=url, api_key=secret, verify=verify)
client_kwargs: dict[str, Any] = {
"url": url,
"api_key": secret,
"verify": verify,
}
if prefer_grpc:
client_kwargs["prefer_grpc"] = True
client_kwargs["grpc_port"] = grpc_port
self._client = QdrantClient(**client_kwargs)
self._executor = Executor(self._client, self._config)

# ── Public API ────────────────────────────────────────────────────────
Expand Down
61 changes: 54 additions & 7 deletions src/qql/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,18 @@ def execute(self, node: ASTNode) -> ExecutionResult:

# ── Statement executors ───────────────────────────────────────────────

@staticmethod
def _is_grpc_not_found_error(error: BaseException) -> bool:
"""Return True if *error* is a gRPC NOT_FOUND status."""
from grpc import RpcError, StatusCode
return isinstance(error, RpcError) and error.code() == StatusCode.NOT_FOUND

def _fetch_collection_info(self, name: str):
"""Fetch full CollectionInfo for *name* in a single API call.

Returns the CollectionInfo object when the collection exists, or
``None`` when the collection is not found (HTTP 404). Any other
Qdrant error is re-raised as :class:`QQLRuntimeError`.
``None`` when the collection is not found (HTTP 404 or gRPC NOT_FOUND).
Any other Qdrant error is re-raised as :class:`QQLRuntimeError`.
"""
try:
return self._client.get_collection(name)
Expand All @@ -255,6 +261,18 @@ def _fetch_collection_info(self, name: str):
raise QQLRuntimeError(
f"Qdrant error fetching collection '{name}': {e}"
) from e
except ValueError as e:
if f"Collection {name} not found" in str(e):
return None
raise QQLRuntimeError(
f"Qdrant error fetching collection '{name}': {e}"
) from e
Comment on lines +264 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

String matching may miss collection-not-found ValueError.

Line 265 checks if f"Collection {name} not found" is in str(e), but this literal string interpolation happens at check-time with the actual collection name. If the ValueError message uses a different format (e.g., quotes around the name: Collection 'docs' not found), this check will fail.

🛡️ Proposed fix for robust pattern matching
         except ValueError as e:
-            if f"Collection {name} not found" in str(e):
+            if "not found" in str(e).lower() and name in str(e):
                 return None
             raise QQLRuntimeError(
                 f"Qdrant error fetching collection '{name}': {e}"
             ) from e

This checks for both "not found" (case-insensitive) and the collection name, making it more robust to message format variations.

🤖 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 `@src/qql/executor.py` around lines 264 - 269, The current except block in
executor.py that inspects ValueError (the block referencing variable name and
raising QQLRuntimeError) uses a brittle literal substring check; replace it with
a robust, case-insensitive regex (or two-part check) that looks for "not found"
and the collection name allowing optional surrounding quotes (e.g. use
re.search(fr"collection\s+['\"]?{re.escape(name)}['\"]?\s+not\s+found", str(e),
re.I) or fall back to checking "not found" in str(e). If the regex matches,
return None; otherwise raise the QQLRuntimeError as before (preserving the
original exception chain).

except Exception as e:
if self._is_grpc_not_found_error(e):
return None
raise QQLRuntimeError(
f"Qdrant error fetching collection '{name}': {e}"
) from e

def _topology_from_collection_info(self, info: Any) -> CollectionTopology:
"""Parse a CollectionInfo object into a :class:`CollectionTopology`.
Expand Down Expand Up @@ -1333,8 +1351,8 @@ def _build_search_params(self, with_clause: SearchWith | None) -> SearchParams |
hnsw_ef=with_clause.hnsw_ef,
exact=with_clause.exact,
quantization=quantization,
indexed_only=True if with_clause.indexed_only else None,
acorn=AcornSearchParams(enable=True) if with_clause.acorn else None,
indexed_only=with_clause.indexed_only if with_clause.indexed_only is not None else None,
acorn=AcornSearchParams(enable=with_clause.acorn) if with_clause.acorn is not None else None,
)

def _build_hnsw_config(self, config: CollectionConfig | None) -> HnswConfigDiff | None:
Expand Down Expand Up @@ -1835,6 +1853,15 @@ def _build_qdrant_filter(self, expr: FilterExpr) -> Any:

# ── Comparison ────────────────────────────────────────────────────
if isinstance(expr, CompareExpr):
if expr.value is None:
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
if expr.op == "=":
return null_condition
if expr.op == "!=":
return Filter(must_not=[null_condition])
raise QQLRuntimeError(
f"Cannot use operator '{expr.op}' with null for field '{expr.field}'"
)
if expr.op == "=":
return FieldCondition(
key=expr.field, match=MatchValue(value=expr.value)
Expand All @@ -1858,14 +1885,34 @@ def _build_qdrant_filter(self, expr: FilterExpr) -> Any:

# ── IN / NOT IN ───────────────────────────────────────────────────
if isinstance(expr, InExpr):
return FieldCondition(
key=expr.field, match=MatchAny(any=list(expr.values))
non_nulls = [v for v in expr.values if v is not None]
if len(non_nulls) == len(expr.values):
return FieldCondition(
key=expr.field, match=MatchAny(any=non_nulls)
)
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
if not non_nulls:
return null_condition
return Filter(
should=[
null_condition,
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls)),
]
)

if isinstance(expr, NotInExpr):
non_nulls = [v for v in expr.values if v is not None]
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
if len(non_nulls) != len(expr.values):
must_not = [null_condition]
if non_nulls:
must_not.append(
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))
)
return Filter(must_not=must_not)
return FieldCondition(
key=expr.field,
match=MatchExcept(**{"except": list(expr.values)}),
match=MatchExcept(**{"except": non_nulls}),
)

# ── IS NULL / IS NOT NULL ─────────────────────────────────────────
Expand Down
Loading
Loading