Khóa học NestJS Bài 04 - Model and Repository Pattern
Post Date : 2023-07-03T14:29:29+07:00
Modified Date : 2023-07-03T14:29:29+07:00
Category: nestjs-tutorial
Tags: nestjs , nestjs-pet-website
Source Code
Bài 04
- Giới thiệu sơ qua về một số ORM phổ biến : Sequelize
- Tìm hiểu về ActiveRecord Pattern
- Ứng dụng để tạo database schema với Sequelize trong NestJS
- Ứng dụng thêm,xóa,sửa,tìm kiếm với Model cho PetCategory
- Ứng dụng để tạo data seed trong NestJS
Cấu hình cho NestJS làm với với MySQL thông qua Sequelize
Đầu tiên bạn cần tạo database trước Database name : nestjs_tutorial_2023
CREATE DATABASE `nestjs_tutorial_2023` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_bin';
- Kết nối với MYSQL bằng Sequelize
- Kết nối với PostgreSQL bằng TypeORM
- Kết nối với MongoDB bằng Mongoose
1.1. Kết nối với MYSQL bằng Sequelize
Cài đặt các packages
npm install --save @nestjs/sequelize sequelize sequelize-typescript mysql2
npm install --save-dev @types/sequelize
npm install --save-dev sequelize-cli
npx sequelize-cli init
Trong phần này tôi xin giới thiệu với các anh chị 3 bước chính làm việc với database:
- Thiết lập kết nối
- Khai báo model
- Cấu trúc thư mục database migration - khởi tạo, cập nhật database schema
Thiết lập kết nối
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ServeStaticModule } from "@nestjs/serve-static";
import { join } from "path";
import { PetModule } from "./pet/pet.module";
import { SequelizeModule } from "@nestjs/sequelize";
@Module({
imports: [
// public folder
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), "public"),
serveRoot: "/public",
}),
PetModule,
SequelizeModule.forRoot({
dialect: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "123456",
database: "nestjs_tutorial_2023",
models: [],
}),
],
providers: [],
})
export class AppModule {}
Khai báo model
Model trong ORM:
- Chính là ánh xạ của 1 thực thể trong cơ sở dữ liệu, thông qua ORM chúng ta chỉ cần làm việc với các phương thức (method) của Model, phần còn lại là các câu lệnh SQL/NoSQL sẽ được ORM xử lý. Ưu điểm là việc lập trình sẽ trở nên dễ dàng và có tính nhất quán hơn. Nhược điểm là đôi khi 1 số câu query sẽ chậm và khó để triển khai các query có tính chất phức tạp. Tuy nhiên với trường hợp đó ORM vẫn hỗ trợ chúng thực hiện SQL/NoSQL query truyền thống.
"use strict";
import { PetCategory } from "src/pet/models/pet-category.model";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
/**
* Add altering commands here.
*
* Example:
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });
*/
await queryInterface.createTable("pet_categories", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.STRING(60),
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.fn("NOW"),
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.fn("NOW"),
},
});
},
async down(queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
await queryInterface.dropTable("pet_categories");
},
};
Điều chỉnh lại config để sử dụng model và sử dụng database uri
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ServeStaticModule } from "@nestjs/serve-static";
import { join } from "path";
import { PetModule } from "./pet/pet.module";
import { SequelizeModule } from "@nestjs/sequelize";
import models from "./pet/models";
@Module({
imports: [
// public folder
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), "public"),
serveRoot: "/public",
}),
PetModule,
SequelizeModule.forRoot({
uri: "mysql://root:123456@localhost/nestjs_tutorial_2023",
dialect: "mysql",
models: models,
}),
],
providers: [],
})
export class AppModule {}
Cấu trúc thư mục database migration - khởi tạo, cập nhật database schema
Đôi lời giải thích tại sao chúng ta cần sử dụng sequelize-cli để cập nhật database. Trong quá trình phát triển dự án, khi có 1 bảng dữ liệu cần thêm mới/xóa hoặc cập nhật các cột trong bảng. Cách trực tiếp nhất là các câu lệnh SQL, mặc dù chúng ta vẫn có thể quản lí được câu lệnh nào đã chạy hoặc chưa và việc chia sẻ với các thành viên trong team hoặc người phụ trách triển khai sản phẩm trên môi trường production.
Trong các ORM hiện đại ngày nay, để thống nhất cách làm và quy chuẩn hóa, thông thường chúng ta sẽ sử dụng cli của các ORM này liên quan đến với tạo/chỉnh sửa database, table. Và thực hiện việc migrate qua các dòng lệnh. Còn cấu trúc các bảng dữ liệu sẽ được triển khai thông qua code - nhằm mang tính thống nhất với model đã được quản lí bởi ORM.
Sau khi chạy câu lệnh mặc định sau của sequelize-cli chúng ta sẽ có cấu trúc thư mục mặc định như sau.
npx sequelize-cli init
config
database.json
db
models
seeders
migrations
.sequelizerc
// .sequelizerc
const path = require("path");
module.exports = {
config: path.resolve("config", "database.json"),
"models-path": path.resolve("db", "models"),
"seeders-path": path.resolve("db", "seeders"),
"migrations-path": path.resolve("db", "migrations"),
};
Tuy nhiên để phù hợp với cấu trúc hiện tại của dự án, ta cần điều chỉnh lại 1 chút như sau
src
database
config
config.ts
migrations
*.ts
seeds
*.ts
.sequelizerc
const path = require("path");
module.exports = {
config: path.resolve("./dist/database/config/config.js"),
"seeders-path": path.resolve("./dist/database/seeders"),
"migrations-path": path.resolve("./dist/database/migrations"),
};
Ở đây, thay vì chạy trực tiếp bản source, chúng ta sẽ chạy bản build của chúng
// config.ts
module.exports = {
production: {
url: "mysql://root:123456@localhost/nestjs_tutorial_2023",
dialect: "mysql",
},
};
// src/pet/models/pet-category.model
import { Column, DataType, Model, Table } from "sequelize-typescript";
@Table({
tableName: "pet_categories",
})
export class PetCategory extends Model {
@Column({
primaryKey: true,
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
id?: string;
@Column({
type: DataType.STRING(60),
allowNull: false,
})
name: string;
}
// src\database\migrations\20230704043449-create-pet-category-table.ts
"use strict";
import { PetCategory } from "src/pet/models/pet-category.model";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
/**
* Add altering commands here.
*
* Example:
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });
*/
await queryInterface.createTable("pet_categories", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.STRING(60),
allowNull: false,
},
});
},
async down(queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
await queryInterface.dropTable("pet_categories");
},
};
Lưu ý đặc biệt cho model ở đây
Do PetCategory Model trong ví dụ có kế thừa từ Model từ sequelize nên sẽ thừa kế 1 số field định sẵn, dù trong code của Model PetCategory ta không thấy chúng xuất hiện. Do vậy khi tạo script migrate cho PetCategory cần lưu ý thêm 2 cột mặc định này.
export declare abstract class Model<TModelAttributes extends {} = any, TCreationAttributes extends {} = TModelAttributes> extends OriginModel<TModelAttributes, TCreationAttributes> {
id?: number | any;
createdAt?: Date | any;
updatedAt?: Date | any;
deletedAt?: Date | any;
version?: number | any;
static isInitialized: boolean;
Một số command phổ biến
$ npx sequelize-cli --help
Sequelize CLI [Node: 16.19.1, CLI: 6.6.1, ORM: 6.32.1]
sequelize <command>
Commands:
sequelize db:migrate Run pending migrations
sequelize db:migrate:schema:timestamps:add Update migration table to have timestamps
sequelize db:migrate:status List the status of all migrations
sequelize db:migrate:undo Reverts a migration
sequelize db:migrate:undo:all Revert all migrations ran
sequelize db:seed Run specified seeder
sequelize db:seed:undo Deletes data from the database
sequelize db:seed:all Run every seeder
sequelize db:seed:undo:all Deletes data from the database
sequelize db:create Create database specified by configuration
sequelize db:drop Drop database specified by configuration
sequelize init Initializes project
sequelize init:config Initializes configuration
sequelize init:migrations Initializes migrations
sequelize init:models Initializes models
sequelize init:seeders Initializes seeders
sequelize migration:generate Generates a new migration file
sequelize migration:create Generates a new migration file
sequelize model:generate Generates a model and its migration
sequelize model:create Generates a model and its migration
sequelize seed:generate Generates a new seed file
sequelize seed:create Generates a new seed file
Tôi xin ví dụ command để thực hiện tạo file migration, sau đó build, và chạy
# create migration
npx sequelize-cli migration:create --name create-pet-category-table --migrations-path ./src/database/migrations
# build
npm run build
# run migration
npx sequelize-cli db:migrate --env production
Lưu ý nếu không chỉ định env thì mặc định là development. Như trong file config ở trên chúng ta chỉ set duy nhất một môi trường là production. Và các config sẽ được thay thế bằng biến môi trường.
Sau khi chạy migrate xong lúc này kiểm tra database ta sẽ thấy
Bước tiếp theo, chúng ta bắt đầu test thử một số method cơ bản của Model: thêm, cập nhật, xóa, tìm kiếm
Lưu ý nhỏ khi tiếp tục bài học với ví dụ của Pet Category hiện tại, chúng ta cần cập nhật PetCategory Model lại 1 chút, thay vì column title -> sẽ chuyển sang column name, cho tương thích với database lúc này.
Thêm PetCategory
import { PetCategory } from "src/pet/models/pet-category.model";
await PetCategory.create({ ...object });
@Controller("admin/pet-categories")
export class ManagePetCategoryController {
@Post("create")
@Render("pet/admin/manage-pet-category/create")
@FormDataRequest()
async create(@Body() createPetCategoryDto: CreatePetCategoryDto) {
const data = {
mode: "create",
};
// validation
const object = plainToInstance(CreatePetCategoryDto, createPetCategoryDto);
// ...
// set value and show success message
Reflect.set(data, "values", object);
// create PetCategory
const newPetCategory = await PetCategory.create({ ...object });
Reflect.set(
data,
"success",
`Pet Category : ${newPetCategory.id} - ${newPetCategory.name} has been created!`
);
// success
return { data };
}
}
Sau khi chạy thử http://localhost:3000/admin/pet-categories/create chúng ta có được kết quả như bên dưới
Ngoài ra Sequelize còn hỗ trợ bạn cấu hình để có thể xem chi tiết được câu sql được ORM tạo ra. Thay đổi một chút ở phần config cho kết nối database tại app module.
SequelizeModule.forRoot({
uri: 'mysql://root:123456@localhost/nestjs_tutorial_2023',
dialect: 'mysql',
models: models,
logging: console.log,
}),
Tìm kiếm PetCategory - danh sách
@Controller('admin/pet-categories')
export class ManagePetCategoryController {
@Get('')
@Render('pet/admin/manage-pet-category/list')
async getList() {
const petCategories = await PetCategory.findAll();
return {
petCategories,
};
}
<%- include('layouts/admin/header'); %>
<section class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">List Pet Categories</h5>
<div class="table-responsive">
<table class="table table-light table-striped">
<thead>
<tr>
<th scope="col" style="width: 360px">ID</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<% petCategories.forEach(petCategory => { %>
<tr class="">
<td><%= petCategory.id %></td>
<td><%= petCategory.name %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</section>
<%- include('layouts/admin/footer'); %>
// find all
const petCategories = await PetCategory.findAll();
// delete
await PetCategory.destroy({ where: { id } });
// create
const newPetCategory = await PetCategory.create({ ...object });
// find by primary key
const petCategory = await PetCategory.findByPk(id);
// update
await petCategory.update(object);
Update source code of ManagePetCategory controllers and views
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Redirect,
Render,
} from "@nestjs/common";
import { CreatePetCategoryDto } from "src/pet/dtos/pet-dto";
import { plainToInstance } from "class-transformer";
import { validate, ValidationError } from "class-validator";
import { FormDataRequest } from "nestjs-form-data";
import { PetCategory } from "src/pet/models/pet-category.model";
import { Response } from "express";
const transformError = (error: ValidationError) => {
const { property, constraints } = error;
return {
property,
constraints,
};
};
@Controller("admin/pet-categories")
export class ManagePetCategoryController {
@Get("")
@Render("pet/admin/manage-pet-category/list")
async getList() {
const petCategories = await PetCategory.findAll();
return {
petCategories,
};
}
@Post("delete/:id")
@Redirect("/admin/pet-categories/")
async deleteOne(@Param() { id }: { id: string }) {
await PetCategory.destroy({ where: { id } });
}
@Get("create")
@Render("pet/admin/manage-pet-category/create")
view_create() {
// a form
return {
data: {
mode: "create",
},
};
}
@Post("create")
@Render("pet/admin/manage-pet-category/create")
@FormDataRequest()
async create(@Body() createPetCategoryDto: CreatePetCategoryDto) {
const data = {
mode: "create",
};
// validation
const object = plainToInstance(CreatePetCategoryDto, createPetCategoryDto);
const errors = await validate(object, {
stopAtFirstError: true,
});
if (errors.length > 0) {
Reflect.set(data, "error", "Please correct all fields!");
const responseError = {};
errors.map((error) => {
const rawError = transformError(error);
Reflect.set(
responseError,
rawError.property,
Object.values(rawError.constraints)[0]
);
});
Reflect.set(data, "errors", responseError);
return { data };
}
// set value and show success message
Reflect.set(data, "values", object);
// create PetCategory
const newPetCategory = await PetCategory.create({ ...object });
Reflect.set(
data,
"success",
`Pet Category : ${newPetCategory.id} - ${newPetCategory.name} has been created!`
);
// success
return { data };
}
@Get(":id")
@Render("pet/admin/manage-pet-category/create")
async getDetail(@Param() { id }: { id: string }) {
const data = {
mode: "edit",
};
const petCategory = await PetCategory.findByPk(id);
Reflect.set(data, "values", petCategory);
return { data };
}
@Post(":id")
@Render("pet/admin/manage-pet-category/create")
@FormDataRequest()
async updateOne(
@Param() { id }: { id: string },
@Body() createPetCategoryDto: CreatePetCategoryDto
) {
const data = {
mode: "edit",
};
const petCategory = await PetCategory.findByPk(id);
// validation
const object = plainToInstance(CreatePetCategoryDto, createPetCategoryDto);
const errors = await validate(object, {
stopAtFirstError: true,
});
if (errors.length > 0) {
Reflect.set(data, "error", "Please correct all fields!");
const responseError = {};
errors.map((error) => {
const rawError = transformError(error);
Reflect.set(
responseError,
rawError.property,
Object.values(rawError.constraints)[0]
);
});
Reflect.set(data, "errors", responseError);
return { data };
}
// set value and show success message
await petCategory.update(object);
Reflect.set(data, "values", petCategory);
return { data };
}
}
Views - list.html
<%- include('layouts/admin/header'); %>
<section class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">List Pet Categories</h5>
<div class="pb-4">
<a
class="btn btn-primary"
href="/admin/pet-categories/create"
role="button"
>New Pet Category</a
>
</div>
<div class="table-responsive">
<table class="table table-light table-striped">
<thead>
<tr>
<th scope="col" style="width: 360px">ID</th>
<th scope="col">Name</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<% petCategories.forEach(petCategory => { %>
<tr class="">
<td><%= petCategory.id %></td>
<td><%= petCategory.name %></td>
<td>
<a
href="/admin/pet-categories/<%= petCategory.id %>"
title="Edit"
>Edit</a
>
<form
action="/admin/pet-categories/delete/<%= petCategory.id %>"
method="post"
>
<button type="submit">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</section>
<%- include('layouts/admin/footer'); %>
Views - Create/Edit
<%- include('layouts/admin/header'); %>
<section class="col-6">
<form method="post" enctype="multipart/form-data">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<% if (data.mode === 'create') { %> New Pet Category <% } %> <% if
(data.mode === 'edit') { %> Edit Pet Category <% } %>
</h5>
<!-- error -->
<% if (data.error){ %>
<div class="alert alert-danger" role="alert"><%= data.error %></div>
<% } %>
<!-- success -->
<% if (data.success){ %>
<div class="alert alert-success" role="alert"><%= data.success %></div>
<script type="text/javascript">
setTimeout(() => {
window.location.href = "/admin/pet-categories/";
}, 2000);
</script>
<% } %>
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<div class="input-group has-validation">
<input
type="text"
class="form-control <%= data.errors && data.errors['name'] ? 'is-invalid': '' %>"
id="name"
name="name"
value="<%= data.values && data.values['name'] %>"
placeholder="Pet Category name"
/>
<% if (data.errors && data.errors['name']) { %>
<div id="validationServerUsernameFeedback" class="invalid-feedback">
<%= data.errors['name'] %>
</div>
<% } %>
</div>
</div>
</div>
<% if(!data.success) { %>
<div class="mb-3 col-12 text-center">
<button type="submit" class="btn btn-primary">Save</button>
</div>
<% } %>
</div>
</form>
</section>
<%- include('layouts/admin/footer'); %>
Tạo data seed với sequelize-cli
Lưu ý tại bước này, nếu sử dụng generate của cli, thư mục target trong config của sequelize lúc này là thư mục dist.
Do đó, ta cần điều chỉnh một chút lại config cho .sequelizerc như sau. Một lưu ý tiếp theo, là hãy nhớ chỉnh lại file extension cho file seeding hay migrations sang đuôi ts nhé.
const path = require("path");
const database_dist = process.env.NODE_ENV === "production" ? "dist" : "src";
module.exports = {
config: path.resolve(`./${database_dist}/database/config/config.js`),
"seeders-path": path.resolve(`./${database_dist}/database/seeders`),
"migrations-path": path.resolve(`./${database_dist}/database/migrations`),
};
# create migration
npx sequelize-cli migration:create --name create-pet-category-table --migrations-path ./src/database/migrations
# create seed
npx sequelize-cli seed:generate --name pet-category
# build
npm run build
# run migration/seeds
NODE_ENV=production npx sequelize-cli db:migrate --env production
import { Column, DataType, Model, Table } from "sequelize-typescript";
export const PetCategoryTableName = "pet_categories";
@Table({
tableName: PetCategoryTableName,
})
export class PetCategory extends Model {
@Column({
primaryKey: true,
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
id?: string;
@Column({
type: DataType.STRING(60),
allowNull: false,
})
name: string;
}
src\database\seeders\20230706141027-pet-category.ts
"use strict";
import {
PetCategory,
PetCategoryTableName,
} from "src/pet/models/pet-category.model";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
await queryInterface.bulkInsert(
PetCategoryTableName,
[
{ name: "Dogs" },
{ name: "Cats" },
{ name: "Pigs" },
{ name: "Birds" },
{ name: "Others" },
],
{}
);
},
async down(queryInterface, Sequelize) {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
await queryInterface.bulkDelete(PetCategoryTableName, null, {});
},
};
Mọi thứ trông có vẻ ổn, tuy nhiên, khi chạy câu lệnh migrate seed, bạn có thể gặp phải lỗi được mô tả tại đây
Loaded configuration file "dist\database\config\config.js".
Using environment "production".
== 20230706141027-pet-category: migrating =======
ERROR: Field 'id' doesn't have a default value
Để bypass issue này trong lúc sequelize chưa có bản vá cho lỗi này, ngay lúc migrate, chúng ta sẽ phải generate uuid trực tiếp, bằng package này
Cập nhật lại 1 chút file seed src\database\seeders\20230706141027-pet-category.ts
"use strict";
import { PetCategoryTableName } from "src/pet/models/pet-category.model";
import { v4 as uuidv4 } from "uuid";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
await queryInterface.bulkInsert(
PetCategoryTableName,
[
{ id: uuidv4(), name: "Dogs" },
{ id: uuidv4(), name: "Cats" },
{ id: uuidv4(), name: "Pigs" },
{ id: uuidv4(), name: "Birds" },
{ id: uuidv4(), name: "Others" },
],
{}
);
},
async down(queryInterface, Sequelize) {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
await queryInterface.bulkDelete(PetCategoryTableName, null, {});
},
};
$ NODE_ENV=production npx sequelize-cli db:seed:all
Sequelize CLI [Node: 16.19.1, CLI: 6.6.1, ORM: 6.32.1]
Loaded configuration file "dist\database\config\config.js".
Using environment "production".
== 20230706141027-pet-category: migrating =======
== 20230706141027-pet-category: migrated (0.155s)
Và kết quả