Query Builder
Build complex database queries with a fluent, chainable API. The QueryBuilder lets you compose filters, sorting, pagination, aggregations, and eager loading step by step.
Backends. Hash-style
.where({ "field": value }) — including comparisons
({ "gt": 10 }), IN, LIKE, and
OR — plus order/limit/offset, count, exists, aggregates,
and group_by compile on
SoliDB and every SQL adapter (postgres,
mysql, sqlite), in both document and column mode.
The string/raw SDBQL form
.where("doc.age >= @age") is SoliDB-only — on a SQL connection it
raises rather than guessing (use Model.find_by_sql for a raw query there).
.join and .having compile on SQL document tables
and column-aware models:
.join as a correlated EXISTS, .having as one
comparison of a group key or aggregate alias against a number. See
SQL document backends.
Query Builder Chaining
Chain methods to build complex queries. You can start a chain with .where(), .order(), or .limit():
# Start with .where()
results = User
.where("age >= @age", { "age": 18 })
.where("active == @active", { "active": true })
.order("created_at", "desc")
.limit(10)
.offset(20)
.all
# Start with .order() — no filter needed
recent = User.order("created_at", "desc").limit(5).all
# Start with .limit()
sample = User.limit(3).all
# Get first result only
first = User.where("email == @email", { "email": "alice@example.com" }).first
# Count with conditions
count = User.where("role == @role", { "role": "admin" }).count
# Bulk delete — one AQL REMOVE for the whole match (no N+1 loop)
User.where("active == @a", { "a": false }).delete_all
post.comments.delete_all # via has_many relation
# Bulk update — one AQL UPDATE for the whole match (no N+1 loop)
User.where("active == @a", { "a": false }).update_all({ "archived": true })
QueryBuilder Methods
All chainable methods available on the QueryBuilder. Methods like .where(), .order(), .limit(), and .includes() return a new QueryBuilder, while .all, .first, .count, .delete_all, and .update_all execute the query.
| Method | Description |
|---|---|
.where(filter, bind_vars) |
Add filter condition (ANDed) |
.order(field, direction) |
Set sort order ("asc"/"desc") |
.limit(n) |
Limit results to n documents |
.offset(n) |
Skip first n documents |
.timeout(secs) |
Allow this one query secs seconds instead of the 10s default — see Query timeouts |
.paginate(hash) |
Terminal: fetch paginated results + metadata. Args: page (default 1), per (default 25). Returns {"records": [...], "pagination": {"page": n, "per": n, "total": n, "total_pages": n}} |
.all |
Execute, return all results |
.find_each(block, opts?) |
Terminal: walk the whole match one record at a time, holding one batch ({"batch_size": 1000}) in memory — see Batch Iteration |
.in_batches(block, opts?) |
As .find_each, but the block receives each batch as an array. Alias: find_in_batches |
.first |
Execute, return the first record, or null when there is none |
.first(n) |
Execute, return the first n records as an array. Equivalent to .limit(n).all; an existing .limit() is overridden. Not available on aggregate or exists queries, which return a single value rather than rows. |
.count |
Execute, return count |
.exists |
Set exists mode (chain with .first to execute, .to_query to inspect) |
.delete_all |
Bulk hard-delete every row matching the accumulated .where/.join clauses in a single AQL REMOVE. Ignores .order/.limit/.offset/.select/.group_by (they don't compose with REMOVE). Hard delete — bypasses soft-delete mode. Returns null. |
.update_all(hash) |
Bulk-patch every row matching the accumulated .where/.join clauses with hash in a single AQL UPDATE. Skips validations and lifecycle callbacks; ignores .order/.limit/.offset/.select/.group_by (they don't compose with UPDATE). Returns null. |
.similar(query, field?, top_k?, opts?) |
Vector similarity search (chain with .all to execute). With a declared vector_index it pushes down to the database's HNSW ANN index; opts: { "exact": true } forces the exact client-side cosine path. Text queries need SOLI_EMBEDDING_API_KEY; vector literals don't. See Search. |
.pluck(field, ...) |
Set pluck mode for specified fields (chain with .all to execute). Field names may be strings or symbols — .pluck(:id, :title) — as in every field-taking builder method. |
.sum(field) |
Set sum aggregation (chain with .first to execute) |
.avg(field) |
Set average aggregation (chain with .first to execute) |
.min(field) |
Set minimum aggregation (chain with .first to execute) |
.max(field) |
Set maximum aggregation (chain with .first to execute) |
.group_by(field, func, agg_field) |
Legacy group-by aggregation — returns [{group, result}] (chain with .all to execute) |
.group_by(fields) |
Grouped mode — field name or array of names; combine with .aggregate, or alone for an implicit count per group (n). Chain .all. See Grouped Aggregation. |
.aggregate(spec) |
Multi-aggregate spec { alias: [func, field] } or { alias: ["count"] } — funcs: sum, avg, min, max, count, count_distinct, median, stddev, variance. Ungrouped: one row, chain .first. |
.having(expr, binds?) |
Post-COLLECT filter over bare group fields / aggregate aliases. Developer-trusted string — never build it from user input. |
.median(field) / .stddev / .variance / .count_distinct |
Statistical aggregation terminals (chain with .first to execute) |
.time_bucket(interval, aggs?) |
Timeseries models only: group rows into fixed time buckets with per-bucket aggregates (chain with .all to execute) |
.includes(rel, ...) |
Eager load relations via subqueries |
.includes(rel, filter, binds) |
Eager load with filter and optional "fields" key |
.includes({ rel: [fields] }) |
Eager load with field projection |
.select(field, ...) |
Select specific fields on the main collection |
.fields(field, ...) |
Alias for .select() |
.join(rel, filter?, binds?) |
Filter by existence of related records |
.to_query |
Return the generated SDBQL string (for debugging) |
Query Generation (SDBQL)
Under the hood, QueryBuilder methods generate SDBQL (SoliDB Query Language) queries. Use .to_query to inspect the generated output:
| Soli Code | Generated SDBQL |
|---|---|
User.all |
FOR doc IN users RETURN doc |
User.where("age >= @age", {"age": 18}) |
FOR doc IN users FILTER doc.age >= @age RETURN doc |
User.order("name", "asc").all |
FOR doc IN users SORT doc.name ASC RETURN doc |
User.order("name", "asc").limit(10).all |
FOR doc IN users SORT doc.name ASC LIMIT 10 RETURN doc |
.limit(10).offset(20) |
... LIMIT 20, 10 RETURN doc |
User.where({}).paginate({"page": 2, "per": 10}) |
Terminal — returns {"records": [...], "pagination": {"page": 2, "per": 10, "total": 45, "total_pages": 5}} |
User.count |
RETURN COLLECTION_COUNT("users") |
User.includes("posts") |
FOR doc IN users LET _rel_posts = (FOR rel IN posts FILTER rel.user_id == doc._key RETURN rel) RETURN MERGE(doc, {posts: _rel_posts}) |
.includes("posts", "published = @p", {"p": true}) |
... FILTER rel.user_id == doc._key AND rel.published == @p RETURN rel ... |
.includes({"posts": ["title"]}) |
... RETURN {title: rel.title} ... |
User.select("name", "email") |
FOR doc IN users RETURN {name: doc.name, email: doc.email, _key: doc._key} |
User.join("posts") |
FOR doc IN users FILTER LENGTH(FOR rel IN posts FILTER rel.user_id == doc._key LIMIT 1 RETURN 1) > 0 RETURN doc |
.exists.to_query |
FOR doc IN users ... LIMIT 1 RETURN true |
.pluck("name").to_query |
FOR doc IN users ... RETURN doc.name |
.pluck("name", "email").to_query |
FOR doc IN users ... RETURN {name: doc.name, email: doc.email} |
.sum("balance").to_query |
FOR doc IN users ... RETURN SUM(doc.balance) |
.group_by("country", "sum", "balance").to_query |
FOR doc IN users ... COLLECT group = doc.country AGGREGATE result = SUM(doc.balance) RETURN {group: group, result: result} |
SDBQL Syntax
FOR doc IN collectioninstead ofSELECT * FROMFILTER expressioninstead ofWHERESORT doc.field ASC/DESCinstead ofORDER BY@variablesyntax for bind parametersLETsubqueries +MERGEfor eager loading
Hash filter operators
The hash form of .where is not equality-only. All of the
behaviour below is verified per backend — the surprises are where the engines differ.
The three value forms
A scalar means equality, an array means IN, and a hash means comparisons.
Several operators on one field are ANDed, and several fields are ANDed.
Order.where({
"status": "open", # equality
"total": { "gte": 100, "lt": 500 }, # both, ANDed
"region": ["eu", "us"], # IN
"or": [{ "rush": true }, { "vip": true }]
})
or and and take an
array of hashes (an empty array is an error) and may nest;
any hash inside them is a full filter again.
Semantics worth knowing
- Ordering comparisons follow the value's type. A number compares numerically; a string compares as text, which is what makes ISO-8601 dates work:
{ "issued_at": { "gte": "2026-01-01" } }. nulland missing keys.{ "f": null }isIS NULL;{ "f": { "ne": null } }isIS NOT NULL; any other operator againstnullis an error rather than a clause that silently never matches. A document that never had the key reads asNULL, so{ "nope": null }matches every row.INdetails. An empty list matches nothing (a false predicate, not a syntax error). Anullelement addsIS NULL, since SQLINnever matchesNULL. Each element keeps the semantics equality would give it, so a list may mix strings and numbers.- Numbers compare differently per backend on document tables: Postgres compares
jsonbnumerically, so{ "v": 10 }matches a stored10.0; MySQL and SQLite compare the JSON representation, where it does not. Store numbers consistently if you filter on them. - Index interaction. String equality and string
INcompile to the JSON text extract — the expression anindexdeclaration creates — so they can use it. Numeric comparisons use a numeric cast, which that index does not cover. - On column-aware models (
table "orders") the same hash compiles against real columns with typed binds, so each comparison is the column's own type and ordering on a text column follows the database's collation. A field that is not a column is refused by name, listing the columns that exist. - Operator spellings. Each comparison also answers to its symbol, so
{ "total": { ">": 100 } }and{ "total": { "gt": 100 } }compile to the same predicate:>>=<<=for the four comparisons,==or=foreq, and!=or<>forne.or/andare matched case-insensitively. - Errors name the vocabulary. An unknown operator reports
gt, gte, lt, lte, eq, ne, like, ilike, inand the symbols; a field name that is not an identifier is refused before anything is compiled.
like / ilike case sensitivity
Both need a string pattern (% = any run, _ = one character). Case
behaviour is where the backends genuinely differ:
| Backend | like |
ilike |
|---|---|---|
| Postgres | case-sensitive | ILIKE |
| SQLite | case-insensitive for ASCII (its LIKE is) | LOWER(x) LIKE LOWER(?) |
| MySQL | depends on the column/collation | LOWER(x) LIKE LOWER(?) |
| SoliDB | SDBQL LIKE | also LIKE — no separate case-folding |
If case behaviour matters, use ilike and get the same
answer everywhere.
Batch Iteration (find_each / in_batches)
.all materialises every matching row as Soli values at once, so a job over a large
collection is bounded by RAM rather than by anything you chose — and .each is not an
escape hatch, because it materialises first and then iterates. find_each
walks the whole result set one record at a time, holding only one batch in memory.
# One batch of 1000 in memory at a time, however large the collection is
User.find_each(fn(user) {
user.recompute_score()
})
# Any filter chain composes, and the batch size is yours to pick
User.where("active == @a", { "a": true }).find_each(fn(user) {
user.backfill_initials()
}, { "batch_size": 500 })
# in_batches hands the block the whole batch — for work that is itself bulk
Invoice.where("status == @s", { "s": "pending" }).in_batches(fn(batch) {
Mailer.deliver_reminders(batch)
}, { "batch_size": 200 })
Paging is by key, not by offset. Each batch adds
FILTER doc._key > <last key of the previous batch> and sorts by _key
ascending. LIMIT offset, n degrades as the offset grows and — worse — skips or
repeats rows when the collection is written to mid-scan, which is exactly what a long correction job does
to the collection it is correcting. Deleting or updating records inside the block is therefore safe: the
cursor is read before the block runs. batch_size defaults to 1000 and is capped at 10,000.
Refused, not silently ignored
- The ordering is fixed and the batch size is the limit, so
.order(),.limit()and.offset()all raise. .pluck()raises — projected rows carry no_keyto position the next batch.- Aggregates,
.group_by(),.time_bucket()and.existsraise — they return a value or reshaped rows, not records..similar()raises because its score ordering would be overridden. - Calling one inside
grouped(...)raises: each batch needs the previous batch's rows to build the next query, so the reads cannot be coalesced. - A columnar model has no
_key, so it has no batch iteration — use itsquery/aggregatesurface instead. - These are errors rather than dropped clauses because these methods drive data-correction jobs, where a silently ignored
.orderis a wrong run that still reports success.
Coalescing Reads (grouped)
An action that reads several unrelated things pays one network round-trip per read. Wrap the reads in grouped(fn() { ... }) and they are deferred and combined into a single request — one LET … RETURN […] statement that computes every subquery server-side and returns them together.
# Without grouped — three round-trips
@posts = Post.all
@accounts = Account.where({ active: true }).count
@tags = Tag.all
# With grouped — one round-trip for all three
grouped(fn() {
@posts = Post.all
@accounts = Account.where({ active: true }).count
@tags = Tag.all
})
Inside the block each read returns a placeholder instead of hitting the database; the queries fire as one combined statement when the block ends, and the results are then materialised. After the block you use @posts, @accounts, and @tags exactly as before — iterate them, index them, read fields, serialise them to JSON.
Strictly speaking the binding stays a thin placeholder that resolves to its value on use, rather than being replaced by one. That is invisible in normal code, but it is why a new way of consuming a value can occasionally need teaching to unwrap it — if you ever see an error naming a type that the value plainly is (cannot iterate over array), that is the shape of the bug, and it is worth reporting.
What gets coalesced
Read queries are batched: all, where(...).all / .first / .count / .exists, the aggregates (sum / avg / min / max), find, find_by, and first_by. Writes are not — create, save, update, delete run immediately even inside the block (use transaction for atomic writes).
Reading a result inside the block (auto-flush)
If you read one of the deferred results before the block ends, the queries collected so far fire immediately (an "auto-flush"), then collection resumes. This always returns correct data; it just means more than one round-trip when you interleave reads. For maximum coalescing, do the reads first and use the results after the block.
Notes
findon a missing id still raisesRecordNotFound(→ 404) — the error surfaces when the result is read or the block ends.- A combined query is all-or-nothing: if it fails, every read in the batch fails together.
- In interactive
--devthe reads are not coalesced — each runs as its own query so the dev query log stays readable instead of showing one combinedLET … RETURN […]. Coalescing is active in production, where the single round-trip matters. soli testcoalesces. The test server runs with--devso the query log is populated, but specs deliberately exercise the production shape — otherwise the coalescing path would go untested andassert_query_countwould measure dev's un-coalesced number. A grouped action reports one query in a spec, not one per read.
Finding reads that should be grouped
Because grouped is for reads that each run once, an N+1 scan can never point you at them: it fingerprints by query template and only fires on a repeated one. Three unrelated reads are three distinct templates with a count of one each — invisible to assert_no_n_plus_one and to the dev bar's N+1 badge. Two tools cover that blind spot:
- The dev bar query panel shows an amber
N READS · N ROUND-TRIPSadvisory when a request issues three or more distinct one-off reads outside anygroupedblock. Amber, not red: it is a suggestion, because reads that feed each other cannot share a round-trip. assert_no_ungrouped_reads(response)fails a spec on the same condition.
test("the dashboard loads in one round-trip", fn() {
let response = get("/dashboard")
assert_no_ungrouped_reads(response)
assert_query_count(response, 1)
})
Neither can prove the reads are independent — User.find(id) followed by a query on user._key is genuinely two round-trips. When the reads must be sequential, that advisory is a false positive and the assertion is the wrong tool for that action.
Query timeouts (timeout)
Every read reaches SoliDB over HTTP, and that client gives a request 10 seconds. The limit is deliberately tight — it is the backstop that stops a stalled connection from pinning a worker — but a genuine long-running query outgrows it: a report over a large collection, a multi-aggregate group_by, an import-sized scan. Such a query fails with Error: HTTP error: error sending request for url … after ten seconds no matter how healthy the database is.
.timeout(secs) raises the limit for one query:
# A slow report gets two minutes; every other query on the
# process keeps the 10s default.
let rows = Order
.where({ "created_at": { "gte": start_of_year } })
.group_by(["region", "channel"])
.aggregate({ "revenue": ["sum", "total"], "n": ["count"] })
.timeout(120)
.all()
# Also a static entry point, so no .where is needed
Order.timeout(120).all()
- Seconds,
IntorFloat—.timeout(0.5)is a valid half-second budget. A zero, negative, or non-numeric value raises rather than being ignored, so a typo cannot silently leave the 10s default in place. - Chainable and position-independent, like
.limit. - Scoped to the one query. The override is installed for that request and reverted the moment it finishes, error included — it never leaks into the next query.
- Lower it too. Nothing stops
.timeout(2)on a query you would rather see fail fast than have a user wait on.
Inside a grouped block the reads become a single request, and the batch runs under the largest .timeout any member asked for — one slow member is not capped by the fast reads sharing its round-trip. A raw db.query read against the same host and database joins that batch, so a sibling Model.timeout(120) covers it; a write via db.query still runs immediately, like Model create / update / delete.
grouped(fn() {
@summary = Order.group_by(["region"]).timeout(120).all # the slow one
@tags = Tag.all # rides along
@heavy = db.query("FOR d IN orders COLLECT … RETURN d") # joins the batch
})
# one request, 120s of room
The QueryBuilder form does not apply to a Solidb client. Raise that path with db.timeout(secs).query(sdbql) (persists on the client until you change it) or a per-call options hash:
rows = db.timeout(60).query(sdbql)
rows = db.query(sdbql, binds, { "timeout": 60 })
In --dev, grouped does not coalesce (so the query log stays one row per read). Put .timeout on each slow query, including db.query.
No effect on the SQL adapters
Postgres, MySQL and SQLite talk to the database over their own connection pool rather than HTTP, so the 10s cap this option lifts does not exist there — and there is no statement timeout to set in its place. The call is accepted so a query stays portable across adapters, but on SQL the query runs as long as the server lets it. Bound it with the server's own knob: Postgres statement_timeout, MySQL max_execution_time.
Graph Traversal (traverse)
On models connected by an edge model, record.traverse(EdgeModel, options?) returns a QueryBuilder over the reachable vertices — it chains like any other builder. Vertex filters use the usual doc variable; prefix a string filter with edge. to match edge attributes. direction is "out" (default), "in", or "any"; depth is an Int n (depth 1..n) or a [min, max] array:
friends = alice.traverse(Follow).all # OUTBOUND, depth 1..1
followers = alice.traverse(Follow, direction: "in").all # INBOUND
# Chain filters, ordering, limits — vertex variable is `doc`
ring = alice.traverse(Follow, depth: [2, 3], direction: "any")
.where({ "active": true })
.where("edge.since >= @y", { "y": 2024 }) # edge-attribute filter
.order("name")
.limit(10)
.all
n = alice.traverse(Follow).count
A traversal builder does not compose with includes, includes_count, join, group_by, update_all, or delete_all — combining them raises a clear error. .similar() does compose — rank reachable vertices by semantic relevance (exact client-side cosine; no HNSW pushdown on graph queries). traverse requires a saved record. Note the depth gotcha: Soli's 1..3 range literal materializes to the exclusive-end array [1, 2] — prefer an explicit [1, 3].
Shortest path
shortest_path is not chainable — it executes immediately and returns the vertices along the shortest path ([] when unconnected). Direction defaults to "any":
path = alice.shortest_path(bob, via: Follow)
path = alice.shortest_path("users/bob-key", via: Follow, direction: "out")
Time Buckets (time_bucket)
On timeseries models, .time_bucket(interval, aggregates?) groups rows into fixed time buckets and computes per-bucket aggregates. Chain it after .where() filters and finish with .all:
rows = Metric.where("device = @d", { "d": "srv1" })
.time_bucket("5m", { "avg": "value", "max": "value" })
.all
# => [{ "bucket": "2026-07-05T10:00:00+00:00", "avg": 0.41, "max": 0.9 }, ...]
rows = Metric.time_bucket("1d").all # bare form: count per bucket
rows = Metric.time_bucket("1h", avg: "value").all # keyword style works too
Notes
- Interval units:
s,m,h,d(the database'sTIME_BUCKETcontract). - Aggregates:
sum/avg/min/maxtake a field name;counttakestrue({ "count": true }). With no aggregates you get acountper bucket. - Buckets fall on the model's declared
timestamp:field (default_created_at). - Rows come back as plain hashes —
bucket(an RFC3339 string) plus your aggregate aliases — sorted by bucket.
Grouped Aggregation (group_by / aggregate / having)
Group on one or more fields and compute several named aggregates at once. In grouped mode .order() must name a group field or an aggregate alias; limit/offset compose as usual:
rows = Order
.where({ "status": "paid" })
.group_by(["country", "plan"])
.aggregate({ "total": ["sum", "amount"], "n": ["count"] })
.having("total > @min", { "min": 1000 })
.order("total", "desc")
.all
# => [{ "country": "FR", "plan": "pro", "total": 5300, "n": 12 }, ...]
User.group_by("role").all # 1-arg form: implicit count per group ("n")
Order.aggregate({ "n": ["count"] }).first # ungrouped: one row
Order.median("amount").first # also: stddev / variance / count_distinct
Notes
- Funcs:
sum,avg,min,max,count,count_distinct,median,stddev,variance. NoPERCENTILE— SolidB has no such function. havingfilters after the COLLECT, over bare aliases — a developer-trusted string, like the string form ofwhere. Never build it from user input.- New grouped queries respect the soft-delete scope; the legacy 3-arg
group_by(field, func, agg_field)never did (unchanged for compat). - The full treatment — plus columnar stores and the window-function escape hatch — lives in Analytics & Columnar Stores.