Qirava DMS docs
QQL fundamentals & core syntax
QQL (the Qirava Query Language) is the single text surface for everything you do with the DMS: reading, writing, schema (DDL), search, graph, vector, and response shaping. A query is plain text that the engine lexes into tokens, parses into an AST (Stmt), plans onto index-selected reads or WAL-then-mutate writes, and returns as one JSON response envelope. There are no client drivers and no SQL dialect to learn beyond a small, regular grammar. This page covers the fundamentals: how text becomes a query, the injection-safe bound-parameter path, the five core statements, the value/literal types, and the exact response envelope you get back.
How a query is processed#
Every QQL string passes through four stages, all zero-dependency pure Rust. First the lexer (lex) turns the bytes into tokens (identifiers, literals, operators, braces, :name placeholders). Then the parser (Parser) builds a typed AST node (Stmt). The planner/executor (Engine) picks an access path — primary point lookup, secondary index, or full scan — and runs it. Finally the encoder serializes the result into the JSON response envelope. The parser requires that ALL tokens are consumed: leftover tokens are a bad_request ("unexpected trailing tokens").
text ──lex──▶ tokens ──parse──▶ Stmt (AST) ──plan/execute──▶ Response ──to_json──▶ envelope
│
primary point / secondary index / scan
WAL-first for writesStatements run through the executor as a registered function (the qql handler), never by calling the engine directly. In code you use Qdb::execute(qql) (returns the JSON string) or Qdb::execute_bound(qql, binds); over the network you POST the text to /api/qql.
Lexing rules you should know#
- Keywords are case-insensitive (
select,SELECT,Selectall work). Identifiers (table/field names) keep their case. - Strings use double quotes with escapes
\\,\",\n,\t(any other\xkeepsxliterally). Single quotes are NOT string delimiters. =and==both mean equality.!=and<>both mean not-equal. Comparison set:= != < <= > >=.- Numbers are classified by shape: an
e/Emakes aFloat; a.makes an exactDecimal; an integer that fits i64 is anInt; a larger integer is an arbitrary-precisionBigInt. So1.50is an exact Decimal, not a float. true,false,nullare literal values (case-insensitive).- Identifiers may contain
.(soaddr.cityandmyns.feeare single tokens). Dotted field names are resolved as nested paths at read time. :is a bound-parameter marker ONLY in value position (after an operator, opener, comma, or whitespace, and not right after an identifier/string/closer). Elsewhere it is a structural colon (col:type,{ key: val }, RETURN aliases).
Bound parameters (:name) — the injection-safe path#
A :name placeholder is resolved AT PARSE TIME from an out-of-band bindings map into a typed Value that lands directly in the AST. The bound value is never lexed or parsed as QQL, so its content can never change the query's structure. This is the secure way to pass untrusted input: supply it through binds, not by interpolating it into the query text.
Request
-- Positive: the bound name matches exactly one row
SELECT * FROM users WHERE name = :n binds = { n: "ada" }
-- Injection attempt: the payload is treated as ONE string literal
SELECT * FROM users WHERE name = :n binds = { n: 'x" OR "1"="1' }Response
// First query -> { "error": null, "data": [ { "_id": 1, "name": "ada", "age": 36 } ], "root": { "count": 1 } }
// Second query -> { "error": null, "data": [], "root": { "count": 0 } } (matches no literal name; no OR breakout)The five core statements#
- SELECT
SELECT <items> FROM <table> [WHERE ...] [JOIN ...] [SEARCH ...] [TRAVERSE ...] [NEAREST ...] [SORT ...] [LIMIT n] [RETURN {...}]. Items can be*, fields, aggregates (count(*),sum(f), ...), or inline function calls. Optional clauses may appear in any order.- INSERT
INSERT INTO <table> { field: value, ... }. The record is an object literal. The engine assigns the immutable primary key_idautomatically; any_idyou supply is dropped and replaced.- UPDATE
UPDATE <table> SET f1 = v1, f2 = v2 [WHERE ...]. Assigns literal values._idis immutable and silently skipped if you try to set it.- DELETE
DELETE FROM <table> [WHERE ...]. No WHERE deletes every row in the table.- CREATE
CREATE TABLE ...orCREATE INDEX ...— DDL is QQL too (see the DDL pages).
Request
CREATE TABLE users SCHEMALESS
INSERT INTO users { name: "alice", age: 30 }
SELECT name, age FROM users WHERE age >= 30Response
// SELECT response
{ "error": null, "data": [ { "name": "alice", "age": 30 } ], "root": { "count": 1 } }Value and literal types#
| QQL literal | Value type | Notes |
|---|---|---|
| 42 | Int | i64 fast integer |
| 999999999999999999999999999999 | BigInt | any-size integer (too big for i64) |
| 1.50 | Decimal | arbitrary-precision EXACT decimal (money/crypto, no float error) |
| 1.0e9 / 2E-3 | Float | approximate f64 (anything with e/E) |
| true / false | Bool | case-insensitive |
| null | Null | absence / explicit null |
| "text" | Str | double-quoted, with \\ \" \n \t escapes |
| [a, b, c] | List | ordered list; numeric lists double as vectors for NEAREST |
| { k: v } | Map | ordered key→value object (nested records) |
Additional Value kinds the engine stores and returns include Bytes (emitted as a 0x... hex JSON string), Timestamp (nanoseconds since the Unix epoch, emitted as an integer), and Vector (a native f32 embedding). Numbers compare across types exactly: 1 == 1.0 and 9 < 10 numerically (not lexically), via arbitrary-precision comparison when needed.
The response envelope#
Every query returns the same JSON envelope with three top-level keys: error, data, and root. On success error is null and data is a List of row objects (or a single aggregate/RETURN object); on failure error is { code, message } and data is null. root.count is the number of rows in data. Arbitrary-precision numbers (BigInt, Decimal) are emitted as JSON STRINGS to preserve exactness.
{
"error": null,
"data": [ { "_id": 1, "name": "alice", "age": 30 } ],
"root": { "count": 1 }
}{
"error": { "code": "not_found", "message": "table 'ghosts' not found" },
"data": null,
"root": { "count": 0 }
}| Error code | Meaning |
|---|---|
| bad_request | parse error, schema/type violation, or bad argument |
| not_found | table (or join/edge table) not found |
| conflict | table already exists, or a unique index was violated |
| worm_violation | tried to update/delete a write-once (WORM) table |
| retention_locked | tried to delete a row still inside its retention window |
| unsupported | feature not available (e.g. inline call with no runtime attached) |
Calling QQL over HTTP#
POST the raw QQL text as the body of /api/qql, or use GET /api/qql?q=<query> for read-style calls. The body IS the statement (no JSON wrapper). Authentication, when a route is covered by an auth group, is per-request via an X-API-Key header or Authorization: Bearer <key>.
Request
curl -X POST http://localhost:8080/api/qql \
--data 'INSERT INTO users { email: "ada@example.com", age: 36 }'
curl 'http://localhost:8080/api/qql?q=SELECT%20*%20FROM%20users'Response
{ "error": null, "data": [ { "_id": 1, "email": "ada@example.com", "age": 36 } ], "root": { "count": 1 } }