Use qql-core when your service needs a transport-free parser and AST. Add qql when the same process should execute plans against Qdrant.
cargo add qql-core qqlQUERY 'laptops' FROM products USING dense LIMIT 10;Parse once, then enforce policy
Section titled “Parse once, then enforce policy”The parser requires complete input. Retain the typed Stmt and rewrite that object before planning or execution. The Filter injection guide details how the injected predicate lands in each statement type.
use qql_core::ast::{inject_filter, ComparisonOp, Value};use qql_core::parser::Parser;
let mut stmt = Parser::parse(query)?;inject_filter( &mut stmt, "tenant_id", ComparisonOp::Eq, Value::Str("org_99".into()),)?;
// Optional request routing. This is not tenant isolation.stmt.set_shard_key(Some("org_99".into()));Execute a statement or script
Section titled “Execute a statement or script”Executor::execute accepts one QQL source string. Use OnError::Stop to halt at the first failure or OnError::Continue to collect a report for every statement in a script.
use qql::executor::{Executor, OnError};
let executor = Executor::rest("http://localhost:6333", None)?;let report = executor.execute(query, OnError::Stop).await?;
assert!(report.ok);println!("{} operations succeeded", report.succeeded);Parameter binding & prepared queries
Section titled “Parameter binding & prepared queries”Rust keeps typed twins: execute_with_params / execute_with_positional_params on Executor, and qql_core::params::{bind_named, bind_positional} for standalone binding. Python, Node, and WASM collapse those into one bind(query, params) plus execute(..., params=...).
use std::collections::HashMap;use qql::executor::{Executor, OnError};use qql_core::ast::Value;use qql_core::params::{bind_named, bind_positional};
let executor = Executor::rest("http://localhost:6333", None)?;
// Named parameters (:name)let mut params = HashMap::new();params.insert("cat".into(), Value::Str("laptops".into()));params.insert("lim".into(), Value::Int(10));let report = executor.execute_with_params( "QUERY 'ultrabook' FROM products WHERE category = :cat LIMIT :lim", ¶ms, OnError::Stop,).await?;
// Positional parameters (?)let pos_params = vec![Value::Str("laptops".into()), Value::Int(10)];let report2 = executor.execute_with_positional_params( "QUERY 'ultrabook' FROM products WHERE category = ? LIMIT ?", &pos_params, OnError::Stop,).await?;
// Standalone binding (qql_core::params)let bound = bind_named("QUERY :q FROM products LIMIT :lim", |k| match k { "q" => Some(Value::Str("thinkpad".into())), "lim" => Some(Value::Int(5)), _ => None,})?;For text input, configure an embedder or provide a QUERY VECTOR / explicit vector value. The runtime also exposes Executor::grpc behind its gRPC feature.
Bulk ingest takes point values — payload as data, never SQL text. One :rows template is prepared once (schema fetched once), then each chunk splices through the point-splice path with no re-parse:
let rows: Vec<qql_core::ast::Value> = vec![ // {id: 1, vector: {dense: [...]}, …payload} per entry];let report = executor.upsert_many("docs", rows, 100, OnError::Stop).await?;Chunks move (never clone) through the point-splice path; the peak live-set is the caller's rows vector by construction — for million-row ingests, chunk the calls, since upsert_many chunks transport, not memory.
Scroll large collections manually
Section titled “Scroll large collections manually”Rust has no scroll helper type; loop SCROLL ... AFTER yourself with the same clause order the Python and Node helpers emit (WHERE then AFTER then SHARD then WITH VECTOR then LIMIT). Keep at most one page buffered and stop on the first empty page. Guard against a non-advancing cursor.
let mut cursor: Option<qql_plan::PlanPointId> = None;loop { let page = match &cursor { None => "SCROLL FROM products LIMIT 100".to_string(), // Integer ids interpolate directly; quote string ids. Some(id) => format!("SCROLL FROM products AFTER {id} LIMIT 100"), }; let report = executor.execute(&page, OnError::Stop).await?; let hits = report.first_hits().unwrap_or_default(); if hits.is_empty() { break; } let next = hits.last().map(|h| h.id.clone()); if next == cursor { break; } for hit in &hits { println!("hit {}", hit.id); } cursor = next;}For the managed equivalent, see Python Client.scroll_cursor and Node scrollCursor on their SDK pages.
Profile before you optimize
Section titled “Profile before you optimize”Executor::explain_analyze (plus _with_named_params / _with_params twins) runs one statement and returns the static plan plus measured client phase timings and honest server telemetry. Every ExecResponse also carries telemetry: Option<ServerTelemetry> (time_s + hardware/inference usage, None where the route reports nothing), and typed result accessors (hits(), points(), ids(), facet(), count(), groups(), collections(), collection(), shard_keys(), quotas()) read from a cached typed representation — no JSON round-trip on the gRPC leg. The response model is closed (ExecData has no raw passthrough; the legacy *_json accessors are removed) and REST responses are parsed strictly per operation, failing closed with QQL-BACKEND-ENVELOPE on a missing or mistyped field. SearchHit carries id, score, payload, collection, and vector only — no text, version, or shard_key; derive text from payload["text"]:
let analysis = executor.explain_analyze( "QUERY 'ultrabook' FROM products USING dense LIMIT 10", OnError::Stop,).await?;println!("server: {:?}s", analysis.server_time_s);println!("dispatch: {}ms", analysis.phases.dispatch_ms);Pin reads with route affinity
Section titled “Pin reads with route affinity”Qdrant 1.19+ can pin subsequent reads to a stable replica using the X-Qdrant-Route-Affinity header (HTTP) or equivalent gRPC metadata. This is transport configuration, not a QQL clause — set it on the client when you construct REST or gRPC access:
use qql::rest::RestQdrant;// use qql::grpc::GrpcQdrant; // with the `grpc` feature
let client = RestQdrant::new("http://localhost:6333", None) .with_route_affinity("session-or-user-id");
// GrpcQdrant::new(...).with_route_affinity("session-or-user-id")Pass a stable session or user identifier so Qdrant can hash affinity consistently. Empty strings are treated as unset. The same helper exists on GrpcQdrant, and the Python (pyqql.Client(route_affinity=…)), Node (new Client({ routeAffinity })), and WASM (client.setRouteAffinity(key)) clients expose the equivalent option.
Core API
Section titled “Core API”Parse exactly one complete statement.
Parse one semicolon-delimited script.
Add a trusted predicate to a parsed statement.
Plan and execute one statement or a script against the configured backend.
| Surface | Function or type | Purpose |
|---|---|---|
| Parse | Parser::parse, Parser::parse_all | Strict single-statement or script parsing |
| Inspect | lexer::Lexer, explain, explain_all | Tokens and human-readable AST explanation |
| Rewrite | inject_filter, Stmt::set_shard_key | Trusted AST mutation before planning |
| Plan | qql_plan::plan::plan, to_rest_route | Offline IR and Qdrant REST projection |
| Execute | Executor::execute, execute_batch, execute_node | Source, batch, or owned-statement execution |
| Transport | RestQdrant::with_route_affinity, GrpcQdrant::with_route_affinity | Pin reads via X-Qdrant-Route-Affinity |
| Embed | Embedder, resolve_embeddings | Resolve text, sparse, multi, image, or rerank inputs |
For offline route inspection, use qql_plan::plan::plan followed by qql_plan::plan::to_rest_route before configuring any transport.