Annotations & Codegen topic

Annotations & Codegen

These annotations mark model classes for basalt_codegen (a build_runner/source_gen derive) to generate reader/insert/changeset code from. They're plain data classes here in basalt — the generation logic lives entirely in the basalt_codegen package.

Setup

dev_dependencies:
  basalt_codegen:
  build_runner: ^2.4.0
import 'package:basalt/basalt.dart';
import 'schema.dart';

part 'user.g.dart';
dart run build_runner build

A class may carry several annotations; each generator contributes its own (non-overlapping) code to the one <file>.g.dart.

Annotations

Annotation Level Generates
@Queryable(table) class an XQuery companion class that is the query (extends MappedQuery/FoldMappedQuery; join query when the class has @Relations/@HasMany, otherwise a select-narrowing subset query) and carries static X fromRow(…), static const mapper = RowMapper<X>(fromRow) and (for @HasMany roots) static fold
@Insertable(table) class extension XInsert { InsertStatement<T> toInsert() } + extension XBatchInsert on Iterable<X> with a multi-row toInsert()
@AsChangeset(table) class extension XChangeset { UpdateStatement<T> toUpdate() } (SET only)
@Column(col, {readOnly, writeOnly}) field column mapping + read/write direction
@Relation(fk, {depth}) field a joined, nested related object (read-side)
@HasMany(fk) field a List<Child> folded from one JOIN query (static fold)
@Agg(tearOff) field an aggregate selection in a GROUP BY view (see below)

The class annotations are independent: a write-only DTO can be @Insertable alone.

Field mapping

Each unnamed-constructor parameter maps to a column. By default the field name selects the column by matching the schema's camelCase accessor (managerIdUsers.managerId). Override or tune it with @Column:

@Column(Users.name) final String displayName;          // rename: field ≠ column
@Column(Users.id, readOnly: true) final int id;        // read on SELECT, skip INSERT/UPDATE
@Column(Users.token, writeOnly: true) final String? token;  // write, skip row reader
  • readOnly — present in the @Queryable reader, excluded from toInsert() and toUpdate()'s SET (autoincrement PKs, server defaults).
  • writeOnly — excluded from the row reader (parameter must be optional), included in writes.
  • Setting both is a generation error — use a getter for computed values.

@Queryable output

@Queryable(Users.table)
class User {
  final int id; final String name; final int age; final int active;
  const User(this.id, this.name, this.age, this.active);
}

generates a UserQuery companion — an object that is the query, plus a reusable reader/mapper:

final users = await db.fetch(UserQuery());
// or compose by hand:
final custom = await db.fetch(from(Users.table).mapWith(UserQuery.mapper));

The generated static UserQuery.fromRow reader is alias-parameterized and composable — it can be called on the same RowReader to nest objects across a join.

The companion name is ${Class}Query — avoid hand-writing a class with that name next to a @Queryable model.

Selectable subsets

A @Queryable class that maps only some of a table's columns gets a select-narrowing XQuery whose query SELECTs exactly those columns:

@Queryable(Users.table)
class UserSummary {
  final int id;
  final String name;
  const UserSummary(this.id, this.name);
}

// UserSummaryQuery() selects only [Users.id, Users.name] and decodes with fromRow.
final summaries = await db.fetch(UserSummaryQuery());

(Classes with @Relations get the join-based query instead.)

Find by primary key via the inherited findBy:

final user = await UserQuery().findBy(Users.id, 1).first(db);

@Relation (read-side joins)

@Queryable(Posts.table)
class Post {
  final int id; final String title; final int views;
  @Relation(Posts.authorId, depth: 2) final User? author;
  const Post({required this.id, required this.title, required this.views, this.author});
}
  • The field must be a nullable, optional named parameter whose type is another @Queryable class.
  • depth: n unrolls the join n levels deep with path-based aliases (author, author_manager, …), so cyclic relations terminate safely.
  • The generator emits a self-mapping query class (e.g. PostQuery):
final posts = await db.fetch(PostQuery().orderBy(Posts.views.desc()));
// each Post has .author, and .author.manager, populated.

Nullable FKs use LEFT JOIN; non-null FKs use INNER JOIN. Relations are read-only — include the raw FK as its own field (e.g. managerId) if you need to write it.

@Agg (aggregate GROUP BY views)

A @Queryable class mixing plain @Column dimensions with @Agg aggregates becomes a GROUP BY view: the generated XQuery joins the dimension tables (joins:), groups by the dimensions and selects each aggregate. Every @Agg field references a private static tear-off returning the selection:

@Queryable(
  OrderItems.table,
  joins: [OrderItems.productId, Products.categoryId],
  orderBy: CategoryRevenueRow._revenue,
  orderDesc: true,
)
class CategoryRevenueRow {
  const CategoryRevenueRow({required this.categoryName, required this.revenue});

  @Column(Categories.name)
  final String categoryName;

  @Agg(CategoryRevenueRow._revenue)
  final double revenue;

  static Aggregate<double?> _revenue() =>
      sum(OrderItems.quantity * OrderItems.unitPrice, as: 'revenue');
}

final rows = await CategoryRevenueRowQuery().load(db);

Each tear-off is hoisted into one static final field of the companion and shared by the SELECT, ORDER BY and the decoder.

NULL vs 0: a non-nullable numeric @Agg field coalesces SQL NULL (an empty group) to 0 in the generated decoder. Declare the field nullable to distinguish "no rows" from an actual zero.

@Insertable / @AsChangeset output

@Insertable(Users.table)
@AsChangeset(Users.table)
class User { /* … */ }
await db.execute(user.toInsert());
await db.execute(user.toUpdate().where(Users.id.eq(user.id)));

// @Insertable also generates toInsert() on Iterable<User> — one multi-row
// INSERT ... VALUES (...), (...) for the whole list (throws on an empty one).
await db.execute(users.toInsert());

toUpdate() builds only the SET clause; append .where(...) yourself. All of them skip readOnly columns and @Relation fields.

Classes

Agg Annotations & Codegen
Binds an aggregate field to a private static select tear-off that returns the SQL projection (e.g. @Agg(CategoryRevenueRow._revenue)).
AsChangeset Annotations & Codegen
Marks a data class for UPDATE generation against table (e.g. @AsChangeset(Users.table)). The generator emits a toUpdate() extension method returning an UpdateStatement whose SET clause is built from each writable field; the caller appends the .where(...) (typically on the primary key). @Column(readOnly: true) fields are skipped.
Column Annotations & Codegen
Maps a constructor field to a schema column and/or tunes its read/write direction. Without it, the field name selects the column by matching the generated schema's camelCase accessor.
HasMany Annotations & Codegen
Marks a one-to-many relation: column is the child's foreign key pointing at this row's primary key (e.g. @HasMany(Addresses.customerId) on a customer view). The field must be List<ChildRow> where ChildRow is @Queryable.
Insertable Annotations & Codegen
Marks a data class for INSERT generation against table (e.g. @Insertable(Users.table)). The generator emits two toInsert() extension methods returning an InsertStatement, mapping each writable field (everything except @Column(readOnly: true)) through TableColumn.set: one on the class itself (single-row insert) and one on Iterable of it, which batches every element into a single multi-row INSERT ... VALUES (...), (...).
Queryable Annotations & Codegen
Marks a data class for query generation against table (e.g. @Queryable(Posts.table)). The generator emits a ThisClassQuery companion that is the canonical query, carrying a static fromRow reader (one RowReader.get per mapped field) and its RowMapper.
Relation Annotations & Codegen
Marks a relation field filled by joining the table referenced by FK column and nesting the related object via its own generated reader, e.g. @Relation(Posts.authorId) final User? author;. The field MUST be a nullable, optional (named) parameter whose type is another @Queryable class.