feat: add async execution and batched programmatic APIs#45
Conversation
Add async QQL support with AsyncConnection and AsyncExecutor, plus sync and async batching helpers for running multiple statements through one programmatic API. Introduce BEGIN BATCH syntax, parameterized query helpers, and optional gRPC connection settings. Refactor shared parser and executor logic into qql.utils so sync and async paths can reuse filter conversion, vector shaping, topology parsing, batch grouping, and search parsing helpers. Update tests and docs for async usage, batching, parameterized queries, gRPC configuration, and batch block execution.
📝 WalkthroughWalkthroughAdds BEGIN BATCH ... END BATCH syntax, parameterized queries and batching helpers, optional gRPC transport, an AsyncConnection/AsyncExecutor pair, shared utils for batching/filtering/search parsing, expanded package exports, documentation updates, and tests covering batching, parameter rendering, and async behavior. ChangesBatch Execution & Async Support
Sequence Diagram (high-level batch execution flow): sequenceDiagram
participant Client
participant Lexer
participant Parser
participant Executor
participant QdrantClient
Client->>Lexer: provide QQL (BEGIN BATCH / statements / END BATCH)
Lexer->>Parser: tokens (including SEMICOLON, BEGIN, END)
Parser->>Executor: BatchBlockStmt (statements tuple)
Executor->>QdrantClient: query_batch_points / upsert / other calls
QdrantClient-->>Executor: responses (per-statement)
Executor-->>Client: list[ExecutionResult] / aggregated ExecutionResult
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 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 `@docs/reference.md`:
- Line 174: Add a language identifier to the opening fenced code block so the
markdown linter (MD040) passes: change the opening ``` to ```text (i.e., update
the fenced block that currently starts with ``` before the listing "qql/" to
begin with ```text) so the code block has an explicit language.
In `@src/qql/async_connection.py`:
- Line 193: The batch resolver currently pairs proxies and results with
zip(self._proxies, results) in async_connection.py (and the analogous loop in
connection.py), which silently drops unmatched proxies or results; update the
post-run handling in the async method that calls run_queries_batch(...) to
explicitly compare len(results) to len(self._proxies) and, if they differ, raise
a clear RuntimeError (or set exception on each unmatched Proxy via
Proxy.set_exception) instead of silently leaving proxies unresolved; for matched
entries continue to call proxy.set_result(res) as before, and mirror the same
length-check and unmatched-proxy exception-handling logic in the sync batch
context manager in connection.py so all proxies are either resolved or fail
deterministically.
In `@src/qql/async_executor.py`:
- Around line 293-301: The code currently calls build_dense_point_vector (and
similarly build sparse vectors/PointStruct.vector) using a topology captured
before calling self._ensure_collection, which can race with another coroutine
that creates the collection with different vector names; change the flow so that
you first call self._ensure_collection(...) (and any hybrid creation lock), then
refresh/re-resolve the topology from the collection metadata that was actually
created/returned, and only after that call build_dense_point_vector (and the
corresponding sparse/vector construction) using the refreshed topology; apply
the same pattern for the other occurrences that build vectors (the blocks using
build_dense_point_vector and setting PointStruct.vector at the other mentioned
sites).
In `@src/qql/parser.py`:
- Around line 73-76: The parser currently calls parse() ->
_parse_single_statement() and immediately expects TokenKind.EOF, rejecting
trailing SEMICOLON tokens; update parse() to allow and consume any number of
trailing TokenKind.SEMICOLON tokens after the node is parsed (e.g., loop while
next token is TokenKind.SEMICOLON call the existing token-consumption helper
such as self._expect/_advance/_consume), then call self._expect(TokenKind.EOF)
and return the node so top-level statements like "SHOW COLLECTIONS;" succeed.
In `@src/qql/utils.py`:
- Around line 80-93: The function render_parameterized_query currently
serializes parameters via str() and inadequate escaping, causing None to become
"None" and backslashes/control chars to be unescaped; update
render_parameterized_query to: map None to the QQL NULL literal, escape strings
for single quotes and backslashes and encode common control sequences (e.g., \n,
\t, \\) so they are preserved as literals, format booleans as "true"/"false" and
numbers via their canonical representation, and then replace placeholders (keep
the current placeholder logic and sorted key order) with these properly
serialized QQL literals.
- Around line 160-170: The batching logic currently treats every SearchStmt as a
plain "query" which strips group semantics; change the branch so a SearchStmt is
only added to the "query" batch when it has no grouping (i.e., stmt.group_by and
stmt.group_size are falsy). For SearchStmt instances with group_by/group_size,
flush the current group via _append_batch_group and start a new group that is
not the plain "query" path (e.g., set current_type to a distinct token like
"grouped_search" and current_group = [stmt]) so
_execute_query_batch/run_queries_batch won’t treat it as a flat QueryRequest;
keep the existing behavior for RecommendStmt and non-grouped SearchStmt.
🪄 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: 20d846a7-d3f5-4169-a380-a106ae17a1cb
📒 Files selected for processing (17)
README.mddocs/getting-started.mddocs/programmatic.mddocs/reference.mddocs/scripts.mdsrc/qql/__init__.pysrc/qql/ast_nodes.pysrc/qql/async_connection.pysrc/qql/async_executor.pysrc/qql/connection.pysrc/qql/executor.pysrc/qql/lexer.pysrc/qql/parser.pysrc/qql/script.pysrc/qql/utils.pytests/test_async_connection.pytests/test_connection.py
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/qql/connection.py (1)
192-200:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReset
QQLBatchinternal state on context exit (and re-entry).
self._queriesandself._proxiesare never cleared. Reusing the sameQQLBatchinstance can replay stale queries and corrupt proxy semantics. Clear lifecycle state in__enter__/__exit__.Proposed fix
def __enter__(self) -> QQLBatch: + self._queries.clear() + self._proxies.clear() return self def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - if exc_type is not None: - return - if not self._queries: - return - results = self.connection.run_queries_batch(self._queries) - for proxy, res in zip(self._proxies, results, strict=False): - proxy._resolve(res) - if len(results) != len(self._proxies): - error = RuntimeError( - "Batch result count mismatch: " - f"expected {len(self._proxies)}, got {len(results)}" - ) - for proxy in self._proxies[len(results):]: - proxy._reject(error) - raise error + try: + if exc_type is not None or not self._queries: + return + results = self.connection.run_queries_batch(self._queries) + if len(results) != len(self._proxies): + error = RuntimeError( + "Batch result count mismatch: " + f"expected {len(self._proxies)}, got {len(results)}" + ) + for proxy in self._proxies: + proxy._reject(error) + raise error + for proxy, res in zip(self._proxies, results): + proxy._resolve(res) + finally: + self._queries.clear() + self._proxies.clear()Also applies to: 195-211
🤖 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/connection.py` around lines 192 - 200, The QQLBatch context manager currently never clears its internal state causing reused instances to replay stale queries; update QQLBatch.__exit__ to clear self._queries and self._proxies unconditionally (even on exception) after calling self.connection.run_queries_batch (or skipping it when exc_type is present), and also reset/initialize those collections in QQLBatch.__enter__ so each context entry starts with a fresh state; reference the QQLBatch class and the __enter__/__exit__ methods and ensure any early returns still perform the cleanup to avoid stale-proxy semantics.
🧹 Nitpick comments (2)
src/qql/utils.py (2)
466-481: 💤 Low value
orfallback may incorrectly ignore zero values for numeric fields.The merge uses
override.hnsw_ef or base.hnsw_efwhich treats0as falsy. Ifoverride.hnsw_ef = 0is explicitly set, it would be ignored in favor ofbase.hnsw_ef. The same applies tommr_candidates. Whilemmr_diversitycorrectly usesis not None, the other numeric fields don't.♻️ Suggested fix for consistency
return SearchWith( - hnsw_ef=override.hnsw_ef or base.hnsw_ef, + hnsw_ef=override.hnsw_ef if override.hnsw_ef is not None else base.hnsw_ef, exact=override.exact or base.exact, acorn=override.acorn or base.acorn, indexed_only=override.indexed_only or base.indexed_only, quantization=override.quantization or base.quantization, mmr_diversity=( override.mmr_diversity if override.mmr_diversity is not None else base.mmr_diversity ), - mmr_candidates=override.mmr_candidates or base.mmr_candidates, + mmr_candidates=override.mmr_candidates if override.mmr_candidates is not None else base.mmr_candidates, )🤖 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/utils.py` around lines 466 - 481, The merge_search_with function currently uses truthy "or" fallbacks which will ignore valid zero/False values (e.g. override.hnsw_ef==0 or override.mmr_candidates==0); change each field assignment to use explicit None checks: replace expressions like override.hnsw_ef or base.hnsw_ef and override.mmr_candidates or base.mmr_candidates with conditional expressions that return override.<field> if override.<field> is not None else base.<field>, and do the same for boolean-like fields (exact, acorn, indexed_only, quantization) so False is preserved rather than falling back to base.
125-139: 💤 Low valueConsider handling list and dict values in
_qql_literal.If a parameterized query includes list or dict parameters (e.g., for
INclauses orINSERT VALUES), the current fallback tostr(value)produces Python repr syntax ([1, 2, 3]→"[1, 2, 3]") rather than valid QQL syntax. This may cause parse errors for compound values.♻️ Suggested enhancement
def _qql_literal(value: Any) -> str: if value is None: return "null" if isinstance(value, str): escaped = ( value.replace("\\", "\\\\") .replace("'", "\\'") .replace("\n", "\\n") .replace("\t", "\\t") .replace("\r", "\\r") ) return f"'{escaped}'" if isinstance(value, bool): return "true" if value else "false" + if isinstance(value, list): + return "[" + ", ".join(_qql_literal(v) for v in value) + "]" + if isinstance(value, dict): + items = ", ".join(f"'{k}': {_qql_literal(v)}" for k, v in value.items()) + return "{" + items + "}" return str(value)🤖 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/utils.py` around lines 125 - 139, The _qql_literal function currently falls back to Python's str() for lists and dicts which yields invalid QQL; update _qql_literal to special-case list/tuple and dict: for list/tuple, map each element through _qql_literal and join with ", " inside square brackets (e.g., [elem1, elem2]); for dict, produce a QQL object by iterating items and emitting key: value pairs where keys are serialized as strings (use the same string-escaping logic) and values are produced by _qql_literal, joined with ", " inside curly braces; keep the existing handling for None, str, bool and primitive types and ensure recursion for nested structures.
🤖 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/connection.py`:
- Around line 200-210: The code currently calls
connection.run_queries_batch(self._queries) and then resolves proxies via
proxy._resolve before checking that len(results) == len(self._proxies), which
can cause wrong proxies to be resolved on a shifted/misaligned results list;
change the flow in the batch handling (around connection.run_queries_batch,
self._queries, self._proxies) to first compare len(results) and
len(self._proxies) and if they differ create the RuntimeError, call
proxy._reject(error) for every proxy (not just the tail) and raise the error,
otherwise only then iterate zip(self._proxies, results) and call
proxy._resolve(res) to safely resolve matched pairs.
In `@src/qql/utils.py`:
- Around line 304-309: When expr.value is None the code only handles "=" and
"!=" and lets other ops fall through into the Range construction (Range) which
will receive None; update the null handling in the expr.value is None branch to
defensively handle all non-equality operators by raising a clear error (e.g.
ValueError) or returning a safe Filter instead of falling through. Specifically,
inside the expr.value is None block that builds
IsNullCondition(PayloadField(key=expr.field)) and returns Filter(must_not=[...])
for "!=" make the function reject any op other than "=" or "!=" (using expr.op)
with an explicit error message referencing expr.field and expr.op so Range and
other downstream builders are never called with None.
---
Outside diff comments:
In `@src/qql/connection.py`:
- Around line 192-200: The QQLBatch context manager currently never clears its
internal state causing reused instances to replay stale queries; update
QQLBatch.__exit__ to clear self._queries and self._proxies unconditionally (even
on exception) after calling self.connection.run_queries_batch (or skipping it
when exc_type is present), and also reset/initialize those collections in
QQLBatch.__enter__ so each context entry starts with a fresh state; reference
the QQLBatch class and the __enter__/__exit__ methods and ensure any early
returns still perform the cleanup to avoid stale-proxy semantics.
---
Nitpick comments:
In `@src/qql/utils.py`:
- Around line 466-481: The merge_search_with function currently uses truthy "or"
fallbacks which will ignore valid zero/False values (e.g. override.hnsw_ef==0 or
override.mmr_candidates==0); change each field assignment to use explicit None
checks: replace expressions like override.hnsw_ef or base.hnsw_ef and
override.mmr_candidates or base.mmr_candidates with conditional expressions that
return override.<field> if override.<field> is not None else base.<field>, and
do the same for boolean-like fields (exact, acorn, indexed_only, quantization)
so False is preserved rather than falling back to base.
- Around line 125-139: The _qql_literal function currently falls back to
Python's str() for lists and dicts which yields invalid QQL; update _qql_literal
to special-case list/tuple and dict: for list/tuple, map each element through
_qql_literal and join with ", " inside square brackets (e.g., [elem1, elem2]);
for dict, produce a QQL object by iterating items and emitting key: value pairs
where keys are serialized as strings (use the same string-escaping logic) and
values are produced by _qql_literal, joined with ", " inside curly braces; keep
the existing handling for None, str, bool and primitive types and ensure
recursion for nested structures.
🪄 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: e617cfeb-e5e9-40f1-bf9a-c206c8a7cae8
📒 Files selected for processing (10)
docs/reference.mdsrc/qql/async_connection.pysrc/qql/async_executor.pysrc/qql/connection.pysrc/qql/parser.pysrc/qql/utils.pytests/test_async_connection.pytests/test_connection.pytests/test_executor.pytests/test_parser.py
✅ Files skipped from review due to trivial changes (1)
- docs/reference.md
… management and null support
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/qql/utils.py (1)
315-343:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPreserve null semantics for
IN/NOT INlists.
NULLnow parses as a literal, but only theCompareExprbranch maps it toIsNullCondition.InExprandNotInExprstill lowerNoneas an ordinary list member, so queries likefield IN (NULL)orfield NOT IN (NULL, 1)won't behave consistently with the existingfield = NULL/field != NULLpaths.Please either reject
NULLin these lists or split it into explicit null predicates plus the remaining non-null values before building the scalar match condition.🤖 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/utils.py` around lines 315 - 343, The IN/NOT IN branches must preserve NULL semantics: in the InExpr and NotInExpr handling, detect None values in expr.values and split them from non-null values; for InExpr, if values contain only None return IsNullCondition(is_null=PayloadField(key=expr.field)), if they contain both None and non-null return a Filter with should=[IsNullCondition(is_null=PayloadField(key=expr.field)), FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))], and if no None keep the existing FieldCondition(...MatchAny(...)); for NotInExpr, if values contain only None return Filter(must_not=[IsNullCondition(is_null=PayloadField(key=expr.field))]), if they contain both None and non-null return Filter(must_not=[IsNullCondition(is_null=PayloadField(key=expr.field)), FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))]), and if no None keep the existing MatchExcept behavior; update the branches referencing InExpr, NotInExpr, FieldCondition, MatchAny, MatchExcept, IsNullCondition, PayloadField, and Filter accordingly.
🤖 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 `@tests/test_async_connection.py`:
- Around line 259-262: The two with-block assertions currently only access the
properties ref1.result and ref2.result for their side effects, triggering Ruff
B018; inside each with pytest.raises(RuntimeError, match="Batch result count
mismatch") block, consume the property access by assigning its value to the
throwaway variable (e.g., _ = ref1.result and _ = ref2.result) so the assertions
remain but the linter no longer flags unused property access.
---
Outside diff comments:
In `@src/qql/utils.py`:
- Around line 315-343: The IN/NOT IN branches must preserve NULL semantics: in
the InExpr and NotInExpr handling, detect None values in expr.values and split
them from non-null values; for InExpr, if values contain only None return
IsNullCondition(is_null=PayloadField(key=expr.field)), if they contain both None
and non-null return a Filter with
should=[IsNullCondition(is_null=PayloadField(key=expr.field)),
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))], and if no None
keep the existing FieldCondition(...MatchAny(...)); for NotInExpr, if values
contain only None return
Filter(must_not=[IsNullCondition(is_null=PayloadField(key=expr.field))]), if
they contain both None and non-null return
Filter(must_not=[IsNullCondition(is_null=PayloadField(key=expr.field)),
FieldCondition(key=expr.field, match=MatchAny(any=non_nulls))]), and if no None
keep the existing MatchExcept behavior; update the branches referencing InExpr,
NotInExpr, FieldCondition, MatchAny, MatchExcept, IsNullCondition, PayloadField,
and Filter accordingly.
🪄 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: a84c6f1a-6686-4757-ba54-b396880d19f0
📒 Files selected for processing (9)
src/qql/ast_nodes.pysrc/qql/async_connection.pysrc/qql/connection.pysrc/qql/parser.pysrc/qql/utils.pytests/test_async_connection.pytests/test_connection.pytests/test_executor.pytests/test_parser.py
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/qql/ast_nodes.py (1)
99-103:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAllow
NoneinCompareExpr.valueto match the implemented null-comparison behavior.
build_qdrant_filter()and the new tests already supportCompareExpr(..., value=None), but this dataclass type still rejects it, creating a public typing mismatch.Suggested fix
`@dataclass`(frozen=True) class CompareExpr: """field op literal — covers =, !=, >, >=, <, <=""" field: str op: str # one of: "=", "!=", ">", ">=", "<", "<=" - value: str | int | float | bool + value: str | int | float | bool | None🤖 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 99 - 103, CompareExpr.value's type excludes None even though build_qdrant_filter and tests accept null comparisons; update the CompareExpr dataclass signature to allow None by changing the type of value to include None (e.g., Optional[str | int | float | bool] or str | int | float | bool | None) and import typing.Optional if needed, and adjust the docstring/comment to reflect that null is supported; ensure the symbol to edit is CompareExpr and its value field in ast_nodes.py.
🤖 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.
Outside diff comments:
In `@src/qql/ast_nodes.py`:
- Around line 99-103: CompareExpr.value's type excludes None even though
build_qdrant_filter and tests accept null comparisons; update the CompareExpr
dataclass signature to allow None by changing the type of value to include None
(e.g., Optional[str | int | float | bool] or str | int | float | bool | None)
and import typing.Optional if needed, and adjust the docstring/comment to
reflect that null is supported; ensure the symbol to edit is CompareExpr and its
value field in ast_nodes.py.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 4b174115-5dab-4ee9-933f-005ade53ed3a
📒 Files selected for processing (5)
src/qql/ast_nodes.pysrc/qql/utils.pytests/test_async_connection.pytests/test_connection.pytests/test_executor.py
Summary
This PR upgrades QQL from a primarily sync, one-query-at-a-time execution layer into a stronger application-facing client API.
It adds async execution, sync/async batching, parameterized query helpers, optional gRPC transport, and a shared utility layer that reduces duplicated executor/parser logic.
What Changed
Programmatic API
AsyncConnectionfor async applications usingAsyncQdrantClientAsyncExecutorto mirror the sync executor behavior withasync/awaitQQLBatchandQQLAsyncBatchcontext managersrun_queries_batch()run_parameterized_batch()run_parameterized_query()run_parameterized_batch()prefer_grpcgrpc_portQQL Language
BEGIN BATCH ... END BATCHsyntaxExecutor / Parser Internals
BatchBlockStmtqql.utils, including:Tests and Docs
Why
These changes make QQL more useful inside real Python applications, not only as a CLI.
Before this, repeated programmatic usage was mostly centered around one statement at a time. This PR adds the pieces needed for higher-throughput and async workloads:
Validation
uv run pytest -q635 passedSummary by CodeRabbit
New Features
Behavior
Documentation
Tests