Menu

Relationships

Relationships in Adonis ODM allow you to define connections between different models, making it easy to work with related data across collections. While MongoDB is document-oriented, relationships help maintain data integrity and provide convenient query methods.

Types of Relationships

Adonis ODM currently supports the following relationship types:

  • Has One - One-to-one relationship
  • Has Many - One-to-many relationship
  • Belongs To - Inverse of has one/has many

Note: Many-to-many and has-many-through relationships are planned for future releases. For now, you can use embedded documents or manual reference management for complex relationships.

Has One Relationship

A has one relationship defines a one-to-one connection between two models.

Defining Has One

import { BaseModel, column, hasOne } from "adonis-odm";
import type { HasOne } from "adonis-odm/types";
import UserProfile from "./user_profile.js";

export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  @column()
  declare email: string;

  // Has one relationship
  @hasOne(() => UserProfile)
  declare profile: HasOne<typeof UserProfile>;
}
// UserProfile model
import { BaseModel, column, belongsTo } from "adonis-odm";
import type { BelongsTo } from "adonis-odm/types";
import User from "./user.js";

export default class UserProfile extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare userId: string;

  @column()
  declare bio: string;

  @column()
  declare avatar: string;

  // Belongs to relationship
  @belongsTo(() => User)
  declare user: BelongsTo<typeof User>;
}

Using Has One

// Create user with profile
const user = await User.create({
  name: "John Doe",
  email: "[email protected]",
});

const profile = await user.related("profile").create({
  bio: "Software developer",
  avatar: "avatar.jpg",
});

// Query with loaded relationship
const userWithProfile = await User.query()
  .load("profile")
  .where("email", "[email protected]")
  .firstOrFail();

console.log(userWithProfile.profile.bio);

Has Many Relationship

A has many relationship defines a one-to-many connection.

Defining Has Many

import { BaseModel, column, hasMany } from "adonis-odm";
import type { HasMany } from "adonis-odm/types";
import Post from "./post.js";

export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  // Has many relationship
  @hasMany(() => Post)
  declare posts: HasMany<typeof Post>;
}
// Post model
import { BaseModel, column, belongsTo } from "adonis-odm";
import type { BelongsTo } from "adonis-odm/types";
import User from "./user.js";

export default class Post extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare userId: string;

  @column()
  declare title: string;

  @column()
  declare content: string;

  @belongsTo(() => User)
  declare author: BelongsTo<typeof User>;
}

Using Has Many

// Create posts for a user
const user = await User.findOrFail("user123");

await user.related("posts").createMany([
  { title: "First Post", content: "Hello world!" },
  { title: "Second Post", content: "MongoDB is great!" },
]);

// Query posts
const userPosts = await user.related("posts").query().all();

// Load relationship
const userWithPosts = await User.query()
  .load("posts", (query) => {
    query.orderBy("createdAt", "desc").limit(5);
  })
  .findOrFail("user123");

Belongs To Relationship

Belongs to is the inverse of has one and has many relationships.

Using Belongs To

// Access parent model
const post = await Post.query().load("author").findOrFail("post123");

console.log(post.author.name);

// Query through relationship
const post = await Post.findOrFail("post123");
const author = await post.related("author").query().firstOrFail();

Alternative Approaches for Complex Relationships

While many-to-many and has-many-through relationships are planned for future releases, you can achieve similar functionality using these approaches:

Manual Reference Management

For many-to-many relationships, you can manually manage arrays of references:

// User model with role references
export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  @column()
  declare roleIds: string[]; // Array of role IDs

  // Helper method to get roles
  async getRoles() {
    return await Role.query()
      .whereIn("_id", this.roleIds || [])
      .all();
  }

  // Helper method to add role
  async addRole(roleId: string) {
    if (!this.roleIds) this.roleIds = [];
    if (!this.roleIds.includes(roleId)) {
      this.roleIds.push(roleId);
      await this.save();
    }
  }

  // Helper method to remove role
  async removeRole(roleId: string) {
    if (this.roleIds) {
      this.roleIds = this.roleIds.filter((id) => id !== roleId);
      await this.save();
    }
  }
}

Using Embedded Documents

For simpler many-to-many scenarios, consider using embedded documents:

// User model with embedded role information
export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  // Embedded roles (denormalized approach)
  @column.embedded(() => UserRole, "many")
  declare roles?: EmbeddedMany<typeof UserRole>;
}

// Embedded role model
export default class UserRole extends BaseModel {
  @column()
  declare roleId: string;

  @column()
  declare roleName: string;

  @column()
  declare permissions: string[];

  @column.dateTime({ autoCreate: true })
  declare assignedAt: DateTime;
}

Through Relationships with Manual Queries

For has-many-through scenarios, you can use manual queries:

export default class Country extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  // Helper method to get posts through users
  async getPosts() {
    // First get users in this country
    const users = await User.query().where("countryId", this._id).all();
    const userIds = users.map((user) => user._id);

    // Then get posts by those users
    return await Post.query().whereIn("userId", userIds).all();
  }

  // Helper method with query builder
  async getPostsQuery() {
    const users = await User.query().where("countryId", this._id).all();
    const userIds = users.map((user) => user._id);

    return Post.query().whereIn("userId", userIds);
  }
}

Advanced Relationship Queries

Loading with Conditions

// Load with conditions
const users = await User.query()
  .load("posts", (query) => {
    query.where("isPublished", true).orderBy("createdAt", "desc").limit(3);
  })
  .all();

// Multiple loads
const users = await User.query()
  .load("profile")
  .load("posts", (query) => {
    query.where("isPublished", true);
  })
  .all();

Manual Relationship Queries

Since advanced relationship querying features are not yet implemented, you can use manual queries:

// Count related records manually
const users = await User.query().all();
for (const user of users) {
  const postCount = await Post.query().where("userId", user._id).count();
  const publishedPostCount = await Post.query()
    .where("userId", user._id)
    .where("isPublished", true)
    .count();

  console.log(`${user.name} has ${postCount} posts`);
  console.log(`${user.name} has ${publishedPostCount} published posts`);
}

// Query users with posts manually
const userIds = await Post.query().distinct("userId");
const usersWithPosts = await User.query().whereIn("_id", userIds).all();

// Query users without posts manually
const usersWithoutPosts = await User.query().whereNotIn("_id", userIds).all();

Relationship Constraints

Custom Foreign Keys

export default class User extends BaseModel {
  @hasMany(() => Post, {
    foreignKey: "authorId", // Custom foreign key
    localKey: "_id",
  })
  declare posts: HasMany<typeof Post>;
}

Relationship Options

export default class User extends BaseModel {
  @hasOne(() => UserProfile, {
    foreignKey: "userId",
    localKey: "_id",
    onQuery: (query) => {
      // Add default constraints
      query.where("isActive", true);
    },
  })
  declare profile: HasOne<typeof UserProfile>;
}

Performance Optimization

Eager Loading

// Efficient: Single query with load
const users = await User.query().load("posts").all();

// Inefficient: N+1 queries
const users = await User.query().all();
for (const user of users) {
  const posts = await user.related("posts").query().all();
}

Selective Loading

// Load only needed fields
const users = await User.query()
  .select(["name", "email"])
  .load("posts", (query) => {
    query.select(["title", "createdAt"]);
  })
  .all();

Pagination with Relationships

// Paginate main query, load relationships
const result = await User.query()
  .load("posts", (query) => {
    query.orderBy("createdAt", "desc").limit(5);
  })
  .paginate(1, 20);

Best Practices

Use Embedded Documents for Simple Relationships

When you have data that naturally belongs together, consider using embedded documents instead of references:

// Instead of separate Profile model
export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  // Embed profile data directly
  @column.embedded(() => UserProfile, "single")
  declare profile?: EmbeddedSingle<typeof UserProfile>;
}

Optimize Reference Arrays

When using manual reference management, consider indexing and limiting array sizes:

export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare _id: string;

  @column()
  declare name: string;

  // Limit array size for performance
  @column()
  declare recentPostIds: string[]; // Only last 10 posts

  @column()
  declare allPostsCount: number; // Keep count separately
}

Next Steps