Query Builder topic
Query Builder
Query is the entry point for reads: from(source) starts a query, refine it
with .where(...), .join(...), .orderBy(...), .limit(...), then finish
with .map(decoder).
final q = from(Users.table)
.where(Users.age > 18)
.orderBy(Users.name.asc())
.limit(20)
.map((r) => User(r.get(Users.id), r.get(Users.name), r.get(Users.age), r.get(Users.active)));
// q is a MappedQuery<User> — pass it to db.fetch, or use .load(db).
from(QuerySource)— aTableRefor aTableAlias. Single-table queries areQuery<Tbl>, which keepswherecompile-time scoped.- Projection is automatic (all columns of the involved tables). Narrow it
with
.select([Users.name, Users.age]). .map((RowReader r) => …)decides the result shape — scalar, record, data class, nested objects..mapWith(rowMapper)is sugar for.map(rowMapper.read).
Two-tier join safety
- Single-table queries are fully compile-time scoped:
from(Users.table).where(Posts.id.eq(1))does not compile. - Joined queries relax to
Query<Object?>;QueryBuildervalidates at build time that every referenced table/alias is in the FROM/JOIN clause, throwingStateErrorotherwise.
Joins
// FK-driven: ON is derived from the Ref (posts.author_id = users.id).
from(Posts.table)
.leftJoin(Users.table, onFk: Posts.authorId)
.map((r) => '${r.get(Posts.title)} <- ${r.get(Users.name)}');
// Explicit ON with eqColumn (required for self-joins).
final mgr = Users.table.aliased('mgr');
from(Users.table)
.innerJoin(mgr, on: Users.managerId.eqColumn(mgr.col(Users.id)))
.map((r) => '${r.get(Users.name)} -> ${r.get(mgr.col(Users.name))}');
innerJoin/leftJointake eitheron:(anExpression<bool>) oronFk:(aRefcolumn).- Joins chain freely; the projection auto-expands with the joined table's columns.
- Self-joins use aliases:
Users.table.aliased('mgr'), thenmgr.col(Users.id).RowReaderkeys by the alias, somgr.id≠users.id. - Generated relation queries (
@Relation) do all of this for you — see Annotations & Codegen.
Reading rows
RowReader reads a result row by selection key — columns by table.name
(alias-aware), aggregates by alias — never by positional index. Reading a
column that isn't in the projection throws StateError.
.map((r) => '${r.get(Posts.title)} (${r.get(Posts.views)})')
MappedQuery and RowMapper wrap a Query with a row-shape mapping function.
MappedQueryExecute adds basalt-style terminals:
| Method | Effect |
|---|---|
.load(db) |
all rows |
.first(db) |
first row, throws if empty |
.optional(db) |
first row or null |
Aggregates & grouping
Aggregate helpers are selections — pass them to .select([...]) and read
them back with the same handle:
final total = countAll();
final n = await from(Users.table).select([total]).map((r) => r.get(total)).first(db);
final sumAge = Users.age.sum();
final avgAge = Users.age.avg();
final (s, a) = await from(Users.table)
.select([sumAge, avgAge])
.map((r) => (r.get(sumAge), r.get(avgAge)))
.first(db);
GROUP BY + HAVING:
final perAuthor = Posts.views.sum();
final rows = await from(Posts.table)
.select([Posts.authorId, perAuthor])
.groupBy([Posts.authorId])
.having(perAuthor.gt(100))
.map((r) => (r.get(Posts.authorId), r.get(perAuthor)))
.load(db);
SELECT DISTINCT via .distinct():
final kinds = await from(Users.table)
.select([Users.active])
.distinct()
.map((r) => r.get(Users.active))
.load(db);
Aggregate result types:
count/countAll/countDistinct→int;sum/min/max→ nullable, typed by the operand (int columns →int?, double columns and expressions →double?);avg→double?. Whenas:is omitted the alias derives from the column name (sum_age); expression operands require an explicitas:.
@HasMany fold queries (one SQL + JOIN)
Codegen for @HasMany emits a ${Class}Query companion extending
FoldMappedQuery<T>: one SELECT with LEFT JOINs for children (and nested
children), then a generated static fold(List<RowReader>) member that dedupes
the cartesian product into parent rows with List<Child> fields.
// Generated — one round-trip via Connection.fetch, fold in Dart.
final rows = await CustomerProfileRowQuery()
.order(Customers.name.asc())
.limit(50)
.load(db);
.mapFold(folder)— after manual JOINs, same pattern without codegen (a generatedXQuery.foldis reusable here)..load(db)/.optional(db)/.first(db)— onFoldMappedQueryviaFoldMappedQueryExecute(folder(await db.fetch(this))).- Parent
limit/offset— not SQLLIMITon flat JOIN rows; the serializer addsWHERE root_pk IN (SELECT … ORDER BY … LIMIT/OFFSET). Call.withRootPk(rootPk)before.limit()/.offset(). - Do not use
.distinct()orGROUP BYon a fold query — deduping belongs in the folder.
Associations (grouped child loads)
loadGroupedByFk loads the children of many parents in one query and groups
them by foreign key — avoids N+1:
final users = await from(Users.table).mapWith(UserQuery.mapper).load(db);
final postsByAuthor = await loadGroupedByFk(
db, Posts.table, Posts.authorId, users.map((u) => u.id).toList(), readPost);
// Map<int, List<Post>>; every author id is present (empty list if no posts).
basalt-style aliases
| basalt | basalt_dart |
|---|---|
users.filter(p) |
from(Users.table).filter(p) — ANDs repeated calls |
.order(col.asc()) |
.order(Users.col.asc()) — alias for orderBy |
col.eq_any([...]) |
Users.col.eqAny([...]) — alias for isIn |
query.load(conn) |
query.load(db) |
query.first(conn) |
query.first(db) — throws if no rows |
query.first(conn).optional() |
query.optional(db) — null if no rows |
users.find(1) |
from(Users.table).findBy(Users.id, 1) |
final adults = await UserQuery()
.filter(Users.age.ge(18))
.order(Users.name.asc())
.load(db);
Classes
-
FoldMappedQuery<
R> Query Builder - A JOIN Query whose SQL rows are folded into fewer parents via folder.
-
MappedQuery<
R> Query Builder - A Query finished with a decoder — the executable SelectQuery.
- Ordering Query Builder
-
One
ORDER BYterm. -
Query<
Scope> Query Builder - Immutable SELECT builder.
-
RowMapper<
R> Query Builder -
A reusable, codegen-friendly row decoder for a data class.
@Queryable(Users)emits one of these (a singlereadbuilt fromRowReader.getcalls). They compose freely — aCommentreader can call aPostreader on the same RowReader to nest objects, with no arity-specific machinery. - RowReader Query Builder
-
Reads typed values out of one result row, addressed by selection (a
column or an aggregate), not by position. This is what makes a single
mapdecoder work for any number of columns/tables:r.get(Users.name)returns aStringregardless of wherenamesits in the projection, joins stay unambiguous (each column is keyed bytable.name), and aggregates are keyed by their alias.
Extensions
-
FoldMappedQueryExecute
on FoldMappedQuery<
R> Query Builder - Terminal helpers for FoldMappedQuery — one SQL round-trip, fold in Dart.
-
MappedQueryExecute
on MappedQuery<
R> Query Builder -
basalt-style terminal helpers so a finished query reads like
query.load(db)/query.first(db)/query.optional(db).
Functions
-
loadGroupedByFk<
Tbl, K, C> (Connection db, QuerySource< Query BuilderTbl> childSource, TableColumn<K, Tbl> fk, List<K> parentKeys, C readChild(RowReader reader)) → Future<Map< K, List< >C> > -
Loads the children whose foreign key
fkis one ofparentKeys, grouped by that key — thebelonging_to(parents).grouped_by(parents)pattern in a single query (avoids N+1). Every key inparentKeysis present in the result, mapping to an empty list when it has no children.