Skip to content

feat: add gRPC support with configurable options for Qdrant connection#51

Open
srimon12 wants to merge 2 commits into
pavanjava:mainfrom
srimon12:feat/grpc-fix-simple
Open

feat: add gRPC support with configurable options for Qdrant connection#51
srimon12 wants to merge 2 commits into
pavanjava:mainfrom
srimon12:feat/grpc-fix-simple

Conversation

@srimon12

@srimon12 srimon12 commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

feat: add gRPC transport support and NULL filter handling

What

Adds optional gRPC transport for faster Qdrant communication and proper SQL-style NULL comparison handling in QQL filter expressions.

Why

  • gRPC is ~30-40% faster than HTTP for Qdrant operations (measured in PR Adds async QQL execution and programmatic gRPC transport support. #50 benchmarks). Users should be able to opt in.
  • NULL comparisons (field = NULL, field != NULL, field IN (val, NULL), field NOT IN (NULL)) were silently broken or missing. QQL had IS NULL/IS NOT NULL syntax but couldn't handle NULL as a literal in =, !=, IN, or NOT IN operators.

Changes

gRPC transport:

  • Connection accepts prefer_grpc and grpc_port parameters
  • QQLConfig persists gRPC settings across sessions
  • CLI qql connect exposes --prefer-grpc and --grpc-port flags
  • run_query() standalone function passes gRPC params through
  • All 4 CLI code paths (connect, execute, dump, _launch_repl) use a shared _client_kwargs_from_cfg() helper

NULL handling:

  • Parser accepts NULL as a literal value in comparisons and IN/NOT IN lists
  • field = NULL builds IsNullCondition
  • field != NULL builds Filter(must_not=[IsNullCondition])
  • field IN (val, NULL) builds Filter(should=[IsNullCondition, MatchAny])
  • field NOT IN (NULL) builds Filter(must_not=[IsNullCondition])
  • field > NULL (or <, >=, <=) raises a clear error: Cannot use operator '>' with null
  • CompareExpr, InExpr, NotInExpr value types now include None
  • SearchWith boolean fields (exact, acorn, indexed_only) changed from bool = False to bool | None = None to distinguish "not set" from "explicitly false"

Error handling:

  • _fetch_collection_info catches ValueError for client versions that throw string-based "Collection not found" errors
  • _fetch_collection_info catches gRPC RpcError with StatusCode.NOT_FOUND (same semantics as HTTP 404)
  • Non-matching ValueErrors now wrap as QQLRuntimeError instead of leaking raw exceptions

Tests

  • 7 new tests for _fetch_collection_info error paths (HTTP 404, ValueError, gRPC NOT_FOUND, gRPC other, generic exception)
  • gRPC connection parameter test verifying args reach QdrantClient
  • Updated run_query delegation test for new signature
  • All 624 tests passing

Summary by CodeRabbit

Release Notes

  • New Features
    • Added gRPC protocol support for connecting to Qdrant with configurable port; configure via new --prefer-grpc and --grpc-port CLI options or when initializing connections
    • Enhanced query filtering to support NULL values in comparison operations and IN/NOT IN clauses, enabling more flexible null-aware queries

Repository owner deleted a comment from coderabbitai Bot Jun 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/qql/ast_nodes.py`:
- Around line 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.

In `@src/qql/cli.py`:
- Around line 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.

In `@src/qql/executor.py`:
- Around line 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).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d7d6693-1448-4233-ab2e-d177e9819b16

📥 Commits

Reviewing files that changed from the base of the PR and between 9172127 and 5a9dfb6.

📒 Files selected for processing (9)
  • src/qql/__init__.py
  • src/qql/ast_nodes.py
  • src/qql/cli.py
  • src/qql/config.py
  • src/qql/connection.py
  • src/qql/executor.py
  • src/qql/parser.py
  • tests/test_connection.py
  • tests/test_executor.py

Comment thread src/qql/ast_nodes.py
Comment on lines +28 to +30
exact: bool | None = None
acorn: bool | None = None
indexed_only: bool | None = None

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.

Comment thread src/qql/cli.py
Comment on lines 243 to 250
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()

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.

Comment thread src/qql/executor.py
Comment on lines +264 to +269
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

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).

Repository owner deleted a comment from coderabbitai Bot Jun 11, 2026
Repository owner deleted a comment from coderabbitai Bot Jun 11, 2026
@srimon12 srimon12 added the enhancement New feature or request label Jun 11, 2026
@srimon12 srimon12 requested a review from pavanjava June 11, 2026 15:07
Repository owner deleted a comment from coderabbitai Bot Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant