Khi xây dựng hệ thống, chúng ta tạo ra nhiều model. Giữa chúng sẽ có những mối liên hệ (Relationship) theo những cách nào đó, và xác định mối quan hệ như vậy là một điều quan trọng và cần thiết khi thiết kế csdl. Trong bài viết này chúng ta sẽ tìm hiểu và xây dựng các mối quan hệ trong Mongoose với NestJS.

Theo dõi source code tại đâybranch dev nha

Link postman để test cho dễ.

Các bạn tiếp tục với source code hơm bữa nha.

1-n relationship ( One to Many)

Những bài trước mình đã tiến hành tạo một số model và chúng ta sẽ tiếp tục làm việc xoay quanh nó.

Ở quan hệ này, chúng ta sẽ xây dựng mối quan hệ 1 – n giữa Post và User. Một người có thể viết nhiều bài viết và mỗi bài viết thuộc về 1 người.

Mở file post.model.ts lên và chỉnh sửa lại 1 xíu

...
const PostSchema = new Schema(
  {
    title: String,
    description: String,
    content: String,
    user: {
      type: Schema.Types.ObjectId,
      ref: 'User',
    },
  },
  ...
);
...

export interface Post extends Document {
  ...
  user: User;
}

Trong post.controller.ts ta chỉnh sửa lại xíu để khi tạo bài viết, ta sẽ lưu lại id của thằng User:

...

@UseGuards(AuthGuard('jwt'))
@Post()
async createPost(@Req() req: any, @Body() post: CreatePostDto) {
  return this.postService.createPost(req.user, post);
}

...

Tiếp tục thay đổi một số file để có thể tạo bài viết

post.service.ts

...
async createPost(user: User, post: CreatePostDto) {
  post.user = user._id;
  return await this.postRepository.create(post);
}

...

post.dto.ts

export class CreatePostDto {
  @IsNotEmpty() title: string;
  description: string;
  content: string;
  user: string;
}

Mở post main lên và test ngay thôi nào, nên nhớ phải login rồi sử dụng token để xác thực thì mới có thể tạo được nha các bạn.

Khi gọi API …/post/:id, :id chính là id của bài viết chúng ta vừa tạo á sẽ nhận được kết quả sau

{
    "_id": "629f66053581547353bc0f8f",
    "title": "Bai viet 1",
    "user": "62713439e262d10be2ffcc3e",
    "createdAt": "2022-06-07T14:51:49.133Z",
    "updatedAt": "2022-06-07T14:51:49.133Z",
    "__v": 0
}

Giả dụ chỗ này chúng ta cần lấy thông tin user thì làm như thế nào?
Đơn giản ta vào file post.service.ts và cập nhật lại function 1 xíu.
Ở phần Post model chúng ta đã khai báo ref: 'User' nên nó sẽ tự hiểu chung ta đã khai báo quan hệ tới thằng User. Ta thêm dòng await post.populate(‘user’).execPopulate(); vào là xong. Thì có nhiều cách để chúng ta có thể lấy được thông tin thông qua Relationship. Mình sẽ giới thiệu rõ về Populate trong những bài sắp tới.

async getPostById(post_id: string) {
  const post = await this.postRepository.findById(post_id);
  if (post) {
    await post.populate('user').execPopulate();
    return post;
  } else {
    // throw new PostNotFoundException(post_id);
    throw new NotFoundException(`Post with id ${post_id} not found`);
  }
}

Gọi lại api ta nhận được

{
    "_id": "629f66053581547353bc0f8f",
    "title": "Bai viet 1",
    "user": {
        "_id": "62713439e262d10be2ffcc3e",
        "name": "Nguyen quang dat",
        "email": "datnquit@gmail.com",
        "password": "$2b$10$FR43Dkd5LoskSqFOcLxK6utS6tFqhxqQKdZnM3aL/OFw2VHTGowoG",
        "__v": 0
    },
    "createdAt": "2022-06-07T14:51:49.133Z",
    "updatedAt": "2022-06-07T14:51:49.133Z",
    "__v": 0
}

Ở đây nó trả về cả mật khẩu. Như mình đã nói ở trên, mình sẽ có bài viết nói rõ hơn về Populate. Chúng ta có thể chủ động trả về những field mong muốn và nhiều thứ hay ho đang chờ phía trước.

Mối quan hệ 1 – n và 1 – 1 ở đây cách khai báo giống nhau. Giả dụ như 1 người chỉ được viết 1 bài viết, thì đơn giản ta kiểm tra người đó đã viết bài nào chưa, và lấy bài viết theo điều kiện là id của user.

Có một số kĩ thuật giúp ta dễ dàng hơn trong việc này.
Để lấy những bài viết của 1 user, mà không phải kiểm tra từ bài viết ta có thể thử cách như sau:

Mở file user.model.ts trong module user và khai báo thêm đoạn code trên đoạn export

UserSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'user',
  justOne: false,
});

justOne thể hiện quan hệ 1 – 1 hay 1 – n, mặc định nếu không khai báo thì sẽ hiểu là false, sẽ trả về 1 array những bài viết.

Quay trở lại post.controller.ts, ta khai báo 1 function mới, trả về những bài viết của User

@UseGuards(AuthGuard('jwt'))
@Get('user/all')
async getPostUser(@Req() req: any) {
  await req.user.populate('posts').execPopulate();
  return req.user.posts;
}

Khi gọi api, ta sẽ nhận được array các bài viết. Nếu set justOne: true thì ta chỉ nhận được 1 bài viết, không phải là array.

Như z là ta đã tìm hiểu được 2 mối quan hệ khá phổ biến rồi. Cuối cùng là quan hệ nhiều – nhiều thôi.

n-n relationship (Many to Many)

Khi học MySql hay MSSQL chúng ta hay nghe khái niệm quan hệ nhiều nhiều ta thiết kế 3 bảng. Nghĩa là sẽ có 1 bảng trung gian thể hiện quan hệ này. Nhưng ở Mongo hoàn toàn khác, ta chỉ cần 2 bảng thể hiện cho mối quan hệ này. Nhờ hỗ trợ kiểu dữ liệu đa dạng mà mongo có thể lưu trữ được nhiều kiểu dữ liệu khác nhau. Giúp cho chúng ta dễ dàng thiết kế các cấu trúc phức tạp.

Bây giờ chúng ta sẽ xây dựng cho ứng dụng của chúng ta. Hiện tại ta có bảng Post, ta tạo thêm bảng Category. Và mối quan hệ ở đây: 1 danh mục có nhiều bài viết, và 1 bài viết thuộc về nhiều danh mục.

Khi đó, mỗi category có 1 array để lưu trữ những object ID của Post, và ngược lại, mỗi Post sẽ lưu trữ array object ID của Category.

Bắt tay vào ngay thôi nào.

Đầu tiên ta sẽ tạo những thành phần liên quan đến Category như model, controller, repository và khai báo nó vào repository. Sau đó mới cập nhập lại phần Post.

Lúc trước các thành phần, các file mình đều để chung trong 1 folder post, bây giờ mình sẽ chia folder lại cho gọn gàng như folder controller, folder model,… các bạn theo dõi git để cập nhật tốt nhất nha.

Phần lớn chúng ta chỉ cần copy bên phần Post và sửa lại đôi chút thôi.

Tạo file post/models/category.model.ts với nội dung sau:

import { Schema, Document } from 'mongoose';

const CategorySchema = new Schema(
  {
    title: String,
    posts: [{ type: Schema.Types.ObjectId, ref: 'Post' }],
  },
  {
    timestamps: true,
    collection: 'categories',
  },
);

export { CategorySchema };

export interface Category extends Document {
  title: string;
  posts: [Post];
}

post/repositories/category.repository.ts

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Post } from '../models/post.model';
import { BaseRepository } from '../../base.repository';

@Injectable()
export class CategoryRepository extends BaseRepository<Post> {
  constructor(
    @InjectModel('Category')
    private readonly categoryModel: Model<Post>,
  ) {
    super(categoryModel);
  }
}

Phần repository không có gì đặc biệt, nên chỉ cần kế thừa từ BaseRepository là đủ dùng rồi nha.

post/services/category.service.ts

import {
  HttpException,
  HttpStatus,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { CategoryRepository } from '../repositories/category.repository';
import { CreateCategoryDto } from '../dto/category.dto';
import { PostRepository } from '../repositories/post.repository';

@Injectable()
export class CategoryService {
  constructor(
    private readonly categoryRepository: CategoryRepository,
    private readonly postRepository: PostRepository,
  ) {}

  async getAll() {
    return await this.categoryRepository.getByCondition({});
  }

  async create(createCategoryDto: CreateCategoryDto) {
    return await this.categoryRepository.create(createCategoryDto);
  }

  async getPosts(category_id) {
    return await this.postRepository.getByCondition({
      categories: { $elemMatch: { $eq: category_id } },
    });
  }
}

post/controllers/category.controller.ts

import {
  Body,
  Controller,
  Get,
  Param,
  Post,
} from '@nestjs/common';
import { CategoryService } from '../services/category.service';
import { CreateCategoryDto } from '../dto/category.dto';

@Controller('category')
export class CategoryController {
  constructor(private readonly categoryService: CategoryService) {}

  @Get()
  async getAllCategories() {
    return await this.categoryService.getAll();
  }

  @Post()
  async createCategory(@Body() createCategoryDto: CreateCategoryDto) {
    return await this.categoryService.create(createCategoryDto);
  }

  @Get(':id/posts')
  async getAllPostsOf(@Param('id') category_id) {
    return await this.categoryService.getPosts(category_id);
  }
}

Cuối cùng là: đừng quên khai báo tất cả chúng nó vào PostModule

import { Module } from '@nestjs/common';
import { PostController } from './controllers/post.controller';
import { PostService } from './services/post.service';
import { MongooseModule } from '@nestjs/mongoose';
import { PostSchema } from './models/post.model';
import { PostRepository } from './repositories/post.repository';
import { CategorySchema } from './models/category.model';
import { CategoryService } from './services/category.service';
import { CategoryRepository } from './repositories/category.repository';
import { CategoryController } from './controllers/category.controller';

@Module({
  imports: [
    MongooseModule.forFeature([
      {
        name: 'Post',
        schema: PostSchema,
      },
      {
        name: 'Category',
        schema: CategorySchema,
      },
    ]),
  ],
  controllers: [PostController, CategoryController],
  providers: [
    PostService,
    CategoryService,
    PostRepository,
    CategoryRepository,
  ],
})
export class PostModule {}

Quay trở lại phần post, ta cần update 1 xíu model cũng như phần controller để có thể test được cái relationship rắc rối này.

Mở file post.model.ts lên và chỉnh sửa lại 1 xíu

...
const PostSchema = new Schema(
  {
    title: String,
    ...
    categories: [{ type: Schema.Types.ObjectId, ref: 'Category' }],
  },
  ...
);
...

export interface Post extends Document {
  ...
  categories: [Category];
}

Thay đổi post.dto.ts trong phần tạo 1 xíu. Ta sẽ truyền thêm mảng id của category

export class CreatePostDto {
  ...
  categories: [string];
}

post/controllers/post.controller.ts

@Get('get/category')
async getByCategory(@Query('category_id') category_id) {
  return await this.postService.getByCategory(category_id);
}

@Get('get/categories')
async getByCategories(@Query('category_ids') category_ids) {
  return await this.postService.getByCategories(category_ids);
}

Controller chỉ thêm 2 function để giúp ta tìm kiếm thôi chứ không có gì, còn phần tạo ta sẽ thay đổi trong service

post/services/post.service.ts

async createPost(user: User, post: CreatePostDto) {
  post.user = user._id;
  const new_post = await this.postRepository.create(post);
  if (post.categories) {
    await this.categoryRepository.updateMany(
      {
        _id: { $in: post.categories },
      },
      {
        $push: {
          posts: new_post._id,
        },
      },
    );
  }
  return new_post;
}

async getByCategory(category_id: string) {
  return await this.postRepository.getByCondition({
    categories: {
      $elemMatch: { $eq: category_id },
    },
  });
}

async getByCategories(category_ids: [string]) {
  return await this.postRepository.getByCondition({
    categories: {
      $all: category_ids,
    },
  });
}

Mở postman lên và test ngay thôi nào. Mình đã để link postman ở đầu trang, các bạn sync về cho dễ theo dõi.

Mình lười cap từng ví dụ test quá, các bạn có thể theo dõi postman nha.

Kết luận

Trên đây là những gì tìm hiểu về các cách để thiết kế dữ liệu với mongoDB khi áp dụng Relationship, có thể thấy có khá nhiều cách thiết kế khác nhau có thể kết hợp lại. Có một số ‘rules’ đưa ra khi thiết kế tuy nhiên nó không phải là bắt buộc, mà sẽ phụ thuộc hoàn toàn vào ứng dụng ta đang phát triển để có một thiết kế tốt và linh hoạt nhất. Hi vọng bài viết giúp ích cho mọi người, hẹn gặp lại!

Nguồn tham khảo:

Rất mong được sự ủng hộ của mọi người để mình có động lực ra những bài viết tiếp theo.
{\__/}
( ~.~ )
/ > ♥️ I LOVE YOU 3000

JUST DO IT!