Prerequisite knowledge: Understand the basics of MongoDB (collections, fields, foreign keys)
What are relations?
- Define relationships between collections
- Use
populate()to automatically fill in associated data - Avoid writing multiple manual queries and merging results by hand
Core Concepts (only 3):
- from: related collection name (such as
'profiles') - localField: local foreign key field (such as
'profileId') - foreignField: foreign primary key field (usually
'_id')
Relationship with MongoDB $lookup:
- relations configuration = simplified version of MongoDB
$lookup - No need to learn new concepts, just understand MongoDB
import { Model } from 'monsqlize';
// Define User Model
Model.define('User', {
schema: (s) => s({
username: 'string!',
email: 'email!',
profileId: 'objectId' // foreign key
}),
relations: {
profile: {
from: 'profiles', // related profiles collection
localField: 'profileId', // User.profileId
foreignField: '_id', // Profile._id
single: true // returns a single document, not an array
}
}
});
// Define Profile Model.
// First-level populate can read the collection without this Model.
// Define it when profile has its own schema, hooks, or nested relations.
Model.define('Profile', {
schema: (s) => s({
bio: 'string',
avatar: 'url'
})
});const User = msq.model('User');
// Query users and automatically populate profiles
const user = await User.findOne({ username: 'john' })
.populate('profile');
console.log(user);
// {
// _id: ObjectId('...'),
// username: 'john',
// email: 'john@example.com',
// profileId: ObjectId('...'),
// profile: { // populated data
// _id: ObjectId('...'),
// bio: 'Software Engineer',
// avatar: 'https://...'
// }
// }It's that simple, no need to write it manually:
//❌ No need to write this manually
const user = await User.findOne({ username: 'john' });
const profile = await Profile.findOne({ _id: user.profileId });
user.profile = profile;Example: A user has a profile
//Configuration
relations: {
profile: {
from: 'profiles',
localField: 'profileId',
foreignField: '_id',
single: true // Return a single document
}
}
// use
const user = await User.findOne({ _id }).populate('profile');
console.log(user.profile); // { bio: '...', avatar: '...' }Example: A user has multiple articles (via reverse query)
//Configuration
relations: {
posts: {
from: 'posts',
localField: '_id', // User._id
foreignField: 'authorId', // Post.authorId points to User._id
single: false // Return an array (default)
}
}
// use
const user = await User.findOne({ _id }).populate('posts');
console.log(user.posts); // [{ title: 'Post 1' }, { title: 'Post 2' }]// Configure multiple relationships
relations: {
profile: {
from: 'profiles',
localField: 'profileId',
foreignField: '_id',
single: true
},
posts: {
from: 'posts',
localField: '_id',
foreignField: 'authorId',
single: false
}
}
// Chain populate calls
const user = await User.findOne({ _id })
.populate('profile')
.populate('posts');
console.log(user.profile); // single document
console.log(user.posts); // array| Field | Type | Required | Default value | Description |
|---|---|---|---|---|
from |
String | ✅ | - | The associated collection name (plural form, such as 'users') |
localField |
String | ✅ | - | Local field name (foreign key, such as 'userId') |
foreignField |
String | ✅ | - | External field name (usually '_id') |
single |
Boolean | ❌ | false |
true=returns a single document, false=returns an array |
Decision Tree:
What shape does the relation return?
├─ One-to-one (one user, one profile)
│ → single: true
│
├─ One-to-many (one user has multiple articles)
│ → single: false
│
└─ Not sure?
→ Use single: false (default, returns array)
Rule: Write the collection name (plural form)
//✅ Correct
from: 'users' //Collection name
from: 'profiles'
from: 'posts'
//❌ Error
from: 'User' //Model name (not collection name)
from: 'Profile'//❌ Error
relations: {
profile: {
from: 'Profile', //Model name
// ...
}
}
//✅ Correct
relations: {
profile: {
from: 'profiles', //Collection name (plural)
// ...
}
}//⚠️Performance issue: foreign keys are not indexed
relations: {
profile: {
from: 'profiles',
localField: 'profileId', //← This field needs to be indexed!
foreignField: '_id'
}
}
//✅ Solution: Create index
Model.define('User', {
schema: (s) => s({ profileId: 'objectId' }),
indexes: [
{ key: { profileId: 1 } } //← Create indexes for foreign keys
],
relations: { /* ... */ }
});//❌ Expected to return a single document, but used single: false
relations: {
profile: {
from: 'profiles',
localField: 'profileId',
foreignField: '_id',
single: false //← Returns an array [profile], not a single document!
}
}
//✅ Correct
single: true //Return single document { bio: '...' }- Select fields:
.populate('profile', { select: 'bio avatar' }) - Sort and limits:
.populate('posts', { sort: { createdAt: -1 }, limit: 10 }) - Nested populate: populate relations of already populated documents
- Performance: add indexes for foreign keys and use
$lookupdirectly for fully custom aggregation - Caching: cache read queries explicitly when the relation result is safe to reuse
const msq = require('monsqlize');
const { Model } = msq;
// 1. Profile Model
Model.define('Profile', {
schema: (s) => s({
bio: 'string:0-500',
avatar: 'url',
location: 'string'
})
});
// 2. Post Model
Model.define('Post', {
schema: (s) => s({
title: 'string:1-200!',
content: 'string!',
authorId: 'objectId!',
status: 'string',
createdAt: 'date'
}),
indexes: [
{ key: { authorId: 1 } } // foreign key index
]
});
// 3. User Model (including relations)
Model.define('User', {
schema: (s) => s({
username: 'string:3-32!',
email: 'email!',
profileId: 'objectId'
}),
indexes: [
{ key: { profileId: 1 } } // foreign key index
],
relations: {
// one-to-one
profile: {
from: 'profiles',
localField: 'profileId',
foreignField: '_id',
single: true
},
// one-to-many (reverse)
posts: {
from: 'posts',
localField: '_id',
foreignField: 'authorId',
single: false
}
}
});
// 4. Usage examples
async function example() {
const User = msq.model('User');
// Query user + profile + posts
const user = await User.findOne({ username: 'john' })
.populate('profile')
.populate('posts');
console.log(`User: ${user.username}`);
console.log(`Profile: ${user.profile?.bio || 'No profile'}`);
console.log(`Number of posts: ${user.posts.length}`);
// Output:
// User: john
// Profile: Full-stack Developer
// Number of posts: 5
}
example();Complete sample file: populate relations runnable example
A:
- Populate batches related IDs and avoids the common N+1 query pattern.
- Direct MongoDB
$lookupis still the right tool when you need a fully custom aggregation pipeline. - Recommendation: Create indexes for foreign keys + enable caching
A:
- Yes for first-level populate.
fromcan point directly to a MongoDB collection name. - Define a Model for the related collection when you need schema validation, hooks, virtuals, or nested populate on that related data.
A:
- The system automatically detects circular references and prevents infinite recursion
- Nested populate supports depth control; keep relationship graphs shallow and set explicit limits for deep branches.
A:
- relations are a simplified
$lookup-style configuration for common document references - populate batches related IDs and avoids the common N+1 query pattern
- use MongoDB
$lookupdirectly when you need a fully custom aggregation pipeline
You now have the core relation setup:
- relation fields:
from,localField,foreignField,single - one-to-one and one-to-many examples
- common mistakes around collection names, indexes, and
single
Use the linked runnable example as the next reference when wiring relations into an application.
Need help?
- View the API reference
- Open an issue: https://github.com/devcodex-labs/monSQLize/issues