Query Builder
quark.For[Model](ctx, client) returns a *quark.Query[Model]. The builder is
immutable: every method that changes query state returns a clone.
base := quark.For[User](ctx, client).
Where("active", "=", true)
admins := base.Where("role", "=", "admin")
editors := base.Where("role", "=", "editor")
base, admins, and editors are independent. This makes it comfortable to
package common filters as scopes or reuse a base query across service methods.
Execution Methods
| Method | Use it when |
|---|---|
List() | You want a slice. Call Limit deliberately. |
First() | You want one matching row or ErrNotFound. |
Find(id) | You want one row by a simple primary key. |
Count() | You need a matching-row count. |
Paginate(size, page) | You need rows plus total/page metadata. |
Iter(fn) | You want streaming callback processing. |
Cursor() | You want manual streaming control. |
List applies a safe default limit of 100 when no explicit Limit is present.
For exports, backfills, or queues, use Iter or Cursor so rows are streamed
instead of collected into memory.
Basic Filters
users, err := quark.For[User](ctx, client).
Where("active", "=", true).
Where("age", ">=", 18).
Where("email", "LIKE", "%@acme.com").
OrderBy("created_at", "DESC").
Limit(25).
List()
Column names and operators are validated before SQL is generated. Values are sent as bound parameters.
| Operator family | Examples |
|---|---|
| Equality | =, !=, <> |
| Comparison | <, <=, >, >= |
| Pattern | LIKE, NOT LIKE |
| Null | IS NULL, IS NOT NULL |
| Set/range | IN, NOT IN, BETWEEN, NOT BETWEEN |
Null checks do not consume a value:
users, err := quark.For[User](ctx, client).
Where("deleted_at", "IS NULL", nil).
List()
Set and Range Predicates
users, err := quark.For[User](ctx, client).
WhereIn("role", []any{"admin", "editor"}).
WhereBetween("created_at", start, end).
List()
Avoid calling WhereIn with an empty slice. Most SQL engines reject IN (), and
an empty input is usually better handled in application code:
if len(ids) == 0 {
return []User{}, nil
}
users, err := quark.For[User](ctx, client).WhereIn("id", ids).List()
Negation and OR Groups
WhereNot wraps the condition in NOT:
users, err := quark.For[User](ctx, client).
WhereNot("banned", "=", true).
List()
Or accepts a callback. Conditions inside the callback are grouped together and
joined with AND, then the group is attached to the outer query with OR.
users, err := quark.For[User](ctx, client).
Where("active", "=", true).
Or(func(q *quark.Query[User]) *quark.Query[User] {
return q.Where("role", "=", "admin").
Where("verified", "=", true)
}).
List()
This produces the shape:
WHERE active = ? OR (role = ? AND verified = ?)
Ordering, Limits, and Pagination
users, err := quark.For[User](ctx, client).
OrderBy("created_at", "DESC").
OrderBy("id", "ASC").
Limit(20).
Offset(40).
List()
Paginate(pageSize, page) runs a count query and a limited select:
page, err := quark.For[User](ctx, client).
Where("active", "=", true).
OrderBy("id", "ASC").
Paginate(20, 0)
fmt.Println(page.Total)
fmt.Println(page.TotalPages)
fmt.Println(page.Items)
Pages are zero-indexed. Paginate(20, 0) returns the first page.
Selecting Columns
Select accepts column identifiers from the model table:
users, err := quark.For[User](ctx, client).
Select("id", "email", "name").
Limit(100).
List()
Because selected columns are validated as identifiers, Select is intentionally
not a raw SQL projection API. Expressions such as COUNT(*) AS count,
users.id, or orders.total AS order_total should be handled by the aggregate
helpers, a view model over a database view, or RawQuery with explicit
AllowRawQueries.
Distinct works with simple selected columns:
roles, err := quark.For[User](ctx, client).
Select("role").
Distinct().
Limit(50).
List()
Aggregates
Use aggregate helpers for common numeric summaries:
total, err := quark.For[Order](ctx, client).
Where("status", "=", "paid").
Sum("amount")
avg, err := quark.For[Order](ctx, client).Avg("amount")
min, err := quark.For[Order](ctx, client).Min("amount")
max, err := quark.For[Order](ctx, client).Max("amount")
Count, Sum, Avg, Min, and Max respect Where, soft-delete filters,
and tenant isolation.
GroupBy and Having are available for simple grouped model queries:
rows, err := quark.For[Order](ctx, client).
Select("status").
GroupBy("status").
Having("status", "!=", "cancelled").
Limit(20).
List()
For grouped aggregate projections, prefer a database view plus a typed model or controlled raw SQL until Quark grows a typed expression API.
JSON Predicates
WhereJSON asks the dialect to build a JSON extraction expression:
users, err := quark.For[User](ctx, client).
WhereJSON("metadata", "plan", "=", "enterprise").
List()
Path grammar
Quark accepts dotted identifier paths:
| Accepted | Rejected |
|---|---|
"name" | "" (empty) |
"user.name" | "$.user.name" (leading $) |
"user.profile.email" | ".user" / "user." / "user..name" |
"_private.field" | "1user" (digit-leading segment) |
"x1.y2.z3" | "user-name" / "user name" / quotes / semicolons / SQL comments |
The exact regex is ^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$, total
length capped at 256 characters. Anything else returns
ErrInvalidJSONPath at execution time. Array
indexes (items[0].id / items.0.id) and engine-specific JSONPath operators
are out of scope — drop down to RawQuery for
those cases.
Injection safety
The path is bound as a parameter, never interpolated into the SQL surface. A path that matches the grammar above produces:
| Dialect | Shape (with column = "metadata", path = "plan") |
|---|---|
| PostgreSQL | jsonb_extract_path_text(("metadata")::jsonb, $1) = $2 (one bind per path segment) |
| MySQL | JSON_EXTRACT(\metadata`, ?) = ?(path bound as"$.plan"`) |
| MariaDB | JSON_VALUE(\metadata`, ?) = ?` |
| SQLite | JSON_EXTRACT("metadata", ?) = ? |
| SQL Server | JSON_VALUE([metadata], @p1) = @p2 |
| Oracle | JSON_VALUE("METADATA", :1) = :2 |
Even if the path were attacker-controlled the validator rejects everything
SQL-meaningful, and the bind-as-parameter render makes injection impossible at
the dialect surface. If you find a path that breaks either guarantee, file an
issue under the security label.
Joins
Join, LeftJoin, and RightJoin add join clauses:
orders, err := quark.For[Order](ctx, client).
Join("users", "users.id = orders.user_id").
Where("status", "=", "paid").
OrderBy("created_at", "DESC").
Limit(50).
List()
ON-clause grammar
The joined table name is validated as an identifier. The ON clause is also
validated against a minimal identifier-only grammar:
| Accepted | Rejected |
|---|---|
"users.id = orders.user_id" | "" (empty) |
"a.x = b.y AND c.z = d.w" | "users.id = 1" (literal RHS) |
"a = b OR c = d" (lowercase ok) | "users.id = 'alice'" |
"users.id = orders.user_id AND users.tenant_id = orders.tenant_id" | "LOWER(users.id) = orders.user_id" (function) |
"(users.id = orders.user_id)" (parentheses) | |
"users.id = orders.user_id; DROP TABLE x" | |
"users.id = orders.user_id -- comment" | |
"users.id = orders.user_id /* x */" |
The exact regex is in internal/guard.ValidateJoinOn; the operators
allowed are =, !=, <>, <, <=, >, >=. Anything else returns
ErrInvalidJoin at execution time.
The string-raw Join(table, on string) API is deprecated as of v0.2 and
will be replaced by a structured Join(table).On(col, op, otherCol) builder
in v0.4 (Phase 2 AST). Until then drop down to RawQuery
for joins that don't fit the grammar.
Where and Select identifiers are still validated as simple names, so they
do not accept dotted identifiers such as users.active. For complex join
filters or projections, use a database view, add columns to a dedicated read
model, or opt into RawQuery.
Reusable Scopes
Scopes are plain functions:
var ActiveUsers = quark.Scope[User](func(q *quark.Query[User]) *quark.Query[User] {
return q.Where("active", "=", true)
})
func CreatedAfter(t time.Time) quark.Scope[User] {
return func(q *quark.Query[User]) *quark.Query[User] {
return q.Where("created_at", ">=", t)
}
}
users, err := quark.For[User](ctx, client).
Apply(ActiveUsers, CreatedAfter(time.Now().AddDate(0, -1, 0))).
Limit(100).
List()
Scopes are useful for authorization boundaries, soft business filters, feature flags, and repeated dashboard queries.
Subqueries and Raw SQL
WhereSubquery is intentionally disabled unless the client allows raw SQL:
limits := quark.DefaultLimits()
limits.AllowRawQueries = true
client, err := quark.New(db,
quark.WithDialect(quark.PostgreSQL()),
quark.WithLimits(limits),
)
Then:
users, err := quark.For[User](ctx, client).
WhereSubquery("id", "IN", "SELECT user_id FROM orders WHERE total > 100").
List()
Only use this for static or carefully constructed SQL. Bound parameters protect values, but a raw subquery can still encode unsafe identifiers or SQL structure.
For full raw reads, use client.RawQuery:
rows, err := client.RawQuery(ctx,
"SELECT id, email FROM users WHERE active = $1",
true,
)
RawQuery requires placeholders when validation asks for them.
Streaming
Iter scans one row at a time and calls your callback:
err := quark.For[User](ctx, client).
Where("active", "=", true).
OrderBy("id", "ASC").
Iter(func(user User) error {
return sendToSearchIndex(ctx, user)
})
Cursor gives manual control:
cursor, err := quark.For[User](ctx, client).
OrderBy("id", "ASC").
Cursor()
if err != nil {
return err
}
defer cursor.Close()
for cursor.Next() {
var user User
if err := cursor.Scan(&user); err != nil {
return err
}
process(user)
}
return cursor.Err()
Always close cursors so the underlying sql.Rows and context cancellation are
released.