-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add gRPC support with configurable options for Qdrant connection #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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] | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Client not closed on connection test failure. When the connection test at line 244 succeeds, 🔒 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
Suggested change
🧰 Tools🪛 Ruff (0.15.15)[warning] 246-246: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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}") | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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}") | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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}") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. String matching may miss collection-not-found ValueError. Line 265 checks if 🛡️ 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 eThis checks for both "not found" (case-insensitive) and the collection name, making it more robust to message format variations. 🤖 Prompt for AI Agents |
||
| 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`. | ||
|
|
@@ -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: | ||
|
|
@@ -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) | ||
|
|
@@ -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 ───────────────────────────────────────── | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 5734
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 100
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 171
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 9807
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 5912
🏁 Script executed:
Repository: pavanjava/qql
Length of output: 1503
Fix
SearchWith.exact/acorn/indexed_onlyhandling offalsevsNoneend-to-endsrc/qql/ast_nodes.pynow definesSearchWith.exact,SearchWith.acorn, andSearchWith.indexed_onlyasbool | None = None, butsrc/qql/parser.py::_parse_with_clauseinitializesexact/acorn/indexed_onlytoFalseand always returns booleans—so omitting those keys inWITH { ... }yieldsFalse(notNone).src/qql/parser.pymergeswith_clausevalues usingparsed_with.<field> or with_clause.<field>, which makes explicitfalseunable to override a previously-settrue.src/qql/executor.py::_build_search_paramstreatsindexed_onlyandacornasTrue if ... else None, sofalseandNoneboth become “unset” for Qdrant (whileexactis passed through directly).Adjust parsing/merging/executor mapping to use
is not None/ coalescing semantics soNoneremains “unset” andfalseremains an explicit disable for all three fields.🤖 Prompt for AI Agents