Relations API - Relationship Definition

Function: Model layer relationship definition, supports hasOne/hasMany/belongsTo


📖 Overview

Relations define how Model documents point to related collections and provide the configuration used by populate().

Model schema examples on this page use the runtime-scoped s namespace passed by monSQLize. Application code does not need to import the root schema-dsl entry for these examples.

Core Features

  • hasOne - one-to-one relationship (user-profile)
  • hasMany - one-to-many relationship (user-article)
  • belongsTo - many-to-one relationship (article - author)
  • Many to Many - implemented through intermediate tables
  • Self-reference - Supports self-association (tree structure)
  • Flexible Configuration - Custom field mapping

🚀 Quick Start

Basic definition

import { Model } from 'monsqlize';

Model.define('users', {
    schema: (s) => s({ username: 'string!' }),
    relations: {
        //Relationship name: configuration
        posts: {
            from: 'posts',          //associated collections
            localField: '_id',      //local field
            foreignField: 'userId', //foreign key field
            single: false           // false = hasMany, true = hasOne
        }
    }
});

📚Relationship type

hasOne (one-to-one)

One document corresponds to one document from another collection.

Example: User-Profile

// User hasOne Profile
Model.define('users', {
    schema: (s) => s({ username: 'string!' }),
    relations: {
        profile: {
            from: 'profiles',
            localField: '_id',
            foreignField: 'userId',
            single: true  //One to one
        }
    }
});

//use
const user = await User.findOne({ username: 'john' })
    .populate('profile');

console.log(user.profile);  // { userId: '...', bio: '...', avatar: '...' }

Data structure:

users collection:
{ _id: '1', username: 'john' }

profiles collection:
{ _id: 'p1', userId: '1', bio: 'Hello', avatar: 'avatar.jpg' }

After populate:
{
  _id: '1',
  username: 'john',
  profile: { _id: 'p1', userId: '1', bio: 'Hello', avatar: 'avatar.jpg' }
}

hasMany (one-to-many)

One document corresponds to multiple documents from another collection.

Example: User-Post

// User hasMany Posts
Model.define('users', {
    schema: (s) => s({ username: 'string!' }),
    relations: {
        posts: {
            from: 'posts',
            localField: '_id',
            foreignField: 'userId',
            single: false  //one to many
        }
    }
});

//use
const user = await User.findOne({ username: 'john' })
    .populate('posts');

console.log(user.posts);  // [{ title: '...', userId: '1' }, ...]

Data structure:

users collection:
{ _id: '1', username: 'john' }

posts collection:
{ _id: 'post1', userId: '1', title: 'Post 1' }
{ _id: 'post2', userId: '1', title: 'Post 2' }

After populate:
{
  _id: '1',
  username: 'john',
  posts: [
    { _id: 'post1', userId: '1', title: 'Post 1' },
    { _id: 'post2', userId: '1', title: 'Post 2' }
  ]
}

belongsTo (many to one)

Multiple documents correspond to one document of another collection (the reverse of hasOne).

Example: Article-Author

// Post belongsTo User
Model.define('posts', {
    schema: (s) => s({ title: 'string!' }),
    relations: {
        author: {
            from: 'users',
            localField: 'userId',  //Locally stored foreign keys
            foreignField: '_id',    //The primary key of the associated collection
            single: true            //belongsTo is also essentially hasOne
        }
    }
});

//use
const post = await Post.findOne({ title: 'Hello World' })
    .populate('author');

console.log(post.author);  // { _id: '1', username: 'john' }

Data structure:

posts collection:
{ _id: 'post1', userId: '1', title: 'Hello World' }

users collection:
{ _id: '1', username: 'john' }

After populate:
{
  _id: 'post1',
  userId: '1',
  title: 'Hello World',
  author: { _id: '1', username: 'john' }
}

🎯 Configuration options

RelationDefinition

OptionsTypeRequiredDescription
fromStringAssociated collection name
localFieldStringLocal field (usually _id)
foreignFieldStringForeign key field
singleBooleantrue=hasOne, false=hasMany
asStringThe field name of the association result (the relationship name is used by default)

Complete configuration example

Model.define('users', {
    schema: (s) => s({ username: 'string!' }),
    relations: {
        //Full configuration
        posts: {
            from: 'posts',          //Required: associated collection
            localField: '_id',      //Required: local field
            foreignField: 'userId', //Required: foreign key field
            single: false,          //Required: Whether it is single
            as: 'articles' // Optional: result field name (default 'posts')
        },

        //Minimal configuration
        profile: {
            from: 'profiles',
            localField: '_id',
            foreignField: 'userId',
            single: true
        }
    }
});

//Use the as option
const user = await User.findOne().populate('posts');
console.log(user.articles);  //Use the name specified by as

💡 Usage scenarios

Scenario 1: User-Profile (hasOne)

Requirement: One user only has one profile

Model.define('users', {
    schema: (s) => s({
        username: 'string!',
        email: 'email!'
    }),
    relations: {
        profile: {
            from: 'profiles',
            localField: '_id',
            foreignField: 'userId',
            single: true
        }
    }
});

Model.define('profiles', {
    schema: (s) => s({
        userId: 'objectId!',
        bio: 'string',
        avatar: 'url',
        website: 'url'
    })
});

//use
const user = await User.findOne({ email: 'john@example.com' })
    .populate('profile');

console.log(`${user.username}: ${user.profile.bio}`);

Scenario 2: User-Article (hasMany)

Requirement: A user can publish multiple articles

Model.define('users', {
    schema: (s) => s({ username: 'string!' }),
    relations: {
        posts: {
            from: 'posts',
            localField: '_id',
            foreignField: 'authorId',
            single: false
        }
    }
});

Model.define('posts', {
    schema: (s) => s({
        title: 'string!',
        content: 'string!',
        authorId: 'objectId!'
    })
});

//use
const users = await User.find()
    .populate('posts', { limit: 5 });

users.forEach(user => {
    console.log(`${user.username} posted ${user.posts.length} posts`);
});

Scenario 3: Article-Author (belongsTo)

Requirement: An article belongs to one author

Model.define('posts', {
    schema: (s) => s({ title: 'string!' }),
    relations: {
        author: {
            from: 'users',
            localField: 'authorId',
            foreignField: '_id',
            single: true
        }
    }
});

//use
const posts = await Post.find({ status: 'published' })
    .populate('author', { select: ['username', 'avatar'] });

posts.forEach(post => {
    console.log(`${post.title} - by ${post.author.username}`);
});

Scenario 4: Many-to-many (student-course)

Requirements: Students can take multiple courses and courses have multiple students

Option: Use intermediate table

//1. Define students
Model.define('students', {
    schema: (s) => s({
        name: 'string!',
        studentNo: 'string!'
    }),
    relations: {
        enrollments: {
            from: 'student_course',
            localField: '_id',
            foreignField: 'studentId',
            single: false
        }
    }
});

//2. Define the curriculum
Model.define('courses', {
    schema: (s) => s({
        name: 'string!',
        code: 'string!'
    }),
    relations: {
        enrollments: {
            from: 'student_course',
            localField: '_id',
            foreignField: 'courseId',
            single: false
        }
    }
});

//3. Define intermediate tables
Model.define('student_course', {
    schema: (s) => s({
        studentId: 'objectId!',
        courseId: 'objectId!',
        enrolledAt: 'date'
    }),
    relations: {
        student: {
            from: 'students',
            localField: 'studentId',
            foreignField: '_id',
            single: true
        },
        course: {
            from: 'courses',
            localField: 'courseId',
            foreignField: '_id',
            single: true
        }
    }
});

//use
const students = await Student.find()
    .populate('enrollments');

for (const student of students) {
    console.log(`${student.name} has taken ${student.enrollments.length} courses:`);

    //Get course details
    const courseIds = student.enrollments.map(e => e.courseId);
    const courses = await Course.findByIds(courseIds);

    courses.forEach(course => {
        console.log(`  - ${course.name}`);
    });
}

//The current version also supports nested populates:
const studentsWithCourses = await Student.find()
    .populate({ path: 'enrollments', populate: 'course' });

Scenario 5: Self-reference (tree structure)

Requirement: Comment reply to comment (father-son relationship)

Model.define('comments', {
    schema: (s) => s({
        content: 'string!',
        parentId: 'objectId'  //Parent comment ID (null means top-level comment)
    }),
    relations: {
        //Subcomment (reply)
        replies: {
            from: 'comments',
            localField: '_id',
            foreignField: 'parentId',
            single: false
        },
        //parent comment
        parent: {
            from: 'comments',
            localField: 'parentId',
            foreignField: '_id',
            single: true
        }
    }
});

//use
const topComments = await Comment.find({ parentId: null })
    .populate('replies', { limit: 10 });

topComments.forEach(comment => {
    console.log(`Comment: ${comment.content}`);
    comment.replies.forEach(reply => {
        console.log(`└─ Reply: ${reply.content}`);
    });
});

🎨 Best Practices

1. Relationship naming

//✅ Good: use plural for hasMany
relations: {
    posts: { ... },      // hasMany
    comments: { ... }    // hasMany
}

//✅ Good: use singular hasOne/belongsTo
relations: {
    profile: { ... },    // hasOne
    author: { ... }      // belongsTo
}

//❌ Bad: Unclear naming
relations: {
    postList: { ... },   //should use posts
    userProfile: { ... } //should use profile
}

2. Foreign key design

//✅ Good: Use standard naming
{
    userId: ObjectId,     //Point to users._id
    authorId: ObjectId,   //Point to users._id
    postId: ObjectId      //Points to posts._id
}

//❌ Bad: inconsistent naming
{
    user: ObjectId,       //UserId should be used
    author_id: ObjectId,  //should use authorId
    post: ObjectId        //should use postId
}

3. Performance considerations

//❌ Bad: Excessive populate
const users = await User.find()
    .populate('posts')
    .populate('comments')
    .populate('likes')
    .populate('followers');  //May return large amounts of data

//✅ Good: populate on demand + limit quantity
const users = await User.find()
    .populate('posts', { limit: 5 });  //Only populate requires

4. Index optimization

//✅ Good: Add index for foreign keys
Model.define('posts', {
    schema: (s) => s({
        title: 'string!',
        userId: 'objectId!'
    }),
    indexes: [
        { key: { userId: 1 } }  //Add indexes to foreign keys to speed up populate
    ],
    relations: {
        author: {
            from: 'users',
            localField: 'userId',
            foreignField: '_id',
            single: true
        }
    }
});

🆚 Comparison with SQL

SQL JOIN vs Populate

FeaturesSQL JOINmonSQLize Populate
Number of queries1 time (JOIN)2 times (batch query)
PerformanceComplex JOIN is slowSimple query is fast
CachingDifficult to cache✅ Easy to cache
FlexibilitySubject to JOIN✅ Flexible
N+1 questionsNone✅ Automatically solved

Example comparison

SQL:

-- Users and articles
SELECT u.*, p.*
FROM users u
LEFT JOIN posts p ON u.id = p.user_id;

-- Complex and difficult to cache

monSQLize:

//Users and articles
const users = await User.find()
    .populate('posts');

//Simple and easy to cache
//The 1st time: SELECT * FROM users
//2nd time: SELECT * FROM posts WHERE userId IN [...]

❓ FAQ

Q1: Can it be many-to-many?

A: ✅ Yes, it can be achieved through an intermediate table.

//Student-Course many-to-many
Student → student_course ← Course

See Scenario 4: Many-to-many

Q2: Can it be self-referenced?

A: ✅ Yes, self-association is supported.

Model.define('comments', {
    relations: {
        replies: {
            from: 'comments',  //Point to yourself
            localField: '_id',
            foreignField: 'parentId',
            single: false
        }
    }
});

See Scenario 5: Self-reference

Q3: Can conditional filtering be used?

A: Supported. The current version can use match in the populate option to filter related data.

const users = await User.find().populate('posts', {
    match: { status: 'published' }
});

Q4: Can there be a two-way relationship?

A: ✅ Yes, the relationship can be defined in both directions.

// User hasMany Posts
Model.define('users', {
    relations: {
        posts: { from: 'posts', localField: '_id', foreignField: 'userId', single: false }
    }
});

// Post belongsTo User
Model.define('posts', {
    relations: {
        author: { from: 'users', localField: 'userId', foreignField: '_id', single: true }
    }
});

//Two-way use
const user = await User.findOne().populate('posts');
const post = await Post.findOne().populate('author');

Q5: How to determine localField and foreignField?

A: Look in the direction of the arrow.

// User (localField: _id) → Posts (foreignField: userId)
//Points from User's _id to Posts' userId

Model.define('users', {
    relations: {
        posts: {
            from: 'posts',
            localField: '_id',      //Which field of User
            foreignField: 'userId', //Which field of Posts corresponds to
            single: false
        }
    }
});

Memory method:

  • localField: local (own collection) field
  • foreignField: foreign (associated collection) field

Q6: Can multiple relationships point to the same collection?

A: ✅ Yes, use different relationship names.

Model.define('posts', {
    schema: (s) => s({
        authorId: 'objectId!',
        reviewerId: 'objectId'
    }),
    relations: {
        author: {
            from: 'users',
            localField: 'authorId',
            foreignField: '_id',
            single: true
        },
        reviewer: {
            from: 'users',
            localField: 'reviewerId',
            foreignField: '_id',
            single: true
        }
    }
});

//use
const post = await Post.findOne()
    .populate('author')
    .populate('reviewer');

console.log(`Author: ${post.author.username}`);
console.log(`Reviewer: ${post.reviewer.username}`);

📝 Complete example

E-commerce system example

import { Model } from 'monsqlize';

//1. User
Model.define('users', {
    schema: (s) => s({
        username: 'string!',
        email: 'email!'
    }),
    relations: {
        orders: {
            from: 'orders',
            localField: '_id',
            foreignField: 'userId',
            single: false
        },
        cart: {
            from: 'carts',
            localField: '_id',
            foreignField: 'userId',
            single: true
        }
    }
});

//2. Goods
Model.define('products', {
    schema: (s) => s({
        name: 'string!',
        price: 'number!',
        categoryId: 'objectId!'
    }),
    relations: {
        category: {
            from: 'categories',
            localField: 'categoryId',
            foreignField: '_id',
            single: true
        },
        reviews: {
            from: 'reviews',
            localField: '_id',
            foreignField: 'productId',
            single: false
        }
    }
});

//3. Order
Model.define('orders', {
    schema: (s) => s({
        orderNo: 'string!',
        userId: 'objectId!',
        status: 'string!'
    }),
    relations: {
        user: {
            from: 'users',
            localField: 'userId',
            foreignField: '_id',
            single: true
        },
        items: {
            from: 'order_items',
            localField: '_id',
            foreignField: 'orderId',
            single: false
        }
    }
});

//4. Line items
Model.define('order_items', {
    schema: (s) => s({
        orderId: 'objectId!',
        productId: 'objectId!',
        quantity: 'number!',
        price: 'number!'
    }),
    relations: {
        product: {
            from: 'products',
            localField: 'productId',
            foreignField: '_id',
            single: true
        }
    }
});

//5. Classification
Model.define('categories', {
    schema: (s) => s({
        name: 'string!',
        parentId: 'objectId'
    }),
    relations: {
        products: {
            from: 'products',
            localField: '_id',
            foreignField: 'categoryId',
            single: false
        },
        //Self-referencing: subcategory
        children: {
            from: 'categories',
            localField: '_id',
            foreignField: 'parentId',
            single: false
        },
        //Self-reference: parent category
        parent: {
            from: 'categories',
            localField: 'parentId',
            foreignField: '_id',
            single: true
        }
    }
});

//Usage example

//Scenario 1: Get user order details
const user = await User.findOne({ username: 'john' })
    .populate('orders');

for (const order of user.orders) {
    const orderDetail = await Order.findOneById(order._id)
        .populate('items');

    console.log(`Order ${orderDetail.orderNo}:`);
    for (const item of orderDetail.items) {
        const itemWithProduct = await OrderItem.findOneById(item._id)
            .populate('product');
        console.log(`  - ${itemWithProduct.product.name} x ${item.quantity}`);
    }
}

//Scenario 2: Get products and categories
const products = await Product.find({}, { cache: 60000 })
    .populate('category')
    .populate('reviews', { limit: 5 });

products.forEach(product => {
    console.log(`${product.name} - ${product.category.name}`);
    console.log(`Number of reviews: ${product.reviews.length}`);
});

//Scenario 3: Get the category and its subcategories
const category = await Category.findOne({ name: 'electronic products' })
    .populate('children')
    .populate('products', { limit: 10 });

console.log(`${category.name}:`);
console.log(`Subcategory: ${category.children.map(c => c.name).join(', ')}`);
console.log(`Number of items: ${category.products.length}`);


📌 Design principles

1. One-way vs two-way relationship

//Unidirectional: only one side is defined
Model.define('users', {
    relations: { posts: { ... } }
});

//Bidirectional: defined on both sides
Model.define('users', {
    relations: { posts: { ... } }
});
Model.define('posts', {
    relations: { author: { ... } }
});

Suggestion: Define as needed, not all relationships need to be bidirectional.

2. Foreign key storage location

//✅ Good: Store foreign keys on the "many" side
//User hasMany Posts → Post stores userId
posts collection: { userId, title, content }

//✅ Good: Store foreign keys on the "belongsTo" side
//Post belongsTo User → Post stores userId
posts collection: { userId, title, content }

3. Relationship vs Embedding

ScenariosUsing relationshipsUsing embeddings
Data updated independently
Data Sharing
Data is fixed
Small data size

Example:

//✅ Usage relationship: users and articles (independent management)
User → Posts

//✅ Use embed: orders and line items (not query separately)
Order { items: [ { productId, quantity }, ... ] }