Advanced ORM Relationships
This guide covers ZinTrust's advanced relationship features for complex data modeling scenarios.
Table of Contents
- Relationship Counting (withCount)
- Constrained Eager Loading
- Polymorphic Relations
- Through Relations
- Performance Considerations
- Best Practices
Relationship Counting (withCount)
Use withCount() to efficiently count related records without loading them into memory. This is especially useful for displaying statistics (e.g., "10 comments").
Current eager-count support is validated for hasMany and belongsToMany, which are the relation types that currently expose withCount(...) semantics in core.
Basic Usage
import { User } from '@app/Models/User';
// Load users with post counts
const users = await User.query().withCount('posts').get<IModel>();
// Access the count
for (const user of users) {
const postCount = user.getAttribute('posts_count');
Logger.info(`${user.getAttribute('name')} has ${postCount} posts`);
}Multiple Counts
You can count multiple relationships:
const users = await User.query().withCount('posts').withCount('comments').get<IModel>();
// Each user will have posts_count and comments_count attributesHow It Works
withCount() executes a subquery to count related records, adding the result as a virtual attribute ({relation}_count):
SELECT users.*,
(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id) as posts_count
FROM usersUse Cases
- Listing pages: Display item counts without loading full relationships
- Dashboards: Show statistics efficiently
- Sorting: Order by relationship counts
- Filtering: Find records by relationship count ranges
// Example: Users with more than 10 posts
const activeUsers = await User.query()
.withCount('posts')
.get<IModel>()
.then((users) => users.filter((u) => (u.getAttribute('posts_count') as number) > 10));Constrained Eager Loading
Apply filters to relationships during eager loading to reduce data transfer and memory usage.
Eager loading in ZinTrust now follows the same attachment rules as lazy relation resolution for supported relation types, including belongsTo, belongsToMany, morphTo, morphMany, hasOneThrough, and hasManyThrough. That means shared related rows, null belongs-to keys, pivot joins, and through mappings behave consistently whether the relation is loaded inline with with(...) or later via relation.get(instance).
Basic Syntax
import { User } from '@app/Models/User';
// Load users with only published posts
const users = await User.query()
.with('posts', (query) => {
query.where('status', 'published');
})
.get<IModel>();Multiple Constraints
Chain multiple conditions on the relationship query:
const users = await User.query()
.with('posts', (query) => {
query
.where('status', 'published')
.where('created_at', '>', thirtyDaysAgo)
.orderBy('created_at', 'desc')
.limit(5);
})
.get<IModel>();
// Each user will have at most 5 recent published postsNested Relationships with Constraints
You can constrain nested relationships:
const users = await User.query()
.with('posts', (query) => {
query.where('status', 'published').with('comments', (commentQuery) => {
commentQuery.where('approved', true).orderBy('created_at', 'desc');
});
})
.get<IModel>();Use Cases
- Filtering: Only load approved comments, active subscriptions, etc.
- Sorting: Pre-sort related records (e.g., latest 3 comments)
- Limiting: Avoid loading thousands of related records
- Performance: Reduce database load and memory usage
Combining with withCount
const users = await User.query()
.withCount('posts') // Total posts
.with('posts', (query) => {
query.where('status', 'published').limit(3); // Only load 3 for display
})
.get<IModel>();
// users[0].getAttribute('posts_count') -> Total count
// users[0].getAttribute('posts') -> Array of up to 3 published postsPolymorphic Relations
Polymorphic relations allow a model to belong to multiple other models on a single association.
Common Use Case: Comments
Comments can belong to posts, videos, or any other content type:
comments table:
id
body
commentable_id (polymorphic foreign key)
commentable_type (stores model type: 'Post', 'Video', etc.)Defining Polymorphic Relations
morphOne
One-to-one polymorphic relation (like hasOne, but polymorphic):
import { IModel, Model } from '@zintrust/core';
import { Image } from '@app/Models/Image';
export const Post = Model.define(
{
table: 'posts',
fillable: ['title', 'content'],
hidden: [],
timestamps: true,
casts: {},
},
{
image(model: IModel) {
return model.morphOne(
Image,
'imageable' // morphName - will use imageable_id and imageable_type
);
},
}
);
export const Video = Model.define(
{
table: 'videos',
fillable: ['title', 'url'],
hidden: [],
timestamps: true,
casts: {},
},
{
image(model: IModel) {
return model.morphOne(Image, 'imageable');
},
}
);morphMany
One-to-many polymorphic relation:
import { IModel, Model } from '@zintrust/core';
import { Comment } from '@app/Models/Comment';
export const Post = Model.define(
{
table: 'posts',
fillable: ['title', 'content'],
hidden: [],
timestamps: true,
casts: {},
},
{
comments(model: IModel) {
return model.morphMany(Comment, 'commentable');
},
}
);
export const Video = Model.define(
{
table: 'videos',
fillable: ['title', 'url'],
hidden: [],
timestamps: true,
casts: {},
},
{
comments(model: IModel) {
return model.morphMany(Comment, 'commentable');
},
}
);morphTo
The inverse polymorphic relation (from Comment back to Post/Video):
import { IModel, Model } from '@zintrust/core';
import { Post } from '@app/Models/Post';
import { Video } from '@app/Models/Video';
export const Comment = Model.define(
{
table: 'comments',
fillable: ['body', 'commentable_id', 'commentable_type'],
hidden: [],
timestamps: true,
casts: {},
},
{
commentable(model: IModel) {
return model.morphTo(
'commentable', // morphName
{
Post: Post,
Video: Video,
} // morphMap - maps type strings to model classes
);
},
}
);Migration for Polymorphic Relations
import { MigrationSchema, type IDatabase } from '@zintrust/core';
export interface Migration {
up(db: IDatabase): Promise<void>;
down(db: IDatabase): Promise<void>;
}
export const migration: Migration = {
async up(db: IDatabase): Promise<void> {
const schema = MigrationSchema.create(db);
await schema.create('comments', (table) => {
table.id();
table.text('body');
table.integer('commentable_id'); // Polymorphic foreign key
table.string('commentable_type'); // Stores model type
table.timestamps();
// Optional: index for performance
table.index(['commentable_id', 'commentable_type']);
});
},
async down(db: IDatabase): Promise<void> {
const schema = MigrationSchema.create(db);
await schema.dropIfExists('comments');
},
};Loading Polymorphic Relations
// Load post with comments
const post = await Post.find(1);
await Post.query().load([post], 'comments');
const comments = post.getAttribute('comments') as IModel[];
// Load comment with parent (polymorphic)
const comment = await Comment.find(1);
await Comment.query().load([comment], 'commentable');
const parent = comment.getAttribute('commentable') as IModel;
// Check the parent type
const parentType = comment.getAttribute('commentable_type');
if (parentType === 'Post') {
Logger.info('Comment belongs to a post');
} else if (parentType === 'Video') {
Logger.info('Comment belongs to a video');
}Eager Loading Polymorphic Relations
// Load posts with comments
const posts = await Post.query().with('comments').get<IModel>();
// Load comments with their parents
const comments = await Comment.query().with('commentable').get<IModel>();
// Constrained polymorphic eager loading
const posts = await Post.query()
.with('comments', (query) => {
query.where('approved', true).orderBy('created_at', 'desc').limit(10);
})
.get<IModel>();Custom Column Names
By default, ZinTrust uses {morphName}_id and {morphName}_type. You can customize:
// In Comment model
commentable(model: IModel) {
return model.morphTo(
'commentable',
{ Post: Post, Video: Video },
'custom_type_column', // Instead of commentable_type
'custom_id_column' // Instead of commentable_id
);
}Use Cases
- Comments: Comments on posts, videos, images
- Likes: Likes on various content types
- Images: Featured images for multiple content types
- Tags: Tags that apply to different entities
- Activity Logs: Activities related to different models
Through Relations
Through relations let you access distant relationships via an intermediate model.
Example: Countries → Users → Posts
countries table: id, name
users table: id, name, country_id
posts table: id, title, user_idA Country has many Posts through Users.
hasManyThrough
Define a "distant" one-to-many relationship:
import { IModel, Model } from '@zintrust/core';
import { Post } from '@app/Models/Post';
import { User } from '@app/Models/User';
export const Country = Model.define(
{
table: 'countries',
fillable: ['name'],
hidden: [],
timestamps: true,
casts: {},
},
{
posts(model: IModel) {
return model.hasManyThrough(
Post, // Final model
User, // Intermediate model
'country_id', // Foreign key on users table
'user_id', // Foreign key on posts table
'id', // Local key on countries table
'id' // Local key on users table
);
},
}
);How It Works
ZinTrust will generate a query like:
SELECT posts.*
FROM posts
INNER JOIN users ON posts.user_id = users.id
WHERE users.country_id = ?hasOneThrough
Similar to hasManyThrough, but for one-to-one relations:
import { IModel, Model } from '@zintrust/core';
import { Profile } from '@app/Models/Profile';
import { User } from '@app/Models/User';
export const Country = Model.define(
{
table: 'countries',
fillable: ['name'],
hidden: [],
timestamps: true,
casts: {},
},
{
primaryProfile(model: IModel) {
return model.hasOneThrough(
Profile, // Final model
User, // Intermediate model
'country_id',
'user_id',
'id',
'id'
);
},
}
);Loading Through Relations
// Lazy load
const country = await Country.find(1);
await Country.query().load([country], 'posts');
const posts = country.getAttribute('posts') as IModel[];
// Eager load
const countries = await Country.query().with('posts').get<IModel>();Constrained Through Relations
Apply filters to the final model:
const countries = await Country.query()
.with('posts', (query) => {
query.where('status', 'published').orderBy('created_at', 'desc').limit(10);
})
.get<IModel>();Use Cases
- Geographic data: Countries → Cities → Businesses
- Organization hierarchies: Departments → Teams → Projects
- Multi-level relationships: Schools → Classes → Students → Grades
- Permission systems: Users → Roles → Permissions
Default Foreign Keys
If you follow ZinTrust conventions, you can omit the key parameters:
// Assuming:
// - users.country_id references countries.id
// - posts.user_id references users.id
posts(model: IModel) {
return model.hasManyThrough(Post, User);
}ZinTrust will infer:
foreignKey:country_id(fromcountries→users)throughForeignKey:user_id(fromusers→posts)localKey:idsecondLocalKey:id
Performance Considerations
1. Avoid N+1 Queries
// ❌ Bad - N+1 problem
const users = await User.query().get<IModel>();
for (const user of users) {
await User.query().load([user], 'posts'); // 1 query per user
}
// ✅ Good - Single query
const users = await User.query().with('posts').get<IModel>();2. Use withCount for Statistics
// ❌ Bad - Loads all posts into memory
const users = await User.query().with('posts').get<IModel>();
const userStats = users.map((u) => ({
name: u.getAttribute('name'),
postCount: (u.getAttribute('posts') as IModel[]).length,
}));
// ✅ Good - Only counts, no data transfer
const users = await User.query().withCount('posts').get<IModel>();
const userStats = users.map((u) => ({
name: u.getAttribute('name'),
postCount: u.getAttribute('posts_count'),
}));3. Constrain Eager Loads
// ❌ Bad - Loads ALL comments
const posts = await Post.query().with('comments').get<IModel>();
// ✅ Good - Limits to recent approved comments
const posts = await Post.query()
.with('comments', (query) => {
query.where('approved', true).orderBy('created_at', 'desc').limit(5);
})
.get<IModel>();4. Index Polymorphic Columns
Always index both {morphName}_id and {morphName}_type:
await schema.create('comments', (table) => {
table.id();
table.text('body');
table.integer('commentable_id');
table.string('commentable_type');
// Critical for performance
table.index(['commentable_id', 'commentable_type']);
table.timestamps();
});5. Index Through Relation Keys
For hasManyThrough, index both foreign keys:
await schema.create('users', (table) => {
table.id();
table.string('name');
table.integer('country_id');
table.index('country_id'); // Important for through queries
table.timestamps();
});
await schema.create('posts', (table) => {
table.id();
table.string('title');
table.integer('user_id');
table.index('user_id'); // Important for through queries
table.timestamps();
});Best Practices
1. Naming Conventions
Polymorphic Relations
Use consistent suffixes:
{entity}ablefor the morph name:commentable,taggable,imageable{entity}able_idand{entity}able_typefor columns
// ✅ Good - Clear naming
model.morphTo('commentable', morphMap);
model.morphMany(Comment, 'commentable');
// ❌ Avoid - Unclear naming
model.morphTo('parent', morphMap);
model.morphMany(Comment, 'owner');Through Relations
Use descriptive method names:
// ✅ Good - Descriptive
posts(model: IModel) {
return model.hasManyThrough(Post, User);
}
// ❌ Avoid - Ambiguous
items(model: IModel) {
return model.hasManyThrough(Post, User);
}2. Document Polymorphic Mappings
Always document which models can be related:
/**
* Comment Model
*
* Polymorphic relation: can belong to Post or Video
*
* @example
* ```typescript
* const comment = await Comment.find(1);
* await Comment.query().load([comment], 'commentable');
* const parent = comment.getAttribute('commentable');
* ```
*/
export const Comment = Model.define(
{
table: 'comments',
fillable: ['body', 'commentable_id', 'commentable_type'],
hidden: [],
timestamps: true,
casts: {},
},
{
commentable(model: IModel) {
return model.morphTo('commentable', {
Post: Post,
Video: Video,
});
},
}
);3. Validate Polymorphic Types
When creating polymorphic records, validate the type:
import { Validator, Schema } from '@zintrust/core';
const schema = Schema.create()
.required('body')
.string('body')
.required('commentable_id')
.integer('commentable_id')
.required('commentable_type')
.in('commentable_type', ['Post', 'Video']); // Validate allowed types
Validator.validate(req.getBody(), schema);4. Test Relationship Loading
import { describe, it, expect, beforeEach } from 'vitest';
import { Post } from '@app/Models/Post';
import { Comment } from '@app/Models/Comment';
describe('Polymorphic Relations', () => {
beforeEach(async () => {
await resetDatabase();
});
it('loads morphMany relationship', async () => {
const post = Post.create({ title: 'Test Post' });
await post.save();
const comment1 = Comment.create({
body: 'Comment 1',
commentable_id: post.getAttribute('id'),
commentable_type: 'Post',
});
await comment1.save();
const comment2 = Comment.create({
body: 'Comment 2',
commentable_id: post.getAttribute('id'),
commentable_type: 'Post',
});
await comment2.save();
await Post.query().load([post], 'comments');
const comments = post.getAttribute('comments') as IModel[];
expect(comments).toHaveLength(2);
expect(comments[0].getAttribute('body')).toBe('Comment 1');
expect(comments[1].getAttribute('body')).toBe('Comment 2');
});
it('loads morphTo relationship', async () => {
const post = Post.create({ title: 'Test Post' });
await post.save();
const comment = Comment.create({
body: 'Test Comment',
commentable_id: post.getAttribute('id'),
commentable_type: 'Post',
});
await comment.save();
await Comment.query().load([comment], 'commentable');
const parent = comment.getAttribute('commentable') as IModel;
expect(parent).toBeDefined();
expect(parent.getAttribute('title')).toBe('Test Post');
});
});5. Handle Missing Polymorphic Parents
When loading morphTo, the parent might not exist:
const comment = await Comment.find(1);
await Comment.query().load([comment], 'commentable');
const parent = comment.getAttribute('commentable') as IModel | undefined;
if (!parent) {
Logger.warn('Comment has no valid parent');
return;
}
// Safe to use parent
Logger.info(parent.getAttribute('title'));6. Cache Relationship Counts
For frequently accessed counts, consider caching:
import { cache } from '@zintrust/core';
async function getUserWithCachedPostCount(userId: number) {
const cacheKey = `user:${userId}:post_count`;
let postCount = await cache.get<number>(cacheKey);
if (postCount === null) {
const users = await User.query().where('id', userId).withCount('posts').get<IModel>();
postCount = (users[0]?.getAttribute('posts_count') as number) ?? 0;
await cache.set(cacheKey, postCount, 3600); // Cache for 1 hour
}
return postCount;
}7. Use Transactions for Complex Operations
When creating polymorphic relations:
import { useDatabase } from '@zintrust/core';
async function createPostWithTags(postData: Record<string, unknown>, tagNames: string[]) {
const db = useDatabase();
await db.transaction(async () => {
// Create post
const post = Post.create(postData);
await post.save();
// Create tags (polymorphic)
for (const tagName of tagNames) {
const tag = Tag.create({
name: tagName,
taggable_id: post.getAttribute('id'),
taggable_type: 'Post',
});
await tag.save();
}
});
}Related Documentation
- Models & ORM - Basic model usage and simple relationships
- Query Builder - Query construction and filtering
- Database Advanced - Multi-database and migrations
- Performance - Optimization strategies
- Testing - Testing ORM code
For questions or issues with advanced relationships, check the GitHub repository or consult the API reference.