Skip to content

Query Builder

The query builder provides a composable fluent API for constructing SELECT statements. Queries are value types — every method returns a new copy, so you can branch and reuse partial queries freely. The builder produces dialect-aware SQL with ? placeholders for SQLite and $N placeholders for PostgreSQL.

Queries are parameterized by a schema type. Initialize with Query(T).init():

const zzz_db = @import("zzz_db");
const q = zzz_db.Query(User).init();

By default, this selects all columns defined in the schema:

SELECT id, name, email, inserted_at, updated_at FROM users

Filter rows with where(). Each call adds an AND condition:

const q = zzz_db.Query(User).init()
.where("name", .eq, "Alice")
.where("email", .like, "%@example.com");
SELECT ... FROM users WHERE name = ? AND email LIKE ?

Use orWhere() for OR clauses:

const q = zzz_db.Query(User).init()
.where("name", .eq, "Alice")
.orWhere("name", .eq, "Bob");
SELECT ... FROM users WHERE name = ? OR name = ?

The Op enum provides these operators:

OperatorSQLExample
.eq=.where("name", .eq, "Alice")
.neq!=.where("status", .neq, "deleted")
.gt>.where("age", .gt, "18")
.lt<.where("price", .lt, "100")
.gte>=.where("score", .gte, "90")
.lte<=.where("quantity", .lte, "0")
.likeLIKE.where("email", .like, "%@example.com")
.is_nullIS NULL.where("deleted_at", .is_null, null)
.is_not_nullIS NOT NULL.where("email", .is_not_null, null)

For IS NULL and IS NOT NULL, pass null as the value:

const q = zzz_db.Query(User).init()
.where("deleted_at", .is_null, null)
.where("email", .is_not_null, null);
SELECT ... FROM users WHERE deleted_at IS NULL AND email IS NOT NULL

For complex conditions like subqueries, use whereRaw():

const q = zzz_db.Query(User).init()
.whereRaw("id IN (SELECT user_id FROM orders)");
SELECT ... FROM users WHERE id IN (SELECT user_id FROM orders)

Raw fragments are inserted as-is — no bind parameters are generated.

Use orderBy() to sort results. Multiple calls add additional sort columns:

const q = zzz_db.Query(User).init()
.orderBy("name", .asc)
.orderBy("inserted_at", .desc);
SELECT ... FROM users ORDER BY name ASC, inserted_at DESC

The Order enum provides .asc and .desc.

Control result size with limit() and offset():

const page = 3;
const per_page = 20;
const q = zzz_db.Query(User).init()
.orderBy("id", .asc)
.limit(per_page)
.offset((page - 1) * per_page);
SELECT ... FROM users ORDER BY id ASC LIMIT 20 OFFSET 40
const q = zzz_db.Query(User).init()
.select("users.*, posts.title")
.innerJoin("posts", "users.id", "posts.user_id");
SELECT users.*, posts.title FROM users INNER JOIN posts ON users.id = posts.user_id
const q = zzz_db.Query(User).init()
.select("users.*, orders.total")
.leftJoin("orders", "users.id", "orders.user_id")
.where("orders.total", .gt, "100");
SELECT users.*, orders.total FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.total > ?

For right and full joins, use the generic join() method:

const q = zzz_db.Query(User).init()
.join(.right, "departments", "users.dept_id", "departments.id");

Available join types: .inner, .left, .right, .full.

const q = zzz_db.Query(User).init()
.select("name, COUNT(*) as cnt")
.groupBy("name");
SELECT name, COUNT(*) as cnt FROM users GROUP BY name

Filter groups with having():

const q = zzz_db.Query(User).init()
.select("name, COUNT(*) as cnt")
.groupBy("name")
.having("COUNT(*)", .gt, "1");
SELECT name, COUNT(*) as cnt FROM users GROUP BY name HAVING COUNT(*) > ?
const q = zzz_db.Query(User).init()
.select("users.name, COUNT(posts.id) as post_count")
.innerJoin("posts", "users.id", "posts.user_id")
.groupBy("users.name")
.having("COUNT(posts.id)", .gte, "5")
.orderBy("post_count", .desc)
.limit(10);

Override the default column list with select():

const q = zzz_db.Query(User).init()
.select("name, email");
SELECT name, email FROM users

Append arbitrary SQL to the end of the query with raw():

const q = zzz_db.Query(User).init()
.raw("FOR UPDATE");
SELECT ... FROM users FOR UPDATE

Combine two queries with merge(). The second query’s clauses are appended to the first:

const base = zzz_db.Query(User).init()
.where("name", .eq, "Alice");
const pagination = zzz_db.Query(User).init()
.limit(10)
.offset(20);
const combined = base.merge(pagination);
SELECT ... FROM users WHERE name = ? LIMIT 10 OFFSET 20

Merge combines where clauses, order clauses, join clauses, group by fields, and having clauses. Limit, offset, select fields, and raw fragments from the second query override the first if set.

This is useful for building reusable query fragments:

fn paginated(comptime T: type, page: u32, per_page: u32) zzz_db.Query(T) {
return zzz_db.Query(T).init()
.limit(per_page)
.offset((page - 1) * per_page);
}
const q = zzz_db.Query(User).init()
.where("active", .eq, "1")
.orderBy("name", .asc)
.merge(paginated(User, 3, 25));

Queries are executed through the Repository:

var repo = zzz_db.SqliteRepo.init(&pool);
// Get all results
const users = try repo.all(User, query, allocator);
defer zzz_db.freeAll(User, users, allocator);
// Get first result
if (try repo.one(User, query, allocator)) |*user| {
defer zzz_db.freeOne(User, user, allocator);
}
// Count matching rows
const total = try repo.count(User, query, allocator);
// Check existence
const has_match = try repo.exists(User, query, allocator);
// Run aggregate
const avg = try repo.aggregate(User, query, .avg, "score", allocator);

The query builder automatically generates the correct placeholder syntax for each backend:

SELECT ... FROM users WHERE name = ? AND email LIKE ?

This happens transparently when the repo executes the query — you do not need to think about placeholders.

The query builder uses fixed-size arrays for clauses to avoid heap allocation:

Clause typeMaximum
WHERE clauses16
ORDER BY4
JOINs4
GROUP BY4
HAVING4

If you exceed these limits, additional clauses are silently dropped. For very complex queries, consider using repo.rawAll() or repo.rawOne() instead.

  • Repository — execute queries and retrieve typed results
  • Schema — define the structs that queries operate on
  • Changesets — validate data before insert or update
  • Transactions — wrap query execution in transactions