Skip to content

Relations and preloading

Relations are values that describe how two tables join. The generator emits them as static fields on your models (Post.author, Author.posts), and Query.preload uses them to load related rows in batches instead of one query per parent.

import 'package:gisila_orm/gisila.dart';
import 'models/blog.gisila.g.dart';
final list = await authors()
.preload([Author.posts.then(Post.comments)])
.all(db.context());
for (final a in list) {
for (final p in a.postsList) {
print('${a.name}: ${p.title} (${p.commentsList.length} comments)');
}
}
Query<T> preload(List<Relation<dynamic, dynamic>> relations)

Schedules relations to load after all, one or first hydrates the parent rows. stream does not preload.

When to use: whenever you’ll read a relation for more than one parent, for example when rendering a list. Loading children inside a loop issues one query per parent (the N+1 problem).

How it runs:

Relation Queries per relation node
belongs-to 1: SELECT ... FROM child WHERE child.id IN (parent fks)
has-many / has-one 1: SELECT ... FROM child WHERE child.fk IN (parent ids)
many-to-many 2: junction lookup, then SELECT ... FROM child WHERE id IN (...)
  • The cost doesn’t depend on how many parents there are. Each sibling relation in the list and each .then level adds its own queries.
  • Keys are de-duplicated and sent in batches of 10,000 (Preloader.maxKeysPerQuery) to stay under Postgres’s parameter limit.
  • Parents with a null key get an empty list or null.
  • Children are loaded with all their columns; there’s no filtering, ordering or limit on preloaded children. For “the latest 3 posts per author”, write a query.
  • Preloading runs on the same DbContext as the parent query, so inside a transaction it sees the transaction’s writes.

Don’t list a relation both on its own and in a chain ([Author.posts, Author.posts.then(Post.comments)]); that loads posts twice. The chain alone loads both levels.

abstract class Relation<Parent, Child> {
String get parentTable;
String get childTable;
String get name;
RelationKind get kind;
TableMeta<Child> get childMeta;
List<Relation<dynamic, dynamic>> get nested;
Relation<Parent, Child> then(Relation<Child, dynamic> next);
List<Child> typedListOf(Iterable<Object?> items);
Child? typedSingleOf(Object? value);
}

Base class of the four relation kinds.

When to use: as the element type of a preload list, and then to chain.

Member Description
name Key the loaded value is stored under on the parent, and the stem of the generated accessor.
kind RelationKind.hasMany, hasOne, belongsTo or manyToMany.
childMeta The child’s TableMeta, used to select and hydrate children.
nested Relations to load on the children afterwards.
then(next) A copy with next appended to nested. next must start from this relation’s child type.
typedListOf, typedSingleOf Cast loaded values to List<Child> / Child?. Used by the preloader.

then can be chained and nested: Author.posts.then(Post.comments.then(Comment.author)).

enum RelationKind { hasMany, hasOne, belongsTo, manyToMany }
const BelongsToRelation({
required String parentTable,
required String childTable,
required String name,
required String parentForeignKey,
required TableMeta<Child> childMeta,
String childPrimaryKey = 'id',
List<Relation<dynamic, dynamic>> nested = const [],
});

The parent holds the foreign key: parent.<parentForeignKey> = child.<childPrimaryKey>. Generated for every foreign-key column (Post.author). Loaded value: Child?, read with post.authorLoaded.

const HasManyRelation({
required String parentTable,
required String childTable,
required String name,
required String childForeignKey,
required TableMeta<Child> childMeta,
String parentPrimaryKey = 'id',
List<Relation<dynamic, dynamic>> nested = const [],
});

The child holds the foreign key: parent.<parentPrimaryKey> = child.<childForeignKey>. Generated on the target model when a foreign key sets reverse_name (Author.posts). Loaded value: List<Child>, read with author.postsList.

Same constructor as HasManyRelation. Generated instead of a has-many when the foreign key is is_unique: true. Loaded value: Child? (the first match), read with <name>Loaded.

const ManyToManyRelation({
required String parentTable,
required String childTable,
required String name,
required String junctionTable,
required String junctionParentKey,
required String junctionChildKey,
required TableMeta<Child> childMeta,
String parentPrimaryKey = 'id',
String childPrimaryKey = 'id',
List<Relation<dynamic, dynamic>> nested = const [],
});

Joins through a junction table. Generated for many_to_many: true columns and their reverse_name inverse. Loaded value: List<Child>, read with <name>List.

The ORM has no API for writing junction rows; insert and delete them with raw SQL.

mixin Preloadable {
final Map<String, Object?> $preloaded;
T? preloaded<T>(String relationName);
bool isPreloaded(String relationName);
}

Every generated model mixes this in. The preloader writes loaded values into $preloaded, keyed by relation name, and the generated ...List / ...Loaded getters read them back.

When to use: call isPreloaded('posts') to tell “not loaded” apart from “loaded and empty” (the generated postsList returns an empty list in both cases). Don’t write to $preloaded yourself.

Member Description
$preloaded Storage map written by the preloader.
preloaded<T>(name) The stored value cast to T, or null when absent.
isPreloaded(name) true when the relation was loaded for this instance, even if empty.
class Preloader {
const Preloader();
static const maxKeysPerQuery = 10000;
Future<void> applyTo(List<Object> parents, List<Relation<dynamic, dynamic>> relations, DbContext db);
}

The batch loader behind preload. It reads key values through each model’s toRow(), so parents and children must be generated models.

When to use: to preload onto models you already have, for example rows hydrated from a raw query or loaded in an earlier step.

final result = await db.execute('SELECT * FROM authors WHERE name ~* \$1', parameters: ['^a']);
final list = Hydrator<Author>(Author.fromRow).hydrateAll(result);
await const Preloader().applyTo(list, [Author.posts], db.context());

A parent that doesn’t mix in Preloadable makes it throw PreloadException.

See also: Eager loading guide, Relations and N+1, Work with relations.