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.
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 |
.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 |
.first |
Execute, return first result |
.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
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. After the block, @posts, @accounts, and @tags are ordinary values you use exactly as before.
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
--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.
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.