Eager Loading
Eager loading is a database optimization technique that allows you to preload related data in a single query, preventing the infamous N+1 query problem and dramatically improving your application's performance.
The N+1 Query Problem
Before understanding eager loading, let's see the problem it solves. Consider this scenario: you want to display a list of blog posts with their authors' names.
❌ The Wrong Way (N+1 Problem)
This approach executes 1 query to get posts + N queries to get each post's author = N+1 total queries!
// ❌ This creates N+1 queries (1 + 100 = 101 total queries!)
final posts = await Post().query.get(); // 1 query to get posts
for (final post in posts) {
final user = await post.user(); // 1 query PER post (100 queries!)
print('Post by: ${user?.name}');
}
// If you have 100 posts, this executes 101 database queries!
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id = 1
// Query 3: SELECT * FROM users WHERE id = 2
// ... 100 more queries!
The Solution: Eager Loading
Eager loading solves this by fetching all related data upfront with just 2 queries total, regardless of how many records you're working with.
✅ The Right Way (Eager Loading)
Only 2 queries: 1 for posts + 1 for all authors. Much faster!
// ✅ This creates only 2 queries total!
final posts = await Post()
.query
.withRelations(['user']) // Preload user relationship
.get();
for (final post in posts) {
final user = post.relation.get('user'); // No database query!
print('Post by: ${user?.name}');
}
// Only 2 database queries total:
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (1,2,3,4,5...)
Basic Usage
Use the withRelations() method to specify which relationships to load. Pass an array of relationship names as strings.
💡 Important
Relationships must be defined in your model using hasMany(), belongsTo(), etc.
// Load posts with their authors
final posts = await Post()
.query
.withRelations(['user'])
.get();
// Access the loaded user data
for (final post in posts) {
final author = post.relation.get('user');
print('Title: ${post.title}');
print('Author: ${author?.name}');
}
// Load a single post with its category
final post = await Post()
.query
.withRelations(['category'])
.where('id', 1)
.first();
final category = post?.relation.get('category');
print('Category: ${category?.name}');
Loading Multiple Relations
You can load multiple relationships at once by passing multiple strings to the array. Each relationship will be loaded efficiently.
// Load multiple relationships at once
final posts = await Post()
.query
.withRelations([
'user', // Post author
'category', // Post category
'tags' // Post tags
])
.get();
// Access all loaded relations
for (final post in posts) {
final author = post.relation.get('user');
final category = post.relation.get('category');
final tags = post.relation.get('tags');
print('Post: ${post.title}');
print('Author: ${author?.name}');
print('Category: ${category?.name}');
print('Tags: ${tags?.length ?? 0} tags');
}
Nested Relations
Load relationships of relationships using dot notation. For example, load posts with their comments, and each comment's author in a single query chain.
🔍 How it works
'comments.user' means: load comments, then for each comment, load its user.
// Load posts with comments, and each comment's author
final posts = await Post()
.query
.withRelations([
'user', // Post author
'comments.user' // Comments + each comment's author
])
.get();
// Access nested data
for (final post in posts) {
final author = post.relation.get('user');
final comments = post.relation.get('comments');
print('Post: ${post.title} by ${author?.name}');
print('Comments: ${comments?.length ?? 0}');
if (comments != null) {
for (final comment in comments) {
final commentAuthor = comment.relation.get('user');
print(' - ${comment.content} by ${commentAuthor?.name}');
}
}
}
// Even deeper nesting is possible
final posts = await Post()
.query
.withRelations([
'comments.user.profile', // Comment author's profile
'category.posts.user' // Category's other posts and their authors
])
.get();
Accessing Loaded Relations
Once relations are loaded, access them using the .relation.get() method. The data is already in memory, so no additional database queries are made.
final post = await Post()
.query
.withRelations(['user', 'comments.user'])
.first();
// Single relation (belongsTo, hasOne)
final author = post?.relation.get('user');
print('Author: ${author?.name}');
// Multiple relations (hasMany)
final comments = post?.relation.get('comments') as List?;
print('Comment count: ${comments?.length ?? 0}');
// Check if relation was loaded
if (post?.relation.isLoaded('user') == true) {
print('User relation is loaded');
}
// Access nested relations
if (comments != null) {
for (final comment in comments) {
final commentUser = comment.relation.get('user');
print('Comment by: ${commentUser?.name}');
}
}
Paginated Relations
For relationships with many records (like comments on a popular post), you can paginate the related data to control memory usage and improve performance.
🔧 Pagination Syntax
Use :paginated:page=X:perPage=Y after the relation name to paginate.
// Paginate comments (load 10 comments per post)
final posts = await Post()
.query
.withRelations([
'user',
'comments:paginated:page=1:perPage=10'
])
.get();
// Access paginated data
for (final post in posts) {
final paginatedComments = post.relation.get('comments');
if (paginatedComments != null) {
final comments = paginatedComments['data'] as List;
final meta = paginatedComments['meta'] as Map;
print('Comments (page ${meta['current_page']}): ${comments.length}');
print('Total comments: ${meta['total']}');
}
}
// Load specific page of comments with their authors
final post = await Post()
.query
.withRelations([
'comments:paginated:page=2:perPage=5.user'
])
.first();
Advanced Configuration (Object Syntax)
For complex scenarios, use object syntax instead of strings. This gives you fine-grained control over pagination and nested relationships.
⚙️ Available Options
paginate: Enable/disable pagination (boolean)page: Which page to load (integer)perPage: Records per page (integer)with: Nested relations to load (array)
// Advanced configuration using object syntax
final posts = await Post()
.query
.withRelations([
'user', // Simple string syntax
{
'comments': {
'paginate': true,
'page': 1,
'perPage': 5,
'with': ['user', 'likes'] // Nested relations
}
},
{
'tags': {
'paginate': false,
'with': ['category']
}
}
])
.get();
// Mix string and object syntax
final post = await Post()
.query
.withRelations([
'user', // String syntax
{ // Object syntax
'comments': {
'paginate': true,
'page': 1,
'perPage': 10,
'with': ['user.profile']
}
}
])
.first();
Real-World Example
Here's a complete example showing how to build a blog post page with eager loading. Notice how we load everything we need in a single query chain.
// Complete blog post page with eager loading
class BlogService {
// Get post with all related data for display
static Future<Map<String, dynamic>> getPostForPage(int postId) async {
final post = await Post()
.query
.withRelations([
'user.profile', // Author with profile
'category', // Post category
'tags', // Post tags
'comments:paginated:page=1:perPage=10.user.profile', // Recent comments
'relatedPosts:paginated:page=1:perPage=5.user', // Related posts
])
.where('id', postId)
.where('published', true)
.first();
if (post == null) {
throw Exception('Post not found');
}
// All data is loaded, no more queries needed!
final author = post.relation.get('user');
final authorProfile = author?.relation.get('profile');
final category = post.relation.get('category');
final tags = post.relation.get('tags') as List?;
final paginatedComments = post.relation.get('comments');
final relatedPosts = post.relation.get('relatedPosts');
return {
'post': post,
'author': {
'name': author?.name,
'email': author?.email,
'avatar': authorProfile?.avatar,
'bio': authorProfile?.bio,
},
'category': category?.name,
'tags': tags?.map((tag) => tag.name).toList() ?? [],
'comments': {
'data': paginatedComments?['data'] ?? [],
'total': paginatedComments?['meta']?['total'] ?? 0,
'hasMore': (paginatedComments?['meta']?['current_page'] ?? 0) <
(paginatedComments?['meta']?['last_page'] ?? 0),
},
'relatedPosts': relatedPosts?['data'] ?? [],
};
}
}
// Usage in your controller/widget
final blogData = await BlogService.getPostForPage(123);
print('Post: ${blogData['post'].title}');
print('Author: ${blogData['author']['name']}');
print('Comments: ${blogData['comments']['total']}');
Performance Tips & Best Practices
🚀 Performance Guidelines
- ✓Always use eager loading when you know you'll need related data
- ✓Paginate large relations (comments, notifications, etc.)
- ✓Load only what you need - don't over-eager load
- ✗Don't load relations you won't use in the current request
- ✗Avoid deeply nested relations (more than 3 levels) without pagination
⚠️ Common Pitfalls
- Typos in relation names: Double-check your relationship method names
- Missing relationship definitions: Ensure relations are defined in your model
- Over-loading: Loading too many relations can slow down queries
- Memory issues: Large unpaginated relations can consume lots of memory
Troubleshooting
Having issues with eager loading? Here are common problems and solutions:
// ❌ Common mistakes and their solutions
// Problem: Relation returns null
final post = await Post().query.withRelations(['user']).first();
final user = post?.relation.get('user'); // Returns null
// ✅ Solutions:
// 1. Check if the relationship is defined in your Post model
class Post extends Model {
// Make sure you have this method
Future<User?> user() async {
return await belongsTo(User(), 'user_id');
}
}
// 2. Check foreign key exists and has correct data
final post = await Post().query.select(['*', 'user_id']).first();
print('User ID: ${post?.user_id}'); // Should not be null
// 3. Check if relation was actually loaded
if (post?.relation.isLoaded('user') == true) {
print('User relation loaded successfully');
} else {
print('User relation was not loaded');
}
// Problem: Typo in relation name
final posts = await Post()
.query
.withRelations(['usr']) // ❌ Typo: should be 'user'
.get();
// ✅ Solution: Use exact relation method name
final posts = await Post()
.query
.withRelations(['user']) // ✅ Correct
.get();
// Problem: Performance issues with large relations
final posts = await Post()
.query
.withRelations(['comments']) // ❌ Could load thousands of comments
.get();
// ✅ Solution: Use pagination
final posts = await Post()
.query
.withRelations([
'comments:paginated:page=1:perPage=10'
])
.get();
// Debug: Enable query logging to see what queries are executed
// (Check your ORM documentation for how to enable query logging)
Default Relations - Auto-Loading
Define relationships that should always be loaded automatically for a model. This eliminates the need to manually specify withRelations() every time.
✨ Automatic Loading
Relations listed in defaultRelations are automatically loaded on get(), first(), findById(), and paginate().
import 'package:khadem/khadem.dart';
// Define default relations in your model
class User extends KhademModel<User> with HasRelationships {
@override
List<dynamic> get defaultRelations => [
'profile', // Always load user's profile
'roles', // Always load user's roles
];
@override
Map<String, RelationDefinition> get relations => {
'profile': hasOne<Profile>(
foreignKey: 'user_id',
relatedTable: 'profiles',
factory: () => Profile(),
),
'roles': belongsToMany<Role>(
pivotTable: 'user_roles',
foreignPivotKey: 'user_id',
relatedPivotKey: 'role_id',
relatedTable: 'roles',
localKey: 'id',
factory: () => Role(),
),
'posts': hasMany<Post>(
foreignKey: 'user_id',
relatedTable: 'posts',
factory: () => Post(),
),
};
}
// Now these queries automatically load profile and roles:
final user = await User().query.where('id', '=', 1).first();
// profile and roles are already loaded!
final users = await User().query.where('status', '=', 'active').get();
// All users have profile and roles loaded
final paginatedUsers = await User().query.paginate(perPage: 20);
// Paginated users also have default relations
// You can still add more relations on top of defaults
final user = await User().query
.withRelations(['posts']) // Load posts in addition to defaults
.where('id', '=', 1)
.first();
// Now has profile, roles, AND postswithout() - Excluding Default Relations
Sometimes you want to skip loading specific default relations for performance reasons. Use without() to exclude them from a specific query.
🎯 Selective Exclusion
Use without() when you don't need all default relations for a particular query.
import 'package:khadem/khadem.dart';
class User extends KhademModel<User> with HasRelationships {
@override
List<dynamic> get defaultRelations => ['profile', 'roles', 'settings'];
}
// Scenario 1: Exclude specific default relations
final users = await User().query
.without(['settings']) // Don't load settings
.get();
// Only profile and roles are loaded
// Scenario 2: Exclude multiple default relations
final users = await User().query
.without(['profile', 'settings']) // Skip profile and settings
.get();
// Only roles are loaded
// Scenario 3: Exclude all defaults for a lightweight query
final users = await User().query
.without(['profile', 'roles', 'settings']) // Skip all
.select(['id', 'name', 'email'])
.get();
// No relations loaded - very fast!
// Scenario 4: Mix without() and withRelations()
final users = await User().query
.without(['settings']) // Don't load settings
.withRelations(['posts']) // But do load posts
.get();
// Loads: profile, roles, posts (but not settings)
// Performance optimization example
Future<List<User>> getUsersForExport() async {
// For CSV export, we don't need relations at all
return await User().query
.without(['profile', 'roles', 'settings'])
.select(['id', 'name', 'email', 'created_at'])
.get();
}withOnly() - Override Default Relations
Completely override the default relations and load only the specific relations you need. This ignores defaultRelations and loads only what you specify.
🔄 Complete Override
withOnly() completely replaces default relations. Use when you need precise control.
import 'package:khadem/khadem.dart';
class User extends KhademModel<User> with HasRelationships {
@override
List<dynamic> get defaultRelations => ['profile', 'roles', 'settings'];
}
// withOnly() completely replaces default relations
// Scenario 1: Load ONLY posts (ignores all defaults)
final users = await User().query
.withOnly(['posts'])
.get();
// Only posts are loaded. No profile, roles, or settings!
// Scenario 2: Load ONLY what's needed for a specific view
final users = await User().query
.withOnly(['profile']) // Just profile, nothing else
.where('status', '=', 'active')
.get();
// Scenario 3: Load NO relations at all
final users = await User().query
.withOnly([]) // Empty array = no relations
.get();
// Fastest query possible - no eager loading
// Scenario 4: Complex nested loading, ignoring defaults
final users = await User().query
.withOnly([
'posts.comments.user', // Deep nesting
'profile', // Just profile from defaults
])
.get();
// Comparison:
// withRelations() ADDS to defaults
final u1 = await User().query.withRelations(['posts']).first();
// Loads: profile, roles, settings, posts ← Added to defaults
// withOnly() REPLACES defaults
final u2 = await User().query.withOnly(['posts']).first();
// Loads: posts ← Only this, ignores defaults
// without() REMOVES from defaults
final u3 = await User().query.without(['settings']).first();
// Loads: profile, roles ← Defaults minus excludedRelation Counts - withCounts
Instead of loading full relationships, sometimes you just need to know how many related records exist. Use withCounts in your model to automatically include counts.
📊 Performance Benefit
Counting is much faster than loading full relations. Perfect for displaying statistics.
import 'package:khadem/khadem.dart';
// Define relation counts in your model
class User extends KhademModel<User> with HasRelationships {
@override
List<String> get withCounts => [
'posts', // Include posts_count
'comments', // Include comments_count
'followers', // Include followers_count
];
@override
Map<String, RelationDefinition> get relations => {
'posts': hasMany<Post>(
foreignKey: 'user_id',
relatedTable: 'posts',
factory: () => Post(),
),
'comments': hasMany<Comment>(
foreignKey: 'user_id',
relatedTable: 'comments',
factory: () => Comment(),
),
'followers': belongsToMany<User>(
pivotTable: 'followers',
foreignPivotKey: 'user_id',
relatedPivotKey: 'follower_id',
relatedTable: 'users',
localKey: 'id',
factory: () => User(),
),
};
// Access counts as properties
int get postsCount => getAppended('posts_count') as int? ?? 0;
int get commentsCount => getAppended('comments_count') as int? ?? 0;
int get followersCount => getAppended('followers_count') as int? ?? 0;
}
// Counts are automatically included when querying
final user = await User().query.where('id', '=', 1).first();
print('Posts: ${user?.postsCount}'); // e.g., 42
print('Comments: ${user?.commentsCount}'); // e.g., 156
print('Followers: ${user?.followersCount}'); // e.g., 1024
// Counts in JSON output
final json = user?.toJson();
// {
// "id": 1,
// "name": "John Doe",
// "posts_count": 42,
// "comments_count": 156,
// "followers_count": 1024
// }
// Manual count loading
final user = await User().query.where('id', '=', 1).first();
await user?.load('posts');
final count = (user?.getRelation('posts') as List?)?.length ?? 0;
user?.setAppended('posts_count', count);Combining All Techniques
Here's a comprehensive example showing how to use all eager loading features together in a real application.
import 'package:khadem/khadem.dart';
// Advanced User model with all features
class User extends KhademModel<User> with HasRelationships {
@override
List<dynamic> get defaultRelations => [
'profile',
'roles',
];
@override
List<String> get withCounts => [
'posts',
'followers',
'following',
];
@override
Map<String, RelationDefinition> get relations => {
'profile': hasOne<Profile>(
foreignKey: 'user_id',
relatedTable: 'profiles',
factory: () => Profile(),
),
'roles': belongsToMany<Role>(
pivotTable: 'user_roles',
foreignPivotKey: 'user_id',
relatedPivotKey: 'role_id',
relatedTable: 'roles',
localKey: 'id',
factory: () => Role(),
),
'posts': hasMany<Post>(
foreignKey: 'user_id',
relatedTable: 'posts',
factory: () => Post(),
),
'followers': belongsToMany<User>(
pivotTable: 'followers',
foreignPivotKey: 'user_id',
relatedPivotKey: 'follower_id',
relatedTable: 'users',
localKey: 'id',
factory: () => User(),
),
'following': belongsToMany<User>(
pivotTable: 'followers',
foreignPivotKey: 'follower_id',
relatedPivotKey: 'user_id',
relatedTable: 'users',
localKey: 'id',
factory: () => User(),
),
};
}
// Example 1: User profile page (load everything)
Future<User?> getUserProfile(int userId) async {
final user = await User().query
.withRelations([
'posts.comments', // Add posts with comments
'followers.profile', // Add followers with profiles
])
.where('id', '=', userId)
.first();
// Has: profile, roles (defaults) + posts, followers (explicit)
// Plus: posts_count, followers_count, following_count
return user;
}
// Example 2: User list page (lightweight)
Future<PaginatedResult<User>> getUsersList() async {
final users = await User().query
.withOnly(['profile']) // Only profile, ignore other defaults
.where('status', '=', 'active')
.paginate(perPage: 20);
// Has: profile only
// Plus: posts_count, followers_count, following_count (automatic)
return users;
}
// Example 3: Export CSV (no relations)
Future<List<User>> exportUsers() async {
final users = await User().query
.withOnly([]) // No relations at all
.select(['id', 'name', 'email', 'created_at'])
.get();
// No relations, no counts - fastest possible
return users;
}
// Example 4: Admin dashboard (custom mix)
Future<List<User>> getAdminDashboard() async {
final users = await User().query
.without(['profile']) // Don't need profile
.withRelations([
'posts.category', // Add posts with categories
'roles.permissions', // Add role permissions
])
.where('is_admin', '=', true)
.get();
// Has: roles (default) + posts, role permissions (explicit)
// Doesn't have: profile (excluded)
// Plus: all counts
return users;
}
