Query Builder
Saferis provides a unified, type-safe Query builder for constructing SQL queries. It supports single-table queries, multi-table joins (up to 5 tables), WHERE clauses, pagination, and subqueries - all with compile-time type safety.
Query Safety (Builder/Ready Pattern)
To prevent accidental unbounded queries that could fetch millions of rows, Saferis uses a Builder/Ready pattern. A query must have at least one safety constraint before it can be executed:
| Safety Constraint | Description |
|---|---|
.where(...) | Filter results with a WHERE clause |
.limit(n) | Limit the number of rows returned |
.seekAfter(...) / .seekBefore(...) | Cursor-based pagination |
.all | Explicit opt-in to fetch all rows |
{
// These compile - they have safety constraints:
val withWhere = Query[SafetyUser].where(_.name).eq("Alice").build.sql // Has WHERE
val withLimit = Query[SafetyUser].limit(100).build.sql // Has LIMIT
val withAll = Query[SafetyUser].all.build.sql // Explicit opt-in
(withWhere, withLimit, withAll)
}(select * from qb_safety_users as qb_safety_users_ref_1 where qb_safety_users_ref_1.name = ?,select * from qb_safety_users as qb_safety_users_ref_1 limit 100,select * from qb_safety_users as qb_safety_users_ref_1)A query with no safety constraint cannot be built — .build simply doesn't exist
on a bare Builder, so the snippet below does not compile (the compiler error is
shown beneath it):
import saferis.*
import saferis.postgres.given
@tableName("qb_safety_users_fail")
case class SafetyUser(@generated @key id: Int, name: String) derives Table
// No WHERE / LIMIT / .all — .build is not available on a Builder.
Query[SafetyUser].buildvalue build is not a member of saferis.Query1Builder[SafetyUser]
Query[SafetyUser].buildThe pattern ensures you consciously choose to query all rows with .all rather than accidentally doing so.
Basic Queries
Start with Query[A] for single-table queries:
Query[QueryUser]
.where(_.name)
.eq("Alice")
.build
.sqlselect * from qb_query_users as qb_query_users_ref_1 where qb_query_users_ref_1.name = ?Query[QueryUser]
.where(_.age)
.gt(18)
.orderBy(users.name.asc)
.limit(10)
.offset(20)
.build
.sqlselect * from qb_query_users as qb_query_users_ref_1 where qb_query_users_ref_1.age > ? order by name asc limit 10 offset 20Type-Safe WHERE Clauses
Use selector syntax for type-safe column references:
Query[QueryUser].where(_.name).eq("Alice").build.sqlselect * from qb_query_users as qb_query_users_ref_1 where qb_query_users_ref_1.name = ?Query[QueryUser].where(_.age).gt(21).build.sqlselect * from qb_query_users as qb_query_users_ref_1 where qb_query_users_ref_1.age > ?Query[QueryUser].where(_.email).isNotNull().build.sqlselect * from qb_query_users as qb_query_users_ref_1 where qb_query_users_ref_1.email is not nullYou can also use raw SqlFragment for complex conditions:
Query[QueryUser]
.where(sql"${users.age} BETWEEN 18 AND 65")
.build
.sqlselect * from qb_query_users as qb_query_users_ref_1 where age BETWEEN 18 AND 65Joins
Chain joins with the fluent API. The on() method uses type-safe selectors:
Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userIdQuery[JoinUser]
.leftJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 left join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userIdQuery[JoinUser]
.rightJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 right join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userIdQuery[JoinUser]
.fullJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 full join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userIdFinalizing Joins with `.endJoin`
After specifying the ON clause, you can either:
Use convenience methods like
.where(),.limit(),.alldirectly on the join chainCall
.endJoinexplicitly to finalize the join and return to the query builder
Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.where(_.name)
.eq("Alice") // Convenience method
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userId where qb_join_users_ref_1.name = ?Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.endJoin // Explicitly finalize join
.orderBy(Table[JoinUser].name.asc)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userId order by name ascThe .endJoin method is useful when you want to add operations like .orderBy() that aren't available as convenience methods on the join chain.
Multi-Table Joins
Chain up to 5 tables. Use onPrev() to reference the previously joined table:
Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.innerJoin[JoinItem]
.onPrev(_.id)
.eq(_.orderId)
.all
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userId inner join qb_join_items as qb_join_items_ref_1 on qb_join_orders_ref_1.id = qb_join_items_ref_1.orderIdWHERE on Joined Queries
After joining, use where() for the first table or whereFrom() for joined tables:
Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.where(_.name)
.eq("Alice")
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userId where qb_join_users_ref_1.name = ?Query[JoinUser]
.innerJoin[JoinOrder]
.on(_.id)
.eq(_.userId)
.whereFrom(_.amount)
.gt(BigDecimal(100))
.build
.sqlselect * from qb_join_users as qb_join_users_ref_1 inner join qb_join_orders as qb_join_orders_ref_1 on qb_join_users_ref_1.id = qb_join_orders_ref_1.userId where qb_join_orders_ref_1.amount > ?ON Clause Operators
All comparison operators are available in the ON clause:
| Method | SQL | Description |
|---|---|---|
eq() | = | Equality |
neq() | <> | Not equal |
lt() | < | Less than |
lte() | <= | Less than or equal |
gt() | > | Greater than |
gte() | >= | Greater than or equal |
isNull() | is null | Null check |
isNotNull() | is not null | Non-null check |
op(Operator.X) | Custom | Any operator |
Pagination
Offset-Based Pagination
Traditional LIMIT/OFFSET pagination:
Query[Article]
.where(_.published)
.eq(true)
.orderBy(articles.views.desc)
.limit(10)
.offset(20)
.build
.sqlselect * from qb_page_articles as qb_page_articles_ref_1 where qb_page_articles_ref_1.published = ? order by views desc limit 10 offset 20Cursor/Seek Pagination
More efficient for large datasets - uses indexed lookups:
Query[Article]
.seekAfter(articles.id, 100L)
.limit(10)
.build
.sqlselect * from qb_page_articles as qb_page_articles_ref_1 where id > ? order by id asc limit 10Query[Article]
.seekBefore(articles.id, 50L)
.limit(10)
.build
.sqlselect * from qb_page_articles as qb_page_articles_ref_1 where id < ? order by id desc limit 10Sorting
Use column extensions for concise sorting:
Query[Article]
.orderBy(articles.views.desc)
.orderBy(articles.title.asc)
.all
.build
.sqlselect * from qb_page_articles as qb_page_articles_ref_1 order by views desc, title ascControl NULL ordering:
Query[Article]
.orderBy(articles.views.descNullsLast)
.all
.build
.sqlselect * from qb_page_articles as qb_page_articles_ref_1 order by views desc nulls lastAvailable sorting extensions:
.asc/.desc- basic ordering.ascNullsFirst/.ascNullsLast.descNullsFirst/.descNullsLast
Executing Queries
Use .query[R] to execute and decode results:
xa.run(
for
_ <- ddl.createTable[ExecUser](ifNotExists = true)
_ <- ddl.createTable[ExecOrder](ifNotExists = true)
_ <- dml.insert(ExecUser(-1, "Alice"))
_ <- dml.insert(ExecUser(-1, "Bob"))
_ <- dml.insert(ExecOrder(-1, 1, BigDecimal(100)))
_ <- dml.insert(ExecOrder(-1, 1, BigDecimal(200)))
result <- Query[ExecUser]
.innerJoin[ExecOrder]
.on(_.id)
.eq(_.userId)
.where(_.name)
.eq("Alice")
.limit(10)
.query[ExecUser]
yield result
).eitherRight(Chunk(ExecUser(1,Alice),ExecUser(1,Alice)))