Typed Relations
Model relations with @InstantLink, typed includes, and recursively-typed fromRow
Relations let a model reference other models. With @InstantLink the generator emits
typed relation accessors, a recursively-typed fromRow, and a RelationRef handle for
typed link/unlink writes.
Declaring relations
Mark a relation field with @InstantLink. Cardinality is inferred from the field
type: List<T> is to-many, a bare T is to-one. The target (T) must itself be an
@InstantModel.
@InstantModel('gadgets')
class Gadget {
final String id;
final String label;
const Gadget({required this.id, required this.label});
}
@InstantModel('widgets')
class Widget2 {
final String id;
final String name;
final int weight;
@InstantLink()
final List<Gadget> gadgets; // to-many (List<Gadget>)
const Widget2({
required this.id,
required this.name,
required this.weight,
required this.gadgets,
});
}@InstantLink({attr: '...'}) overrides the stored relation attribute (the include key),
which defaults to the field name.
What the generator emits
For each @InstantLink field the generator emits:
-
A typed relation accessor getter returning a
TypedQueryof the target table, tagged with the relation attribute:TypedQuery<GadgetTable> get gadgets => TypedQuery<GadgetTable>(GadgetTable(), relationAttr: 'gadgets'); -
A
RelationRefconst for typed link/unlink writes (see Transactions):static const gadgetsRel = RelationRef<GadgetTable>('gadgets'); -
A recursively-typed
fromRowarm mapping included relation maps toList<T>(to-many) orT?(to-one). Un-included relations safely yield[]/nullvia awhereType<Map>guard:gadgets: (m['gadgets'] as List<dynamic>?) ?.whereType<Map<String, dynamic>>() .map(GadgetTable().fromRow) .toList() ?? const <Gadget>[],
Without code generation
The generator is a convenience, not a requirement. The whole typed layer — Col,
RelationRef, InstantTable, TypedQuery, TypedTx — is plain Dart you can write by
hand. A relation is just a RelationRef<TargetTable>('storedAttr'); linking is just an
update(...).link({attr: id}) under the hood.
Declare the tables yourself: extend InstantTable<Self>, expose columns as
static const Col<T> fields, and expose each relation as a
static const RelationRef<TargetTable>:
class UserTable extends InstantTable<UserTable> {
UserTable() : super('users');
static const id = Col<String>('id');
static const name = Col<String>('name');
}
class TodoTable extends InstantTable<TodoTable> {
TodoTable() : super('todos');
static const id = Col<String>('id');
static const text = Col<String>('text');
static const done = Col<bool>('done');
// Relation handle — no generator needed. 'author' is the stored attribute.
static const authorRel = RelationRef<UserTable>('author');
}Linking and unlinking
TypedTx gives you two link APIs — neither needs generated code:
final todos = db.txFor(TodoTable());
// Typed: pass the RelationRef you declared above. `targetIds` is one id or a List.
await db.transact(todos.linkRel(todoId, TodoTable.authorRel, userId));
await db.transact(todos.unlinkRel(todoId, TodoTable.authorRel, userId));
// Untyped: pass the relation attribute as a plain string.
await db.transact(todos.link(todoId, 'author', userId));
await db.transact(todos.unlink(todoId, 'author', userId));Both compile to the same update(todoId).link({'author': userId}) op, so the fully
untyped form works too if you skip TypedTx entirely:
await db.transact(db.update(todoId).link({'author': userId}));Including a relation by hand
Without a generated relation accessor, build the included sub-query with the
relationAttr: constructor argument — that string becomes the include key:
final q = TodoTable().query().include(
(t) => TypedQuery<UserTable>(UserTable(), relationAttr: 'author'),
);
final r = await db.queryOnceTyped(q);
// r.documents[i]['author'] holds the linked user map.You read results from the raw r.documents maps (the recursively-typed fromRow is the
only piece you give up by not generating). Reach for the generator when you want
fromRow, typed relation accessor getters, and toMap written for you.
Typed includes
Use TypedQuery.include((t) => t.relation...) to fetch related entities. The relation
sub-query supports nested where / order / limit / offset, cursor pagination, and
recursive includes. Includes are immutable — the source query is never mutated.
// Fetch goals, each with its linked todos
final q = GoalTable().query().include((g) => g.todos);
// Narrow the included set
final q2 = GoalTable()
.query()
.include((g) => g.todos.where((t) => t.n.gte(2)));
// Order + cursor window the relation
final q3 = GoalTable()
.query()
.include((g) => g.todos.order((t) => t.n.asc()).first(1));
final r = await db.queryOnceTyped(q3);
final goal = GoalTable().fromRow(r.documents.firstWhere((d) => d['id'] == 'g1'));
final todos = goal.todos; // List<Todo>, in order, windowedQuerying a parent without an include leaves relation fields empty — fromRow
yields [] / null rather than crashing.
The .select() restriction
include(...) throws ArgumentError if the relation sub-query carries .select()
(a fields projection). The generated fromRow hard-casts every field, so a projected
map would cause a TypeError. Use the untyped map API if you need a projected relation.
// Throws ArgumentError:
GoalTable().query().include((g) => g.todos.select((t) => [t.n]));Per-relation pageInfo
When a nested relation include is cursor-paginated, the engine surfaces its pageInfo
under a composite key '<parentType>.<relation>'. Deeper nesting produces dotted keys.
final q = GoalTable()
.query()
.include((g) => g.todos.order((t) => t.n.asc()).first(1));
final r = await db.queryOnceTyped(q);
r.pageInfo?['goals.todos']?['hasNextPage']; // true
// Deeper: r.pageInfo?['goals.todos.tags']Non-paginated includes add no composite pageInfo key. Note that pageInfo is per relation path, not per parent entity: with multiple parents, the key reflects the last parent's window.