Skip to content

Many_to_many

Mastering Many-to-Many Entity Relationships

Section titled “Mastering Many-to-Many Entity Relationships”

A many-to-many relationship connects two entities where multiple instances of each entity can be associated with each other.

Many-to-Many Relationship
================================================================================
+------------------+ +------------------+ +------------------+
| User | | user_roles | | Role |
+------------------+ +------------------+ +------------------+
| id (PK) | M:N | userId (FK) | M:N | id (PK) |
| name | <------>| roleId (FK) |<------->| name |
| email | | (composite PK) | | description |
+------------------+ +------------------+ +------------------+
|
|
Junction Table
(Join Table)
Example:
- User 1 -----> Role 1, Role 2
- User 2 -----> Role 2, Role 3
- Role 1 -----> User 1, User 3
- Role 2 -----> User 1, User 2
================================================================================

src/users/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Role } from '../roles/role.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
// Many users have many roles
@ManyToMany(() => Role, (role) => role.users)
@JoinTable() // Owning side - creates junction table
roles: Role[];
}
src/roles/role.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { User } from '../users/user.entity';
@Entity()
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ nullable: true })
description: string;
// Many roles belong to many users (inverse side)
@ManyToMany(() => User, (user) => user.roles)
users: User[];
}

Generated Database Schema
================================================================================
Table: users
+------------+----------+---------------+
| Column | Type | Constraints |
+------------+----------+---------------+
| id | integer | PRIMARY KEY |
| name | varchar | NOT NULL |
| email | varchar | UNIQUE |
+------------+----------+---------------+
Table: roles
+------------+----------+---------------+
| Column | Type | Constraints |
+------------+----------+---------------+
| id | integer | PRIMARY KEY |
| name | varchar | UNIQUE |
| description| varchar | |
+------------+----------+---------------+
Table: user_roles_roles (Junction Table)
+------------+----------+---------------+
| Column | Type | Constraints |
+------------+----------+---------------+
| userId | integer | FK (users.id) |
| roleId | integer | FK (roles.id) |
+------------+----------+---------------+
| |
| +---- FOREIGN KEY -> roles.id
+------------------- FOREIGN KEY -> users.id
Primary Key: (userId, roleId)
================================================================================

Owning Side vs Inverse Side
================================================================================
Owning Side (User):
- Has @JoinTable() decorator
- Controls the junction table
- Changes here affect the database
Inverse Side (Role):
- References owning side in @ManyToMany
- No @JoinTable() decorator
- Read-only from this side
Example:
user.roles = [role1, role2]; // This will update junction table
await userRepository.save(user);
role.users = [user1, user2]; // This will NOT update junction table
await roleRepository.save(role); // Need to save from User side
================================================================================
src/users/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Role, (role) => role.users)
@JoinTable({
name: 'user_role', // Custom junction table name
joinColumn: {
name: 'user_id', // Custom column name for User
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'role_id', // Custom column name for Role
referencedColumnName: 'id',
},
})
roles: Role[];
}

28.5 Working with Many-to-Many Relationships

Section titled “28.5 Working with Many-to-Many Relationships”
// Method 1: Assign roles array
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});
const adminRole = await roleRepository.findOne({ where: { name: 'admin' } });
const editorRole = await roleRepository.findOne({ where: { name: 'editor' } });
user.roles = [adminRole, editorRole];
await userRepository.save(user);
// Method 2: Add to existing roles
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});
const newRole = await roleRepository.findOne({ where: { name: 'moderator' } });
user.roles.push(newRole);
await userRepository.save(user);
// Method 3: Using QueryBuilder
await userRepository
.createQueryBuilder()
.relation(User, 'roles')
.of(user)
.add(role);
// Method 1: Remove specific role
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});
user.roles = user.roles.filter(r => r.name !== 'admin');
await userRepository.save(user);
// Method 2: Remove all roles
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});
user.roles = [];
await userRepository.save(user);
// Method 3: Using QueryBuilder
await userRepository
.createQueryBuilder()
.relation(User, 'roles')
.of(user)
.remove(role);
// Method 4: Remove and add in one operation
await userRepository
.createQueryBuilder()
.relation(User, 'roles')
.of(user)
.addAndRemove([newRole1, newRole2], [oldRole1, oldRole2]);
// Find user with roles
const userWithRoles = await userRepository.findOne({
where: { id: 1 },
relations: ['roles'],
});
// Find role with users
const roleWithUsers = await roleRepository.findOne({
where: { name: 'admin' },
relations: ['users'],
});
// Using QueryBuilder
const userWithRoles = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role')
.where('user.id = :id', { id: 1 })
.getOne();
// Find users by role
const usersWithAdminRole = await userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.where('role.name = :roleName', { roleName: 'admin' })
.getMany();

Many-to-Many with Extra Columns
================================================================================
When you need additional columns in the junction table:
+------------------+ +------------------+ +------------------+
| User | | UserTeam | | Team |
+------------------+ +------------------+ +------------------+
| id (PK) | 1:N | id (PK) | N:1 | id (PK) |
| name | <------>| userId (FK) |<------->| name |
| email | | teamId (FK) | | description |
+------------------+ | role (extra!) | +------------------+
| joinedAt (extra!)|
+------------------+
This transforms M:N into two 1:N relationships
================================================================================
src/users/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
email: string;
@OneToMany(() => UserTeam, (userTeam) => userTeam.user)
userTeams: UserTeam[];
}
// src/teams/team.entity.ts
@Entity()
export class Team {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@OneToMany(() => UserTeam, (userTeam) => userTeam.team)
userTeams: UserTeam[];
// Virtual many-to-many for convenience
@ManyToMany(() => User, (user) => user.teams)
users: User[];
}
// src/user-teams/user-team.entity.ts
@Entity()
export class UserTeam {
@PrimaryGeneratedColumn()
id: number;
@Column()
userId: number;
@Column()
teamId: number;
@Column()
role: string; // Extra column: user's role in team
@CreateDateColumn()
joinedAt: Date; // Extra column: when user joined
@ManyToOne(() => User, (user) => user.userTeams)
user: User;
@ManyToOne(() => Team, (team) => team.userTeams)
team: Team;
@Column({ default: true })
isActive: boolean; // Extra column: is membership active
}
src/user-teams/user-teams.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTeam } from './user-team.entity';
import { User } from '../users/user.entity';
import { Team } from '../teams/team.entity';
@Injectable()
export class UserTeamsService {
constructor(
@InjectRepository(UserTeam)
private userTeamRepository: Repository<UserTeam>,
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Team)
private teamRepository: Repository<Team>,
) {}
// Add user to team with role
async addUserToTeam(
userId: number,
teamId: number,
role: string,
): Promise<UserTeam> {
const userTeam = this.userTeamRepository.create({
userId,
teamId,
role,
});
return this.userTeamRepository.save(userTeam);
}
// Update user's role in team
async updateUserRole(
userId: number,
teamId: number,
newRole: string,
): Promise<UserTeam> {
const userTeam = await this.userTeamRepository.findOne({
where: { userId, teamId },
});
if (!userTeam) {
throw new Error('User not in team');
}
userTeam.role = newRole;
return this.userTeamRepository.save(userTeam);
}
// Remove user from team
async removeUserFromTeam(userId: number, teamId: number): Promise<void> {
await this.userTeamRepository.delete({ userId, teamId });
}
// Get all users in team with their roles
async getTeamMembers(teamId: number) {
return this.userTeamRepository
.createQueryBuilder('userTeam')
.leftJoinAndSelect('userTeam.user', 'user')
.where('userTeam.teamId = :teamId', { teamId })
.andWhere('userTeam.isActive = :isActive', { isActive: true })
.getMany();
}
// Get all teams for user with roles
async getUserTeams(userId: number) {
return this.userTeamRepository
.createQueryBuilder('userTeam')
.leftJoinAndSelect('userTeam.team', 'team')
.where('userTeam.userId = :userId', { userId })
.getMany();
}
// Get users by role in team
async getUsersByRole(teamId: number, role: string) {
return this.userTeamRepository
.createQueryBuilder('userTeam')
.leftJoinAndSelect('userTeam.user', 'user')
.where('userTeam.teamId = :teamId', { teamId })
.andWhere('userTeam.role = :role', { role })
.getMany();
}
}

// Users with both 'admin' AND 'editor' roles
const users = await userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.where((qb) => {
const subQuery = qb
.subQuery()
.select('ur.userId')
.from('user_roles', 'ur')
.innerJoin('roles', 'r', 'r.id = ur.roleId')
.where('r.name IN (:...roles)', { roles: ['admin', 'editor'] })
.groupBy('ur.userId')
.having('COUNT(r.id) = 2')
.getQuery();
return `user.id IN ${subQuery}`;
})
.getMany();
// Users with 'admin' OR 'editor' role
const users = await userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.where('role.name IN (:...roles)', { roles: ['admin', 'editor'] })
.distinct(true)
.getMany();
const roleUserCounts = await roleRepository
.createQueryBuilder('role')
.leftJoin('role.users', 'user')
.select('role.id', 'roleId')
.addSelect('role.name', 'roleName')
.addSelect('COUNT(user.id)', 'userCount')
.groupBy('role.id')
.getRawMany();
// Result:
// [
// { roleId: 1, roleName: 'admin', userCount: '5' },
// { roleId: 2, roleName: 'editor', userCount: '10' },
// ]
// Users without 'admin' role
const usersWithoutAdmin = await userRepository
.createQueryBuilder('user')
.where((qb) => {
const subQuery = qb
.subQuery()
.select('ur.userId')
.from('user_roles', 'ur')
.innerJoin('roles', 'r', 'r.id = ur.roleId')
.where('r.name = :roleName')
.getQuery();
return `user.id NOT IN ${subQuery}`;
})
.setParameter('roleName', 'admin')
.getMany();

src/users/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
username: string;
// Users this user follows
@ManyToMany(() => User, (user) => user.followers)
@JoinTable({
name: 'user_follows',
joinColumn: { name: 'followerId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'followingId', referencedColumnName: 'id' },
})
following: User[];
// Users who follow this user
@ManyToMany(() => User, (user) => user.following)
followers: User[];
}
// Usage
// User 1 follows User 2
const user1 = await userRepository.findOne({ where: { id: 1 } });
const user2 = await userRepository.findOne({ where: { id: 2 } });
user1.following.push(user2);
await userRepository.save(user1);
// Get user's followers
const userWithFollowers = await userRepository.findOne({
where: { id: 1 },
relations: ['followers'],
});
// Get users that a user follows
const userWithFollowing = await userRepository.findOne({
where: { id: 1 },
relations: ['following'],
});
// Count followers and following
const user = await userRepository
.createQueryBuilder('user')
.leftJoin('user.followers', 'follower')
.leftJoin('user.following', 'following')
.select('user.id', 'userId')
.addSelect('COUNT(DISTINCT follower.id)', 'followersCount')
.addSelect('COUNT(DISTINCT following.id)', 'followingCount')
.where('user.id = :id', { id: 1 })
.groupBy('user.id')
.getRawOne();

src/users/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Role } from '../roles/role.entity';
import { Permission } from '../permissions/permission.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false })
password: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToMany(() => Role, (role) => role.users, {
eager: false,
cascade: ['insert'],
})
@JoinTable({
name: 'user_roles',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles: Role[];
}
// src/roles/role.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable,
} from 'typeorm';
import { User } from '../users/user.entity';
import { Permission } from '../permissions/permission.entity';
@Entity()
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ nullable: true })
description: string;
@ManyToMany(() => User, (user) => user.roles)
users: User[];
@ManyToMany(() => Permission, (permission) => permission.roles, {
eager: true,
})
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions: Permission[];
}
// src/permissions/permission.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { Role } from '../roles/role.entity';
@Entity()
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ nullable: true })
description: string;
@ManyToMany(() => Role, (role) => role.permissions)
roles: Role[];
}
// src/users/users.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { Role } from '../roles/role.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Role)
private roleRepository: Repository<Role>,
) {}
async assignRoles(userId: number, roleIds: number[]): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['roles'],
});
if (!user) {
throw new NotFoundException('User not found');
}
const roles = await this.roleRepository.findByIds(roleIds);
if (roles.length !== roleIds.length) {
throw new BadRequestException('Some roles not found');
}
user.roles = roles;
return this.userRepository.save(user);
}
async addRole(userId: number, roleId: number): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['roles'],
});
if (!user) {
throw new NotFoundException('User not found');
}
const role = await this.roleRepository.findOne({ where: { id: roleId } });
if (!role) {
throw new NotFoundException('Role not found');
}
// Check if user already has this role
const hasRole = user.roles.some(r => r.id === roleId);
if (hasRole) {
throw new BadRequestException('User already has this role');
}
user.roles.push(role);
return this.userRepository.save(user);
}
async removeRole(userId: number, roleId: number): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['roles'],
});
if (!user) {
throw new NotFoundException('User not found');
}
user.roles = user.roles.filter(r => r.id !== roleId);
return this.userRepository.save(user);
}
async getUserWithRoles(userId: number) {
return this.userRepository.findOne({
where: { id: userId },
relations: ['roles', 'roles.permissions'],
});
}
async getUserPermissions(userId: number): Promise<string[]> {
const user = await this.userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.leftJoin('role.permissions', 'permission')
.select('permission.name', 'permissionName')
.where('user.id = :userId', { userId })
.getRawMany();
return user.map(u => u.permissionName).filter(Boolean);
}
async hasPermission(userId: number, permissionName: string): Promise<boolean> {
const result = await this.userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.leftJoin('role.permissions', 'permission')
.where('user.id = :userId', { userId })
.andWhere('permission.name = :permissionName', { permissionName })
.getCount();
return result > 0;
}
async getUsersByRole(roleName: string) {
return this.userRepository
.createQueryBuilder('user')
.leftJoin('user.roles', 'role')
.where('role.name = :roleName', { roleName })
.getMany();
}
}

Many-to-Many Relationship Quick Reference
+------------------------------------------------------------------+
| |
| Decorator | Purpose |
| -------------------|------------------------------------------|
| @ManyToMany() | Define many-to-many relationship |
| @JoinTable() | Specify junction table (owning side) |
| |
| Key Points | Description |
| -------------------|------------------------------------------|
| Owning side | Entity with @JoinTable |
| Inverse side | Entity without @JoinTable |
| Junction table | Auto-created table for M:N |
| |
| Operations | Method |
| -------------------|------------------------------------------|
| Add relation | user.roles.push(role); save() |
| Remove relation | user.roles.filter(); save() |
| QueryBuilder add | .relation().of().add() |
| QueryBuilder remove| .relation().of().remove() |
| |
| Best Practices | Description |
| -------------------|------------------------------------------|
| Use junction entity| When you need extra columns |
| Index FK columns | Improve join performance |
| Avoid eager: true | Prevent N+1 problems |
| Use distinct | Prevent duplicate results |
| |
+------------------------------------------------------------------+

Chapter 29: Transactions & Concurrency


Last Updated: February 2026