basalt_codegen
build_runner / source_gen code generation for basalt_dart — the Dart analog of
derive macros. It derives row mappers, self-mapping join queries, and INSERT/UPDATE
builders from the annotations in package:basalt.
Contents
Setup
dev_dependencies:
basalt_codegen:
build_runner: ^2.4.0
Add a part directive next to your annotated classes and run the builder:
import 'package:basalt/basalt.dart';
import 'schema.dart';
part 'user.g.dart';
dart run build_runner build # one-shot
dart run build_runner watch # rebuild on change
The builder is a SharedPartBuilder, so all generated code for a file lands in one <file>.g.dart. Three
generators run — QueryableGenerator, InsertableGenerator, AsChangesetGenerator — and a class may carry
any combination of the annotations.
What it generates
| Annotation | Output |
|---|---|
@Queryable(table) |
$XFromRow reader, const xMapper = RowMapper<X>(…), an xQuery getter, and a bare findX(pk) when the class maps a PrimaryKey |
@Insertable(table) |
extension XInsert on X { InsertStatement<T> toInsert() } |
@AsChangeset(table) |
extension XChangeset on X { UpdateStatement<T> toUpdate() } (the SET clause; you append .where(...)) |
@Column(col, {readOnly, writeOnly}) |
field → column mapping and read/write direction |
@Relation(fk, {depth}) |
a joined, nested related object (read-side), unrolled depth levels with path aliases |
For a class with @Relations, xQuery is a self-mapping join query — it wires up the joins, table
aliases, and nested decoding for you and is still a chainable MappedQuery. For a class without relations,
xQuery narrows the projection to exactly that class's columns (the "Selectable" analog).
Field mapping
- Fields map to columns by name (camelCase ↔ snake_case) unless
@Column(SomeTable.col)overrides. readOnly— read on SELECT, skipped on write (autoincrement PKs, server defaults).writeOnly— written but skipped by the row reader (the field must be optional so its default is used).- Setting both is a generation error — a field that's neither read nor written isn't a column; use a getter.
@Relationfields must be nullable, optional, and named; the write derives skip them.
Full details and edge cases: packages/basalt/doc/annotations.md.
A worked example
@Queryable(Users.table)
@Insertable(Users.table)
@AsChangeset(Users.table)
class User {
final int id;
final String name;
final int age;
@Relation(Users.managerId, depth: 2) // self-join, nested two levels
final User? manager;
const User(this.id, this.name, this.age, {this.manager});
}
generates (abridged) user.g.dart:
User $UserFromRow(RowReader r, [QuerySource<Users> src = Users.table, ...]) =>
User(r.get(src.col(Users.id)), r.get(src.col(Users.name)), r.get(src.col(Users.age)),
manager: /* nested join, alias-safe */);
const userMapper = RowMapper<User>($UserFromRow);
MappedQuery<User> get userQuery { /* leftJoin the manager, map */ }
// find by PK: userQuery.findBy(Users.id, id)
See example/ for a complete two-file model (cross-file @Relation) and its generated output.
How it works
The pipeline is small and layered:
EdgeAnalyzer (analyzer elements → a plain model)
→ pure string emitters (reader_emitter, insert_emitter, changeset_emitter, relation emitters)
→ generators (thin GeneratorForAnnotation bridges) registered in builder.dart
Emitters are pure functions, unit-tested under test/ without the analyzer; the generators are thin
analyzer bridges. Adding a new derive is a five-step recipe (annotation → TypeChecker/parsing → emitter →
generator → register in builder.dart) — see CONTRIBUTING.md.
Libraries
- builder
- basalt_codegen Getting Started
- build_runner code generator for the Basalt Dart ORM. Wire it via build.yaml;
it derives a
RowMapper<T>for every@Queryableclass andtoInsert()/toUpdate()extensions for@Insertable/@AsChangesetclasses.
Getting Started
Getting Started