Relations

Relations#

Duxt ORM supports four types of relationships between models:

  • HasMany - One-to-many (e.g., Category has many Posts)
  • BelongsTo - Inverse of has-many (e.g., Post belongs to Category)
  • HasOne - One-to-one (e.g., User has one Profile)
  • BelongsToMany - Many-to-many through pivot tables

Defining Relations#

Relations are registered alongside your model:

import 'package:duxt_orm/duxt_orm.dart';

class Post extends Entity {
  int? _id;
  String title;
  int? categoryId;  // Foreign key

  // Relation accessor
  Category? get category => getRelation<Category>('category');

  static void register() {
    // Register model with schema
    Entity.registerModel<Post>(Post.fromRow, schema: {
      'id': Column.integer().primaryKey().autoIncrement(),
      'title': Column.string(200).notNull(),
      'category_id': Column.integer().nullable().references('categories'),
    });

    // Register BelongsTo relation
    Entity.registerRelation<Post>(
      'category',
      BelongsTo<Category>(foreignKey: 'category_id'),
    );
  }

  @override
  Map<String, dynamic> toMap() => {
    'id': id,  // Include id for relations
    'title': title,
    'category_id': categoryId,
  };

  // ... rest of model
}
class Category extends Entity {
  int? _id;
  String name;

  // Relation accessor
  List<Post> get posts => getRelation<List<Post>>('posts') ?? [];

  static void register() {
    Entity.registerModel<Category>(Category.fromRow, schema: {
      'id': Column.integer().primaryKey().autoIncrement(),
      'name': Column.string(100).notNull(),
    });

    // Register HasMany relation
    Entity.registerRelation<Category>(
      'posts',
      HasMany<Post>(foreignKey: 'category_id'),
    );
  }

  // ... rest of model
}

Eager Loading#

Use .include() to load related models in a single query, preventing N+1 queries:

final posts = Model<Post>();

// Load posts WITH their categories (2 queries total instead of N+1)
final results = await posts.query()
    .include(['category'])
    .where('published', 1)
    .get();

for (final post in results) {
  print(post.title);
  print(post.category?.name);  // Already loaded!
}
final categories = Model<Category>();

// Load categories WITH their posts
final results = await categories.query()
    .include(['posts'])
    .get();

for (final category in results) {
  print(category.name);
  print('${category.posts.length} posts');
}

HasMany#

One-to-many relationship. The related model has a foreign key pointing to this model.

// Category has many Posts
Entity.registerRelation<Category>(
  'posts',
  HasMany<Post>(
    foreignKey: 'category_id',  // Column on posts table
    localKey: 'id',              // Column on categories table (default)
  ),
);

// Access loaded relation
final categories = Model<Category>();
final category = await categories.query()
    .include(['posts'])
    .find(1);

print(category?.posts.length);  // List<Post>

BelongsTo#

Inverse of HasMany. This model has a foreign key pointing to the related model.

// Post belongs to Category
Entity.registerRelation<Post>(
  'category',
  BelongsTo<Category>(
    foreignKey: 'category_id',  // Column on posts table
    ownerKey: 'id',              // Column on categories table (default)
  ),
);

// Access loaded relation
final posts = Model<Post>();
final post = await posts.query()
    .include(['category'])
    .find(1);

print(post?.category?.name);  // Category?

HasOne#

One-to-one relationship. Similar to HasMany but returns a single model.

// User has one Profile
Entity.registerRelation<User>(
  'profile',
  HasOne<Profile>(
    foreignKey: 'user_id',  // Column on profiles table
    localKey: 'id',          // Column on users table (default)
  ),
);

// Access loaded relation
final users = Model<User>();
final user = await users.query()
    .include(['profile'])
    .find(1);

print(user?.profile?.bio);  // Profile?

BelongsToMany#

Many-to-many through a pivot table.

// Post has many Tags through post_tags pivot table
Entity.registerRelation<Post>(
  'tags',
  BelongsToMany<Tag>(
    pivotTable: 'post_tags',
    foreignPivotKey: 'post_id',   // Column on pivot for this model
    relatedPivotKey: 'tag_id',     // Column on pivot for related model
    localKey: 'id',                // Column on posts table (default)
    relatedKey: 'id',              // Column on tags table (default)
  ),
);

// Register the pivot table schema (v0.2.1+)
Entity.registerPivotTable('post_tags', schema: {
  'post_id': Column.integer().notNull().references('posts'),
  'tag_id': Column.integer().notNull().references('tags'),
}, primaryKey: ['post_id', 'tag_id']);

// Access loaded relation
final posts = Model<Post>();
final post = await posts.query()
    .include(['tags'])
    .find(1);

print(post?.tags.map((t) => t.name));  // List<Tag>

Pivot Table Methods (v0.2.1+)#

Manage many-to-many relationships with attach, detach, and sync:

// Attach a tag to a post
await post.attach('tags', tagId);

// Detach a tag from a post
await post.detach('tags', tagId);

// Sync tags (replace all with given IDs)
await post.sync('tags', [1, 2, 3]);

Complete Many-to-Many Example#

class Post extends Entity {
  int? _id;
  String title;

  // Relation accessor
  List<Tag> get tags => getRelation<List<Tag>>('tags') ?? [];

  static void register() {
    Entity.registerModel<Post>(Post.fromRow, schema: {
      'id': Column.integer().primaryKey().autoIncrement(),
      'title': Column.string(200).notNull(),
    });

    // Register BelongsToMany relation
    Entity.registerRelation<Post>(
      'tags',
      BelongsToMany<Tag>(
        pivotTable: 'post_tags',
        foreignPivotKey: 'post_id',
        relatedPivotKey: 'tag_id',
      ),
    );

    // Register pivot table for migration
    Entity.registerPivotTable('post_tags', schema: {
      'post_id': Column.integer().notNull().references('posts'),
      'tag_id': Column.integer().notNull().references('tags'),
    }, primaryKey: ['post_id', 'tag_id']);
  }

  // Convenience methods
  Future<void> addTag(int tagId) => attach('tags', tagId);
  Future<void> removeTag(int tagId) => detach('tags', tagId);
  Future<void> setTags(List<int> tagIds) => sync('tags', tagIds);
}

// Usage
final post = Post(title: 'My Post');
await post.save();

// Add tags
await post.addTag(1);
await post.addTag(2);

// Or sync all at once
await post.setTags([1, 2, 3]);

// Load post with tags
final loaded = await Model<Post>().query()
    .include(['tags'])
    .find(post.id);

for (final tag in loaded!.tags) {
  print(tag.name);
}

Relation Accessors#

Models have helper methods for working with relations:

class Post extends Entity {
  // Type-safe getter for loaded relation
  Category? get category => getRelation<Category>('category');

  // For HasMany relations, return empty list if not loaded
  List<Comment> get comments => getRelation<List<Comment>>('comments') ?? [];
}

// Check if relation is loaded
if (post.relationLoaded('category')) {
  print(post.category?.name);
}

// Manually set a relation (used internally by QueryBuilder)
post.setRelation('category', someCategory);

Important Notes#

Include ID in toMap()#

For relations to work correctly, include the id field in your toMap() method:

@override
Map<String, dynamic> toMap() => {
  'id': id,  // Important for relations!
  'title': title,
  'category_id': categoryId,
};

Registration Order#

Register models in dependency order (parent before child):

void main() async {
  // Category first (no foreign keys)
  Category.register();

  // Post second (has category_id foreign key)
  Post.register();

  await DuxtOrm.init(config);
  await DuxtOrm.migrate();
}

Nested Eager Loading (Dot Notation)#

Load nested relations using dot notation. This loads relations on the related models:

final posts = Model<Post>();

// Load Post -> Author -> Profile
final results = await posts.include(['author.profile']).get();

for (final post in results) {
  print(post.author?.name);
  print(post.author?.profile?.bio);  // Nested relation loaded!
}

You can combine multiple nested and flat relations:

// Load Post -> Author -> Profile, Post -> Comments -> User
final results = await posts
    .include(['author.profile', 'comments.user', 'category'])
    .get();

Deeper nesting also works:

// Load Post -> Author -> Profile -> Avatar
final results = await posts
    .include(['author.profile.avatar'])
    .get();

Eager Loading Multiple Relations#

Load multiple relations in one query:

final posts = Model<Post>();
final results = await posts.query()
    .include(['category', 'author', 'comments'])
    .get();

Query Helpers with Eager Loading#

Add helper methods to your models for common queries:

class Post extends Entity {
  // Helper with optional eager loading
  static Future<List<Post>> findAll({
    bool publishedOnly = false,
    bool withCategory = false,
  }) async {
    var query = Model<Post>().query();

    if (withCategory) {
      query = query.include(['category']);
    }

    if (publishedOnly) {
      query = query.where('published', 1);
    }

    return query.orderByDesc('created_at').get();
  }
}

// Usage
final posts = await Post.findAll(
  publishedOnly: true,
  withCategory: true,
);