Skip to content

Commit df98498

Browse files
authored
feat: add gRPC support with configurable options for Qdrant connection (#51)
* feat: add gRPC support with configurable options for Qdrant connection * feat: improve error handling and parameter validation in Qdrant connection and executor
1 parent 9172127 commit df98498

9 files changed

Lines changed: 239 additions & 32 deletions

File tree

src/qql/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def run_query(
4343
secret: str | None = None,
4444
default_model: str | None = None,
4545
verify: bool | str = True,
46+
prefer_grpc: bool = False,
47+
grpc_port: int = 6334,
4648
) -> ExecutionResult:
4749
"""One-shot convenience function kept for backward compatibility.
4850
@@ -61,5 +63,7 @@ def run_query(
6163
secret=secret,
6264
default_model=default_model,
6365
verify=verify,
66+
prefer_grpc=prefer_grpc,
67+
grpc_port=grpc_port,
6468
) as conn:
6569
return conn.run_query(query)

src/qql/ast_nodes.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ class QuantizationConfig:
2525
class SearchWith:
2626
"""Query-time search params supported by Qdrant SearchParams."""
2727
hnsw_ef: int | None = None
28-
exact: bool = False
29-
acorn: bool = False
30-
indexed_only: bool = False
28+
exact: bool | None = None
29+
acorn: bool | None = None
30+
indexed_only: bool | None = None
3131
quantization: "QuantizationSearchWith | None" = None
3232
mmr_diversity: float | None = None
3333
mmr_candidates: int | None = None
@@ -99,7 +99,7 @@ class CompareExpr:
9999
"""field op literal — covers =, !=, >, >=, <, <="""
100100
field: str
101101
op: str # one of: "=", "!=", ">", ">=", "<", "<="
102-
value: str | int | float | bool
102+
value: str | int | float | bool | None
103103

104104

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

119119

120120
@dataclass(frozen=True)
121121
class NotInExpr:
122122
"""field NOT IN (v1, v2, ...)"""
123123
field: str
124-
values: tuple[str | int | float | bool, ...]
124+
values: tuple[str | int | float | bool | None, ...]
125125

126126

127127
@dataclass(frozen=True)

src/qql/cli.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import sys
4+
from typing import Any
45

56
import click
67
from prompt_toolkit import PromptSession
@@ -18,6 +19,20 @@
1819
console = Console()
1920
err_console = Console(stderr=True)
2021

22+
23+
def _client_kwargs_from_cfg(cfg: QQLConfig) -> dict[str, Any]:
24+
"""Build QdrantClient keyword arguments from a QQLConfig."""
25+
kwargs: dict[str, Any] = {
26+
"url": cfg.url,
27+
"api_key": cfg.secret,
28+
"verify": cfg.verify,
29+
}
30+
if cfg.prefer_grpc:
31+
kwargs["prefer_grpc"] = True
32+
kwargs["grpc_port"] = cfg.grpc_port
33+
return kwargs
34+
35+
2136
HELP_TEXT = """
2237
[bold cyan]QQL — Qdrant Query Language[/bold cyan]
2338
@@ -185,11 +200,26 @@ def main(ctx: click.Context) -> None:
185200
type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True),
186201
help="Path to a custom CA certificate bundle (PEM).",
187202
)
203+
@click.option(
204+
"--prefer-grpc",
205+
is_flag=True,
206+
default=False,
207+
help="Connect via gRPC transport instead of HTTP.",
208+
)
209+
@click.option(
210+
"--grpc-port",
211+
type=int,
212+
default=6334,
213+
show_default=True,
214+
help="gRPC port of the Qdrant instance.",
215+
)
188216
def connect(
189217
url: str,
190218
secret: str | None,
191219
verify: bool,
192220
ca_cert: str | None,
221+
prefer_grpc: bool,
222+
grpc_port: int,
193223
) -> None:
194224
"""Connect to a Qdrant instance and launch the QQL shell."""
195225
from qdrant_client import QdrantClient
@@ -201,16 +231,31 @@ def connect(
201231

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

234+
client_kwargs: dict[str, Any] = {
235+
"url": url,
236+
"api_key": secret,
237+
"verify": verify_val,
238+
}
239+
if prefer_grpc:
240+
client_kwargs["prefer_grpc"] = True
241+
client_kwargs["grpc_port"] = grpc_port
242+
243+
client = None
204244
try:
205-
client = QdrantClient(url=url, api_key=secret, verify=verify_val)
245+
client = QdrantClient(**client_kwargs)
206246
client.get_collections()
207247
except Exception as e:
208248
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
249+
if client is not None:
250+
client.close()
209251
sys.exit(1)
210252
else:
211253
client.close()
212254

213-
cfg = QQLConfig(url=url, secret=secret, verify=verify_val)
255+
cfg = QQLConfig(
256+
url=url, secret=secret, verify=verify_val,
257+
prefer_grpc=prefer_grpc, grpc_port=grpc_port,
258+
)
214259
save_config(cfg)
215260
console.print("[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
216261
_launch_repl(cfg)
@@ -252,7 +297,7 @@ def execute(file: str, stop_on_error: bool) -> None:
252297
sys.exit(1)
253298

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

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

345390
try:
346-
client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify)
391+
client = QdrantClient(**_client_kwargs_from_cfg(cfg))
347392
client.get_collections()
348393
except Exception as e:
349394
err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")

src/qql/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class QQLConfig:
2020
default_dense_vector_name: str = DEFAULT_DENSE_VECTOR_NAME
2121
default_sparse_vector_name: str = DEFAULT_SPARSE_VECTOR_NAME
2222
verify: bool | str = True
23+
prefer_grpc: bool = False
24+
grpc_port: int = 6334
2325

2426

2527
def save_config(cfg: QQLConfig) -> None:
@@ -45,6 +47,8 @@ def load_config() -> QQLConfig | None:
4547
"default_sparse_vector_name", DEFAULT_SPARSE_VECTOR_NAME
4648
),
4749
verify=data.get("verify", True),
50+
prefer_grpc=data.get("prefer_grpc", False),
51+
grpc_port=data.get("grpc_port", 6334),
4852
)
4953

5054

src/qql/connection.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from typing import Any
4+
35
from .config import DEFAULT_MODEL, QQLConfig
46
from .executor import Executor, ExecutionResult
57
from .lexer import Lexer
@@ -52,6 +54,8 @@ def __init__(
5254
secret: str | None = None,
5355
default_model: str | None = None,
5456
verify: bool | str = True,
57+
prefer_grpc: bool = False,
58+
grpc_port: int = 6334,
5559
) -> None:
5660
"""Create a connection to a Qdrant instance.
5761
@@ -64,6 +68,8 @@ def __init__(
6468
verify: SSL certificate verification. Set to ``False`` to skip
6569
verification for self-signed/internal certificates, or pass
6670
a path to a custom CA bundle (default: ``True``).
71+
prefer_grpc: Whether to connect via fast gRPC transport.
72+
grpc_port: The gRPC port of Qdrant instance (default: 6334).
6773
"""
6874
from qdrant_client import QdrantClient
6975

@@ -72,8 +78,18 @@ def __init__(
7278
secret=secret,
7379
default_model=default_model or DEFAULT_MODEL,
7480
verify=verify,
81+
prefer_grpc=prefer_grpc,
82+
grpc_port=grpc_port,
7583
)
76-
self._client = QdrantClient(url=url, api_key=secret, verify=verify)
84+
client_kwargs: dict[str, Any] = {
85+
"url": url,
86+
"api_key": secret,
87+
"verify": verify,
88+
}
89+
if prefer_grpc:
90+
client_kwargs["prefer_grpc"] = True
91+
client_kwargs["grpc_port"] = grpc_port
92+
self._client = QdrantClient(**client_kwargs)
7793
self._executor = Executor(self._client, self._config)
7894

7995
# ── Public API ────────────────────────────────────────────────────────

src/qql/executor.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,18 @@ def execute(self, node: ASTNode) -> ExecutionResult:
240240

241241
# ── Statement executors ───────────────────────────────────────────────
242242

243+
@staticmethod
244+
def _is_grpc_not_found_error(error: BaseException) -> bool:
245+
"""Return True if *error* is a gRPC NOT_FOUND status."""
246+
from grpc import RpcError, StatusCode
247+
return isinstance(error, RpcError) and error.code() == StatusCode.NOT_FOUND
248+
243249
def _fetch_collection_info(self, name: str):
244250
"""Fetch full CollectionInfo for *name* in a single API call.
245251
246252
Returns the CollectionInfo object when the collection exists, or
247-
``None`` when the collection is not found (HTTP 404). Any other
248-
Qdrant error is re-raised as :class:`QQLRuntimeError`.
253+
``None`` when the collection is not found (HTTP 404 or gRPC NOT_FOUND).
254+
Any other Qdrant error is re-raised as :class:`QQLRuntimeError`.
249255
"""
250256
try:
251257
return self._client.get_collection(name)
@@ -255,6 +261,18 @@ def _fetch_collection_info(self, name: str):
255261
raise QQLRuntimeError(
256262
f"Qdrant error fetching collection '{name}': {e}"
257263
) from e
264+
except ValueError as e:
265+
if f"Collection {name} not found" in str(e):
266+
return None
267+
raise QQLRuntimeError(
268+
f"Qdrant error fetching collection '{name}': {e}"
269+
) from e
270+
except Exception as e:
271+
if self._is_grpc_not_found_error(e):
272+
return None
273+
raise QQLRuntimeError(
274+
f"Qdrant error fetching collection '{name}': {e}"
275+
) from e
258276

259277
def _topology_from_collection_info(self, info: Any) -> CollectionTopology:
260278
"""Parse a CollectionInfo object into a :class:`CollectionTopology`.
@@ -1333,8 +1351,8 @@ def _build_search_params(self, with_clause: SearchWith | None) -> SearchParams |
13331351
hnsw_ef=with_clause.hnsw_ef,
13341352
exact=with_clause.exact,
13351353
quantization=quantization,
1336-
indexed_only=True if with_clause.indexed_only else None,
1337-
acorn=AcornSearchParams(enable=True) if with_clause.acorn else None,
1354+
indexed_only=with_clause.indexed_only if with_clause.indexed_only is not None else None,
1355+
acorn=AcornSearchParams(enable=with_clause.acorn) if with_clause.acorn is not None else None,
13381356
)
13391357

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

18361854
# ── Comparison ────────────────────────────────────────────────────
18371855
if isinstance(expr, CompareExpr):
1856+
if expr.value is None:
1857+
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
1858+
if expr.op == "=":
1859+
return null_condition
1860+
if expr.op == "!=":
1861+
return Filter(must_not=[null_condition])
1862+
raise QQLRuntimeError(
1863+
f"Cannot use operator '{expr.op}' with null for field '{expr.field}'"
1864+
)
18381865
if expr.op == "=":
18391866
return FieldCondition(
18401867
key=expr.field, match=MatchValue(value=expr.value)
@@ -1858,14 +1885,34 @@ def _build_qdrant_filter(self, expr: FilterExpr) -> Any:
18581885

18591886
# ── IN / NOT IN ───────────────────────────────────────────────────
18601887
if isinstance(expr, InExpr):
1861-
return FieldCondition(
1862-
key=expr.field, match=MatchAny(any=list(expr.values))
1888+
non_nulls = [v for v in expr.values if v is not None]
1889+
if len(non_nulls) == len(expr.values):
1890+
return FieldCondition(
1891+
key=expr.field, match=MatchAny(any=non_nulls)
1892+
)
1893+
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
1894+
if not non_nulls:
1895+
return null_condition
1896+
return Filter(
1897+
should=[
1898+
null_condition,
1899+
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls)),
1900+
]
18631901
)
18641902

18651903
if isinstance(expr, NotInExpr):
1904+
non_nulls = [v for v in expr.values if v is not None]
1905+
null_condition = IsNullCondition(is_null=PayloadField(key=expr.field))
1906+
if len(non_nulls) != len(expr.values):
1907+
must_not = [null_condition]
1908+
if non_nulls:
1909+
must_not.append(
1910+
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))
1911+
)
1912+
return Filter(must_not=must_not)
18661913
return FieldCondition(
18671914
key=expr.field,
1868-
match=MatchExcept(**{"except": list(expr.values)}),
1915+
match=MatchExcept(**{"except": non_nulls}),
18691916
)
18701917

18711918
# ── IS NULL / IS NOT NULL ─────────────────────────────────────────

0 commit comments

Comments
 (0)