ESC
Type to search...
S
Soli Docs

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 collection instead of SELECT * FROM
  • FILTER expression instead of WHERE
  • SORT doc.field ASC/DESC instead of ORDER BY
  • @variable syntax for bind parameters
  • LET subqueries + MERGE for 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 notcreate, 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

  • find on a missing id still raises RecordNotFound (→ 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 --dev the reads are not coalesced — each runs as its own query so the dev query log stays readable instead of showing one combined LET … 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's TIME_BUCKET contract).
  • Aggregates: sum / avg / min / max take a field name; count takes true ({ "count": true }). With no aggregates you get a count per 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. No PERCENTILE — SolidB has no such function.
  • having filters after the COLLECT, over bare aliases — a developer-trusted string, like the string form of where. 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.

Next Steps