Skip to content

One_to_many

Chapter 27: One-to-Many & Many-to-One Relationships

Section titled “Chapter 27: One-to-Many & Many-to-One Relationships”

Mastering Parent-Child Entity Relationships

Section titled “Mastering Parent-Child Entity Relationships”

A one-to-many relationship connects two entities where one instance of the first entity can be associated with multiple instances of the second entity.

One-to-Many Relationship
================================================================================
+------------------+ +------------------+
| User | | Post |
+------------------+ +------------------+
| id (PK) | 1:N | id (PK) |
| name | <---------> | title |
| email | | content |
| | | authorId (FK) |
+------------------+ +------------------+
|
|
Foreign Key
References User.id
Example:
- User 1 -----> Post 1, Post 2, Post 3
- User 2 -----> Post 4
- User 3 -----> (no posts)
================================================================================

src/users/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Post } from '../posts/post.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
// One user has many posts
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
src/posts/post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column('text')
content: string;
// Many posts belong to one user (owning side)
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'authorId' })
author: User;
}

Generated Database Schema
================================================================================
Table: users
+------------+----------+---------------+
| Column | Type | Constraints |
+------------+----------+---------------+
| id | integer | PRIMARY KEY |
| name | varchar | NOT NULL |
| email | varchar | UNIQUE |
+------------+----------+---------------+
Table: posts
+------------+----------+---------------+
| Column | Type | Constraints |
+------------+----------+---------------+
| id | integer | PRIMARY KEY |
| title | varchar | NOT NULL |
| content | text | |
| authorId | integer | FK (users.id) |
+------------+----------+---------------+
|
| FOREIGN KEY
v
users.id
Note: authorId is NOT unique (multiple posts can have same author)
================================================================================

src/posts/post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
// Many-to-one with all options
@ManyToOne(() => User, (user) => user.posts, {
// Eager loading
eager: false, // Don't auto-load author
// Cascading
cascade: true, // Cascade insert/update
// cascade: ['insert', 'update'],
// Delete behavior
onDelete: 'CASCADE', // Delete posts when user deleted
// onDelete: 'SET NULL', // Set authorId to null
// onDelete: 'RESTRICT', // Prevent user deletion if posts exist
// Nullability
nullable: false, // authorId cannot be null
// Default value
default: undefined,
})
@JoinColumn({ name: 'authorId' })
author: User;
}
src/users/user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Post, (post) => post.author, {
// Cascading
cascade: true, // Auto-save posts with user
cascade: ['insert', 'update', 'remove'],
// Eager loading
eager: false, // Don't auto-load posts
// Nullable (for foreign key)
nullable: true, // Posts can exist without user
})
posts: Post[];
}

27.5 Working with One-to-Many Relationships

Section titled “27.5 Working with One-to-Many Relationships”
// Method 1: Create parent first, then children
const user = await userRepository.save({
name: 'John Doe',
email: 'john@example.com',
});
await postRepository.save([
{ title: 'Post 1', content: 'Content 1', author: user },
{ title: 'Post 2', content: 'Content 2', author: user },
]);
// Method 2: Create with cascade
const user = await userRepository.save({
name: 'John Doe',
email: 'john@example.com',
posts: [
{ title: 'Post 1', content: 'Content 1' },
{ title: 'Post 2', content: 'Content 2' },
],
});
// Method 3: Using entity instance
const user = userRepository.create({
name: 'John Doe',
email: 'john@example.com',
});
user.posts = [
postRepository.create({ title: 'Post 1', content: 'Content 1' }),
postRepository.create({ title: 'Post 2', content: 'Content 2' }),
];
await userRepository.save(user);
// Find user with posts
const userWithPosts = await userRepository.findOne({
where: { id: 1 },
relations: ['posts'],
});
// Find posts with author
const postsWithAuthor = await postRepository.find({
relations: ['author'],
});
// Using QueryBuilder
const userWithPosts = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.id = :id', { id: 1 })
.getOne();
// Select specific columns
const userWithPostTitles = await userRepository
.createQueryBuilder('user')
.leftJoin('user.posts', 'post')
.select(['user.id', 'user.name', 'post.id', 'post.title'])
.where('user.id = :id', { id: 1 })
.getOne();
// Update post's author
const post = await postRepository.findOne({ where: { id: 1 } });
const newAuthor = await userRepository.findOne({ where: { id: 2 } });
post.author = newAuthor;
await postRepository.save(post);
// Add new post to user
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['posts'],
});
user.posts.push({ title: 'New Post', content: 'Content' } as Post);
await userRepository.save(user);
// Replace all posts
const user = await userRepository.findOne({ where: { id: 1 } });
user.posts = [
{ title: 'Post 1', content: 'Content 1' } as Post,
{ title: 'Post 2', content: 'Content 2' } as Post,
];
await userRepository.save(user);
// Delete specific post
await postRepository.delete({ id: 1 });
// Delete all posts by user
await postRepository.delete({ author: { id: 1 } });
// Delete user (posts deleted via CASCADE)
await userRepository.delete({ id: 1 });
// Remove post from user's collection
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['posts'],
});
user.posts = user.posts.filter(p => p.id !== postId);
await userRepository.save(user);

// Using QueryBuilder
const usersWithPostCount = await userRepository
.createQueryBuilder('user')
.leftJoin('user.posts', 'post')
.select(['user.id', 'user.name', 'user.email'])
.addSelect('COUNT(post.id)', 'postCount')
.groupBy('user.id')
.getRawMany();
// Result:
// [
// { user_id: 1, user_name: 'John', user_email: 'john@example.com', postCount: '3' },
// { user_id: 2, user_name: 'Jane', user_email: 'jane@example.com', postCount: '5' },
// ]
const usersWithLatestPost = await userRepository
.createQueryBuilder('user')
.leftJoin(
(qb) => qb
.select('authorId')
.addSelect('MAX(createdAt)', 'latestDate')
.from(Post, 'post')
.groupBy('authorId'),
'latest',
'latest.authorId = user.id'
)
.leftJoin('user.posts', 'post', 'post.createdAt = latest.latestDate')
.select(['user.id', 'user.name', 'post.title', 'post.createdAt'])
.getRawMany();
const postsWithAuthor = await postRepository
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.orderBy('post.createdAt', 'DESC')
.take(10)
.getMany();
// Using find options
const postsWithAuthor = await postRepository.find({
relations: ['author'],
order: { createdAt: 'DESC' },
take: 10,
});
// Posts by active users
const posts = await postRepository
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.where('author.isActive = :isActive', { isActive: true })
.getMany();
// Posts by users with specific role
const posts = await postRepository
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.where('author.role = :role', { role: 'admin' })
.getMany();
async function getPostsByUser(
userId: number,
page: number,
limit: number,
) {
const skip = (page - 1) * limit;
const [posts, total] = await postRepository
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.where('author.id = :userId', { userId })
.orderBy('post.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data: posts,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

src/categories/category.entity.ts
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// Parent category
@ManyToOne(() => Category, (category) => category.children)
@JoinColumn({ name: 'parentId' })
parent: Category;
// Child categories
@OneToMany(() => Category, (category) => category.parent)
children: Category[];
}
// Usage
const parentCategory = await categoryRepository.save({
name: 'Electronics',
});
const childCategory = await categoryRepository.save({
name: 'Laptops',
parent: parentCategory,
});
// Query with children
const categoryWithChildren = await categoryRepository.findOne({
where: { id: 1 },
relations: ['children', 'children.children'], // Nested relations
});
src/comments/comment.entity.ts
@Entity()
export class Comment {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
content: string;
// Parent comment (for replies)
@ManyToOne(() => Comment, (comment) => comment.replies, {
nullable: true, // Root comments have no parent
})
@JoinColumn({ name: 'parentId' })
parent: Comment;
// Reply comments
@OneToMany(() => Comment, (comment) => comment.parent)
replies: Comment[];
// Post the comment belongs to
@ManyToOne(() => Post, (post) => post.comments)
post: Post;
// User who wrote the comment
@ManyToOne(() => User)
author: User;
}

src/users/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Post } from '../posts/post.entity';
import { Comment } from '../comments/comment.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
@Index()
name: string;
@Column({ unique: true })
@Index()
email: string;
@Column({ select: false })
password: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => Post, (post) => post.author, {
cascade: ['insert'],
eager: false,
})
posts: Post[];
@OneToMany(() => Comment, (comment) => comment.author)
comments: Comment[];
}
// src/posts/post.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { User } from '../users/user.entity';
import { Comment } from '../comments/comment.entity';
@Entity()
@Index(['author', 'createdAt']) // Composite index
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
@Index()
title: string;
@Column('text')
content: string;
@Column({ default: 0 })
viewCount: number;
@Column({ default: true })
isPublished: boolean;
@CreateDateColumn()
@Index()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => User, (user) => user.posts, {
eager: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'authorId' })
author: User;
@OneToMany(() => Comment, (comment) => comment.post, {
cascade: true,
})
comments: Comment[];
}
// src/posts/posts.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './post.entity';
import { User } from '../users/user.entity';
@Injectable()
export class PostsService {
constructor(
@InjectRepository(Post)
private postRepository: Repository<Post>,
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(userId: number, postData: any): Promise<Post> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
const post = this.postRepository.create({
...postData,
author: user,
});
return this.postRepository.save(post);
}
async findAll(page: number, limit: number) {
const skip = (page - 1) * limit;
const [data, total] = await this.postRepository.findAndCount({
relations: ['author'],
order: { createdAt: 'DESC' },
skip,
take: limit,
select: {
id: true,
title: true,
createdAt: true,
author: {
id: true,
name: true,
},
},
});
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findByAuthor(authorId: number, page: number, limit: number) {
const skip = (page - 1) * limit;
const [data, total] = await this.postRepository
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.where('author.id = :authorId', { authorId })
.orderBy('post.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOneWithDetails(id: number) {
const post = await this.postRepository
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.leftJoinAndSelect('post.comments', 'comments')
.leftJoinAndSelect('comments.author', 'commentAuthor')
.where('post.id = :id', { id })
.getOne();
if (!post) {
throw new NotFoundException('Post not found');
}
// Increment view count
await this.postRepository
.createQueryBuilder()
.update(Post)
.set({ viewCount: () => 'viewCount + 1' })
.where('id = :id', { id })
.execute();
return post;
}
async getPostStats(authorId: number) {
const stats = await this.postRepository
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.where('author.id = :authorId', { authorId })
.select('COUNT(post.id)', 'totalPosts')
.addSelect('SUM(post.viewCount)', 'totalViews')
.addSelect('AVG(post.viewCount)', 'avgViews')
.getRawOne();
return stats;
}
}

One-to-Many Relationship Quick Reference
+------------------------------------------------------------------+
| |
| Decorator | Purpose |
| -------------------|------------------------------------------|
| @OneToMany() | Define one-to-many (parent side) |
| @ManyToOne() | Define many-to-one (child/owning side) |
| @JoinColumn() | Specify foreign key column |
| |
| Key Points | Description |
| -------------------|------------------------------------------|
| Owning side | Child entity (has foreign key) |
| Inverse side | Parent entity (has collection) |
| cascade | Auto-save related entities |
| onDelete | Action on parent delete |
| |
| Best Practices | Description |
| -------------------|------------------------------------------|
| Index FK columns | Improve join performance |
| Use pagination | Prevent memory issues |
| Avoid eager: true | Prevent N+1 problems |
| Use QueryBuilder | For complex queries |
| |
+------------------------------------------------------------------+

Chapter 28: Many-to-Many Relationships


Last Updated: February 2026