Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 47 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,28 @@ 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

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()
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 +294,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 +352,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 +385,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
57 changes: 52 additions & 5 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 @@ -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
13 changes: 8 additions & 5 deletions src/qql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,12 +1165,15 @@ def _parse_field_path(self) -> str:
f"Expected a field name, got '{tok.value}'", tok.pos
)

def _parse_literal(self) -> str | int | float | bool:
"""STRING | INTEGER | FLOAT | boolean"""
def _parse_literal(self) -> str | int | float | bool | None:
"""STRING | INTEGER | FLOAT | boolean | NULL"""
tok = self._peek()
if tok.kind == TokenKind.STRING:
self._advance()
return tok.value
if tok.kind == TokenKind.NULL:
self._advance()
return None
if tok.kind == TokenKind.INTEGER:
self._advance()
return int(tok.value)
Expand All @@ -1186,7 +1189,7 @@ def _parse_literal(self) -> str | int | float | bool:
self._advance()
return False
raise QQLSyntaxError(
f"Expected a literal value (string, integer, float, or boolean), got '{tok.value}'",
f"Expected a literal value (string, integer, float, boolean, or null), got '{tok.value}'",
tok.pos,
)

Expand All @@ -1203,10 +1206,10 @@ def _parse_number(self) -> int | float:
f"Expected a number, got '{tok.value}'", tok.pos
)

def _parse_literal_list(self) -> list[str | int | float | bool]:
def _parse_literal_list(self) -> list[str | int | float | bool | None]:
"""'(' literal { ',' literal } [','] ')' — used by IN / NOT IN."""
self._expect(TokenKind.LPAREN)
items: list[str | int | float | bool] = []
items: list[str | int | float | bool | None] = []
if self._peek().kind == TokenKind.RPAREN:
self._advance()
return items
Expand Down
18 changes: 17 additions & 1 deletion tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ def test_custom_ca_bundle_passed_to_qdrant_client(self, mocker):
)
assert conn.config.verify == "/etc/ssl/internal-ca.pem"

def test_grpc_options_passed_to_qdrant_client(self, mocker):
mock_client_cls = mocker.patch("qdrant_client.QdrantClient")
Connection("http://localhost:6333", prefer_grpc=True, grpc_port=9999)
mock_client_cls.assert_called_once_with(
url="http://localhost:6333",
api_key=None,
verify=True,
prefer_grpc=True,
grpc_port=9999,
)

def test_custom_default_model_stored_in_config(self, mocker):
mocker.patch("qdrant_client.QdrantClient")
conn = Connection("http://localhost:6333", default_model="BAAI/bge-small-en-v1.5")
Expand Down Expand Up @@ -174,7 +185,12 @@ def test_run_query_delegates_to_connection(self, mocker):
conn_cls = mocker.patch("qql.Connection", return_value=conn_instance)
run_query("SHOW COLLECTIONS", url="http://localhost:6333")
conn_cls.assert_called_once_with(
url="http://localhost:6333", secret=None, default_model=None, verify=True
url="http://localhost:6333",
secret=None,
default_model=None,
verify=True,
prefer_grpc=False,
grpc_port=6334,
)
conn_instance.run_query.assert_called_once_with("SHOW COLLECTIONS")

Expand Down
Loading
Loading