2. Introduction to NestJS
2.1. Installing and Using Nest CLI
npm i -g @nestjs/cli
创建新项目
# nest new
nest new nest-events-backend
npm run start:dev
2.2. NestJS Project Structure
3. Rest API
3.1. Controllers
@Get("/bye")
getBye() {
return "Bye!"
}
3.2. Resource Controller
nest generate controller
nest g co
import { Controller, Get, Post, Delete, Patch } from "@nestjs/common";
@Controller("events")
export class EventsController {
@Get()
findAll() {}
@Get()
findOne() {}
@Post()
create() {}
@Patch()
update() {}
@Delete()
remove() {}
}
3.3. Route Parameters
获取路由中特定参数
@Get(':id')
findOne(@Param('id') id) {
return id;
}
@Param()没有指定路由参数名称,则获取所有
@Get(':id/:event')
findOne(@Param() params) {
return params;
}
3.4. Request Body
@Post()
create(@Body() input) {
return input;
}
3.5. Responses and Status Codes
@Get()
findAll() {
return [
{ id: 1, name: 'First event' },
{ id: 2, name: 'Second event' },
]
}
// localhost:3000/events/id
@Get(':id')
findOne(@Param('id') id) {
return { id: 1, name: 'First event' };
}
@Patch(':id')
update(@Param('id') id, @Body() input) {
return input;
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id') id) { }
3.6. Request Payload - Data Transfer Objects
export class CreateEventDto {
name: string;
description: string;
when: string;
address: string;
}
@Post()
create(@Body() input: CreateEventDto) {
// DTO: data transfer object 定义了一个数据结构
return input;
}
3.7. The Update Payload
@Patch(':id')
update(@Param('id') id, @Body() input: UpdateEventDto) {
return input;
}
可选参数
export class UpdateEventDto {
name?: string; // ?代表可选
description?: string;
when?: string;
address?: string;
}
另一种方式
npm i --save @nestjs/mapped-types
import { PartialType } from "@nestjs/mapped-types";
import { CreateEventDto } from "./create-event.dto";
// 继承CreateEventDto的所有字段,并且是可选的
export class UpdateEventDto extends PartialType(CreateEventDto) {}
3.8. A Working API Example
export class Event {
id: number;
name: string;
description: string;
when: Date;
address: string;
}
import {
Controller,
Get,
Post,
Delete,
Patch,
Param,
Body,
HttpCode,
} from "@nestjs/common";
import { CreateEventDto } from "./create-event.dto";
import { UpdateEventDto } from "./update-event.dto";
import { Event } from "./event.entity";
@Controller("events")
export class EventsController {
private events: Event[] = [];
@Get()
findAll() {
return this.events;
}
// localhost:3000/events/id
@Get(":id")
findOne(@Param("id") id) {
return this.events.find((event) => event.id === parseInt(id));
}
@Post()
create(@Body() input: CreateEventDto) {
const event = {
...input,
when: new Date(input.when),
id: this.events.length + 1,
};
this.events.push(event);
return event;
}
@Patch(":id")
update(@Param("id") id, @Body() input: UpdateEventDto) {
// 根据id找到要修改的
const index = this.events.findIndex((event) => event.id === parseInt(id));
this.events[index] = {
...this.events[index],
...input, // 后面的会覆盖前面的
when: input.when ? new Date(input.when) : this.events[index].when,
};
return this.events[index];
}
@Delete(":id")
@HttpCode(204)
remove(@Param("id") id) {
this.events = this.events.filter((event) => event.id !== parseInt(id));
}
}
4. Database Basics
4.1. Adding Docker to the Stack
4.2. Running the Database with Docker Compose
version: "3.8"
services:
mysql:
image: mysql:8.0.23
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: example
ports:
- 3306:3306
postgres:
image: postgres:13.1
restart: always
environment:
POSTGRES_PASSWORD: example
ports:
- 5432:5432
adminer:
image: adminer
restart: always
ports:
- 8080:8080
# 在有docker-compose.yml文件的目录运行
docker-compose up
4.3. Introduction to ORMs
4.5. Connecting to the Database
npm i --save @nestjs/typeorm typeorm mysql
// app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EventsController } from "./events/events.controller";
import { TypeOrmModule } from "@nestjs/typeorm";
@Module({
// 导入orm框架,配置数据库连接
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "127.0.0.1",
port: 3307,
username: "root",
password: "123456",
database: "nest-events",
}),
],
// 引入controller
controllers: [AppController, EventsController],
providers: [AppService],
})
export class AppModule {}
4.7. The Entity (Primary Key & Columns)
// event.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
// name: 表名
// @Entity('event', { name: 'event' })
@Entity() // 改变name不会删旧表,而是会创建新表
export class Event {
// @PrimaryGeneratedColumn('uuid')
// @PrimaryGeneratedColumn('rowid')
// @PrimaryColumn() // 如果是复合主键,就在每个主键上加
@PrimaryGeneratedColumn() // 自动生成
id: number;
@Column({ length: 100 })
name: string;
@Column()
description: string;
// name指定列名
@Column({ name: "when_date" })
when: Date;
@Column()
address: string;
}
// app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { EventsController } from "./events/events.controller";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Event } from "./events/event.entity";
@Module({
// 导入orm框架,配置数据库连接
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "127.0.0.1",
port: 3307,
username: "root",
password: "123456",
database: "nest-events",
// 使用实体需要在这里引入
entities: [Event],
// 自动建表,你修改 Entity 里面字段,
// 或者 *.entity{.ts,.js } 的名字,都会自动帮你修改。
synchronize: true,
}),
],
// 引入controller
controllers: [AppController, EventsController],
providers: [AppService],
})
export class AppModule {}
4.8. Repository Pattern
4.9. Repository in Practice
import {
Controller,
Get,
Post,
Delete,
Patch,
Param,
Body,
HttpCode,
} from "@nestjs/common";
import { CreateEventDto } from "./create-event.dto";
import { UpdateEventDto } from "./update-event.dto";
import { Event } from "./event.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm/repository/Repository";
@Controller("events")
export class EventsController {
// 依赖注入
constructor(
@InjectRepository(Event)
private readonly respository: Repository<Event>
) {}
@Get()
async findAll() {
return await this.respository.find();
}
// localhost:3000/events/id
@Get(":id")
async findOne(@Param("id") id) {
return await this.respository.findOne(id);
}
@Post()
async create(@Body() input: CreateEventDto) {
return await this.respository.save({
...input,
when: new Date(input.when),
});
}
@Patch(":id")
async update(@Param("id") id, @Body() input: UpdateEventDto) {
const event = await this.respository.findOne(id);
return await this.respository.save({
...event,
...input,
when: input.when ? new Date(input.when) : event.when,
});
}
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id) {
const event = await this.respository.findOne(id);
await this.respository.remove(event);
}
}
4.10. Repository Querying Criteria and Options
SET NAMES utf8mb4;
INSERT INTO `event` (`id`, `description`, `when`, `address`, `name`) VALUES
(1, 'Let\'s meet together.', '2021-02-15 21:00:00', 'Office St 120', 'Team Meetup'),
(2, 'Let\'s learn something.', '2021-02-17 21:00:00', 'Workshop St 80', 'Workshop'),
(3, 'Let\'s meet with big bosses', '2021-02-17 21:00:00', 'Boss St 100', 'Strategy Meeting'),
(4, 'Let\'s try to sell stuff', '2021-02-11 21:00:00', 'Money St 34', 'Sales Pitch'),
(5, 'People meet to talk about business ideas', '2021-02-12 21:00:00', 'Invention St 123', 'Founders Meeting');
如果请求方法不对,也会报错 You must provide selection conditions in order to find a single row
@Get('/practice')
async practice() {
return await this.repository.find({
where: { id: 3 }
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
where: { id: MoreThan(3) }
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
where: {
id: MoreThan(3),
when: MoreThan(new Date('2021-02-12T13:00:00'))
}
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
// 多个where是or
where: [
{
id: MoreThan(3),
when: MoreThan(new Date('2021-02-12T13:00:00'))
},
{
description: Like('%meet%')
}
]
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
// 多个where是or
where: [
{
id: MoreThan(3),
when: MoreThan(new Date('2021-02-12T13:00:00'))
},
{
description: Like('%meet%')
}
],
// 限制取几个
take: 2
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
// 多个where是or
where: [
{
id: MoreThan(3),
when: MoreThan(new Date('2021-02-12T13:00:00'))
},
{
description: Like('%meet%')
}
],
// 限制取几个
take: 2,
order: {
// 倒序
id: 'desc'
}
})
}
@Get('/practice')
async practice() {
return await this.repository.find({
// 只取其中指定列
select: ['id', 'when'],
// 多个where是or
where: [
{
id: MoreThan(3),
when: MoreThan(new Date('2021-02-12T13:00:00'))
},
{
description: Like('%meet%')
}
],
// 限制取几个
take: 2,
order: {
// 倒序
id: 'desc'
}
})
}
5. Data Validation
5.1. Introduction to Pipes
// localhost:3000/events/id
@Get(':id') // ParseIntPipe转为number类型
async findOne(@Param('id', ParseIntPipe) id) {
console.log(typeof id); // number
return await this.repository.findOne(id)
}
pipe 最有用的是用于参数校验
5.2. Input Validation
npm i --save class-validator class-transformer
@Post() // ValidationPipe 自动校验
async create(@Body(ValidationPipe) input: CreateEventDto) {
return await this.repository.save({
...input,
when: new Date(input.when)
})
}
import { Length } from "class-validator";
export class CreateEventDto {
@Length(5, 255)
name: string;
description: string;
when: string;
address: string;
}
export class CreateEventDto {
@Length(5, 255, { message: "the name length is wrong" })
name: string;
description: string;
when: string;
address: string;
}
export class CreateEventDto {
@IsString()
@Length(5, 255, { message: "the name length is wrong" })
name: string;
@Length(5, 255)
description: string;
@IsDateString()
when: string;
@Length(5, 255)
address: string;
}
开启全局验证,如果有使用 Dto,并且有校验装饰器(注解),则会自动校验,无需自己加 ValidationPipe
UpdateEventDto 继承了 CreateEventDto,它的属性也会带上校验注解.
// main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
5.3. Validation Groups and Options
import { Length, IsString, IsDateString } from "class-validator";
export class CreateEventDto {
@IsString()
@Length(5, 255, { message: "the name length is wrong" })
name: string;
@Length(5, 255)
description: string;
@IsDateString()
when: string;
// 分组
@Length(5, 255, { groups: ["create"] })
@Length(10, 20, { groups: ["update"] })
address: string;
}
@Post() // ValidationPipe 自动校验
async create(@Body(new ValidationPipe({ groups: ['create'] })) input: CreateEventDto) {
return await this.repository.save({
...input,
when: new Date(input.when)
})
}
@Patch(':id')
async update(
@Param('id') id,
@Body(new ValidationPipe({ groups: ['update'] })) input: UpdateEventDto
) {
const event = await this.repository.findOne(id)
return await this.repository.save({
...event, ...input,
when: input.when ? new Date(input.when) : event.when
})
}
要使用分组校验,需要禁用全局校验
6. Modules, Providers, Dependency Injection
6.1. Introduction to Modules, Providers and Dependency Injection
nest generate module events
6.2. Creating a Custom Module
使用@Module 定义的模块是静态的,提供固定功能,而动态模块提供多个可选的功能,动态模块可以按需导入
// app.japan.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppJapanService {
getHello(): string {
return "ハローワールド";
}
}
import { Injectable, Inject } from "@nestjs/common";
@Injectable()
export class AppJapanService {
constructor(
@Inject("APP_NAME")
private readonly name: string
) {}
getHello(): string {
return `ハローワールド from ${this.name}`;
}
}
// app.dummy.ts
export class AppDummy {
public dummy(): string {
return "dummy";
}
}
7. Configuration, Logging, and Errors
7.1. Application Config and Environments
npm i --save @nestjs/config
7.2. Custom Configuration Files and Options
把 typeOrm 的配置放在单独的配置文件
// orm.config.ts
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { Event } from "src/events/event.entity";
import { registerAs } from "@nestjs/config";
export default registerAs(
"org.config",
(): TypeOrmModuleOptions => ({
type: "mysql",
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
// 使用实体需要在这里引入
entities: [Event],
// 自动建表,你修改 Entity 里面字段,
// 或者 *.entity{.ts,.js } 的名字,都会自动帮你修改。
synchronize: true,
})
);
// orm.config.prod.ts
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { Event } from "src/events/event.entity";
import { registerAs } from "@nestjs/config";
export default registerAs(
"org.config",
(): TypeOrmModuleOptions => ({
type: "mysql",
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
// 使用实体需要在这里引入
entities: [Event],
// 自动建表,你修改 Entity 里面字段,
// 或者 *.entity{.ts,.js } 的名字,都会自动帮你修改。
synchronize: false,
})
);
// app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { TypeOrmModule } from "@nestjs/typeorm";
import { EventsModule } from "./events/events.module";
import { AppJapanService } from "./app.japan.service";
import { AppDummy } from "./app.dummy";
import { ConfigModule } from "@nestjs/config";
import ormConfig from "./config/orm.config";
import ormConfigProd from "./config/orm.config.prod";
@Module({
// 导入orm框架,配置数据库连接
imports: [
// 配置这个,可以读取根目录的.env文件
ConfigModule.forRoot({
envFilePath: ".env",
isGlobal: true,
expandVariables: true,
load: [ormConfig],
}),
TypeOrmModule.forRootAsync({
useFactory:
process.env.NODE_ENV !== "production" ? ormConfig : ormConfigProd,
}),
// 导入子模块
EventsModule,
],
// 引入controller
controllers: [AppController],
providers: [
// 提供类
{
provide: AppService,
useClass: AppJapanService,
},
// 提供变量
{
provide: "APP_NAME",
useValue: "Nest Events Backend!",
},
// 提供函数调用结果
{
provide: "MESSAGE",
inject: [AppDummy],
useFactory: (app) => `${app.dummy()}`,
},
AppDummy,
],
})
export class AppModule {}
expandVariables
APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL}
7.3. Logging
@Get()
async findAll() {
this.logger.log(`Hit the findAll route`)
const events = await this.repository.find()
this.logger.debug(`Found ${events.length} events`)
return events
}
7.4. Exception Filters
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const event = await this.repository.findOne({
where: { id }
})
if (!event) {
throw new NotFoundException()
}
return event
}
8. Intermediate Database Concepts
8.1. Understanding Relations
8.2. One To Many Relation
// attendee.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Event } from "./event.entity";
@Entity()
export class Attendee {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToOne(() => Event, (event) => event.attendees)
event: Event;
}
// event.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Attendee } from "./attendee.entity";
@Entity() // 改变name不会删旧表,而是会创建新表
export class Event {
// ...
@OneToMany(() => Attendee, (attendee) => attendee.event)
attendees: Attendee[];
}
8.3. Loading Related Entities
INSERT INTO
`event` (`id`, `description`, `when`, `address`, `name`)
VALUES
(
1,
'Let\'s meet together.',
'2021-02-15 21:00:00',
'Office St 120',
'Team Meetup'
),
(
2,
'Let\'s learn something.',
'2021-02-17 21:00:00',
'Workshop St 80',
'Workshop'
),
(
3,
'Let\'s meet with big bosses',
'2021-02-17 21:00:00',
'Boss St 100',
'Strategy Meeting'
),
(
4,
'Let\'s try to sell stuff',
'2021-02-11 21:00:00',
'Money St 34',
'Sales Pitch'
),
(
5,
'People meet to talk about business ideas',
'2021-02-12 21:00:00',
'Invention St 123',
'Founders Meeting'
);
INSERT INTO
`attendee` (`id`, `name`, `eventId`)
VALUES
(1, 'Piotr', 1),
(2, 'John', 1),
(3, 'Terry', 1),
(4, 'Bob', 2),
(5, 'Joe', 2),
(6, 'Donald', 2),
(7, 'Harry', 4);
@Get('/practice2')
async practice2() {
return await this.repository.findOne({
where: { id: 1 }
})
}
这样也可以查出 attendees
8.4. Associating Related Entities
@Get('/practice2')
async practice2() {
const event = await this.repository.findOne({
where: { id: 1 }
})
const attendee = new Attendee()
attendee.name = 'Jetty'
attendee.event = event
await this.attendeeRepository.save(attendee)
return event
}
@Get('/practice2')
async practice2() {
const event = new Event()
event.id = 1
const attendee = new Attendee()
attendee.name = 'Jetty The Second'
attendee.event = event
await this.attendeeRepository.save(attendee)
return event
}
级联操作(不是数据库的级联)
@Get('/practice2')
async practice2() {
const event = await this.repository.findOne({
where: { id: 1 }, relations: ['attendees']
})
const attendee = new Attendee()
attendee.name = 'Using cascade'
event.attendees.push(attendee)
await this.repository.save(event)
return event
}
如果 nullable 设置为 true
将 event 的 attendees 设为空
此时与之相关联的 attendee 的外键的值都置为 null
8.5. Many To Many Relation
import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { Teacher } from "./teacher.entity";
@Entity()
export class Subject {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Teacher, (teacher) => teacher.subjects, { cascade: true })
@JoinTable() // 会创建一个关系表(subject_teachers_teacher)
teachers: Teacher[];
}
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm";
import { Subject } from "./subject.entity";
@Entity()
export class Teacher {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Subject, (subject) => subject.teachers)
subjects: Subject[];
}
import { Controller, Post } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Subject } from "./subject.entity";
import { Teacher } from "./teacher.entity";
@Controller("school")
export class TrainingController {
constructor(
@InjectRepository(Subject)
private readonly subjectRepository: Repository<Subject>
) {}
@Post("/create")
public async savingRelation() {
const subject = new Subject();
subject.name = "Math";
const teacher1 = new Teacher();
teacher1.name = "John Doe";
const teacher2 = new Teacher();
teacher2.name = "Harry Doe";
subject.teachers = [teacher1, teacher2];
await this.subjectRepository.save(subject);
}
@Post("/remove")
public async removingRelation() {
const subject = await this.subjectRepository.findOne({
where: { id: 1 },
relations: ["teachers"],
});
subject.teachers = subject.teachers.filter((teacher) => teacher.id !== 2);
await this.subjectRepository.save(subject);
}
}
// school.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Subject } from "./subject.entity";
import { Teacher } from "./teacher.entity";
import { TrainingController } from "./training.controller";
@Module({
imports: [TypeOrmModule.forFeature([Subject, Teacher])],
controllers: [TrainingController],
})
export class SchoolModule {}
8.6. Query Builder Introduction
@Get('/practice3')
async practice3() {
return await this.repository.createQueryBuilder('e')
.select(['e.id', 'e.name'])
.orderBy('e.id', 'DESC')
.getMany()
}
// event.service.ts
import { InjectRepository } from "@nestjs/typeorm";
import { Event } from "./event.entity";
import { Repository } from "typeorm";
import { Logger } from "@nestjs/common/services";
import { Injectable } from "@nestjs/common";
@Injectable()
export class EventsService {
private readonly logger = new Logger(EventsService.name);
constructor(
@InjectRepository(Event)
private readonly eventsRepository: Repository<Event>
) {}
private getEventsBaseQuery() {
return (
this.eventsRepository
.createQueryBuilder("e")
// .select(["e.id", "e.name"])
.orderBy("e.id", "ASC")
);
}
public async getEvent(id: number): Promise<Event | undefined> {
const query = this.getEventsBaseQuery().andWhere("e.id = :id", { id });
this.logger.debug(await query.getSql());
return await query.getOne();
}
}
// 依赖注入
constructor(
@InjectRepository(Event)
private readonly repository: Repository<Event>,
@InjectRepository(Attendee)
private readonly attendeeRepository: Repository<Attendee>,
private readonly eventsService: EventsService
) { }
// ...
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const event = await this.eventsService.getEvent(id)
if (!event) {
throw new NotFoundException()
}
return event
}
8.7. Joins And Aggregation with Query Builder
public getEventsWithAttendeeCountQuery() {
return this.getEventsBaseQuery() // 查询到所有events
.loadRelationCountAndMap( // 给每个events的attendees注入值,值为关联的attendee的数量
'e.attendeeCount', 'e.attendees'
)
}
public async getEvent(id: number): Promise<Event | undefined> {
const query = this.getEventsWithAttendeeCountQuery()
.andWhere('e.id = :id', { id }) // 根据id筛选结果
this.logger.debug(await query.getSql())
return await query.getOne()
}
// attendee.entity.ts
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Event } from "./event.entity";
// 参会意向
export enum AttendeeAnswerEnum {
Accepted = 1,
Maybe,
Rejected,
}
@Entity()
export class Attendee {
//...
@Column("enum", {
enum: AttendeeAnswerEnum,
default: AttendeeAnswerEnum.Accepted,
})
answer: AttendeeAnswerEnum;
}
// event.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Attendee } from "./attendee.entity";
@Entity()
export class Event {
// ...
attendeeRejected?: number;
attendeeMaybe?: number;
attendeeAccepted?: number;
}
// event.service.ts
import { InjectRepository } from "@nestjs/typeorm";
import { Event } from "./event.entity";
import { Repository } from "typeorm";
import { Logger } from "@nestjs/common/services";
import { Injectable } from "@nestjs/common";
import { Attendee, AttendeeAnswerEnum } from "./attendee.entity";
@Injectable()
export class EventsService {
// ...
public getEventsWithAttendeeCountQuery() {
return this.getEventsBaseQuery() // 查询到所有events
.loadRelationCountAndMap(
// 给每个events的attendees注入值,值为关联的attendee的数量
"e.attendeeCount",
"e.attendees"
)
.loadRelationCountAndMap(
"e.attendeeAccepted",
"e.attendees",
"attendee",
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Accepted,
})
)
.loadRelationCountAndMap(
"e.attendeeRejected", // mapToProperty映射到哪个值
"e.attendees", // relationName关系名称
"attendee", // aliasName数据库名
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Rejected,
})
)
.loadRelationCountAndMap(
"e.attendeeMaybe",
"e.attendees",
"attendee",
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Maybe,
})
);
}
// ...
}
8.8. Filtering Data Using Query Builder
// list.event.ts
export class ListEvents {
when?: WhenEventFilter = WhenEventFilter.All;
}
export enum WhenEventFilter {
All = 1,
Today,
Tommorow,
ThisWeek,
NextWeek,
}
@Get() // Query: ?xxx=xxx
async findAll(@Query() filter: ListEvents) {
this.logger.log(`Hit the findAll route`)
const events = await this.eventsService
.getEventsWithAttendeeCountFiltered(filter)
this.logger.debug(`Found ${events.length} events`)
return events
}
public async getEventsWithAttendeeCountFiltered(filter?: ListEvents) {
let query = this.getEventsWithAttendeeCountQuery()
if (!filter) {
return await query.getMany()
}
if (filter.when) {
if (filter.when == WhenEventFilter.Today) {
query = query.andWhere(
`e.when >= CURDATE() AND e.when <= CURDATE() + INTERVAL 1 DAY`
)
}
if (filter.when == WhenEventFilter.Tommorow) {
query = query.andWhere(
`e.when >= CURDATE() + INTERVAL 1 DAY AND e.when <= CURDATE() + INTERVAL 2 DAY`
)
}
if (filter.when == WhenEventFilter.ThisWeek) {
query = query.andWhere('YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1)')
}
if (filter.when == WhenEventFilter.NextWeek) {
query = query.andWhere('YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1) + 1')
}
}
return await query.getMany()
}
8.9. Pagination Using Query Builder
// paginator.ts
import { SelectQueryBuilder } from "typeorm";
export interface PaginateOptions {
limit: number;
currentPage: number;
total?: boolean;
}
// 可以返回数组
export interface PaginationResult<T> {
first: number;
last: number;
limit: number;
total?: number;
data: T[];
}
export async function paginate<T>(
qb: SelectQueryBuilder<T>,
options: PaginateOptions = {
limit: 10,
currentPage: 1,
}
): Promise<PaginationResult<T>> {
const offset = (options.currentPage - 1) * options.limit;
const data = await qb.limit(options.limit).offset(offset).getMany();
return {
first: offset + 1,
last: offset + data.length,
limit: options.limit,
total: options.total ? await qb.getCount() : null,
data,
};
}
@Get() // Query: ?xxx=xxx
@UsePipes(new ValidationPipe({ transform: true })) // 没有提供值时,会填充默认值
async findAll(@Query() filter: ListEvents) {
const events = await this.eventsService
.getEventsWithAttendeeCountFilteredPaginated(
filter,
{
total: true,
currentPage: filter.page,
limit: 10
}
)
return events
}
private async getEventsWithAttendeeCountFiltered(filter?: ListEvents) {
let query = this.getEventsWithAttendeeCountQuery()
if (!filter) {
return query
}
if (filter.when) {
if (filter.when == WhenEventFilter.Today) {
query = query.andWhere(
`e.when >= CURDATE() AND e.when <= CURDATE() + INTERVAL 1 DAY`
)
}
if (filter.when == WhenEventFilter.Tommorow) {
query = query.andWhere(
`e.when >= CURDATE() + INTERVAL 1 DAY AND e.when <= CURDATE() + INTERVAL 2 DAY`
)
}
if (filter.when == WhenEventFilter.ThisWeek) {
query = query.andWhere('YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1)')
}
if (filter.when == WhenEventFilter.NextWeek) {
query = query.andWhere('YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1) + 1')
}
}
return await query
}
public async getEventsWithAttendeeCountFilteredPaginated(
filter: ListEvents,
paginateOptions: PaginateOptions
) {
return await paginate(
await this.getEventsWithAttendeeCountFiltered(filter),
paginateOptions
)
}
8.10. Updating, Deleting, Modifying Relations using QB
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id) {
const result = await this.eventsService.deleteEvent(id)
if (result?.affected !== 1) {
throw new NotFoundException()
}
}
// 把name属性改成Confidential
await this.subjectRepository
.createQueryBuilder("e")
.update()
.set({ name: "Confidential" })
.execute();
constructor(
@InjectRepository(Subject)
private readonly subjectRepository: Repository<Subject>,
@InjectRepository(Teacher)
private readonly teacherRepository: Repository<Teacher>
) { }
@Post('/create')
public async savingRelation() {
const subject = new Subject();
subject.name = 'Math';
await this.subjectRepository.save(subject)
const teacher1 = new Teacher();
teacher1.name = 'John Doe';
const teacher2 = new Teacher();
teacher2.name = 'Harry Doe';
await this.teacherRepository.save([teacher1, teacher2]);
}
@Post('/create')
public async savingRelation() {
const subject = await this.subjectRepository.findOne({ where: { id: 3 } })
const t1 = await this.teacherRepository.findOne({ where: { id: 3 } })
const t2 = await this.teacherRepository.findOne({ where: { id: 4 } })
return await this.subjectRepository
.createQueryBuilder()
.relation(Subject, 'teachers')
.of(subject)
.add([t1, t2])
}
8.11. One to One Relation
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Profile } from "./profile.entity";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@Column()
email: string;
@Column()
firstName: string;
@Column()
lastName: string;
@OneToOne(() => Profile)
@JoinColumn()
profile: Profile;
}
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column()
age: number;
}
9. Authentication, JWT, Authorization
9.1. Introduction to Authentication
9.2. Local Passport Strategy
npm i --save @nestjs/passport passport passport-local @types/passport-local
// local.strategy.ts
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { Repository } from "typeorm";
import { User } from "./user.entity";
import { InjectRepository } from "@nestjs/typeorm";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(LocalStrategy.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {
super();
}
public async validate(username: string, password: string): Promise<any> {
const user = await this.userRepository.findOne({
where: { username },
});
if (!user) {
this.logger.debug(`User ${username} not found!`);
throw new UnauthorizedException();
}
if (password !== user.password) {
this.logger.debug(`Invalid credentials for user ${username}`);
throw new UnauthorizedException();
}
return user;
}
}
// auth.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { LocalStrategy } from "./local.strategy";
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [LocalStrategy],
})
export class AuthModule {}
9.3. Logging In - Passport Strategy with a Nest Guard
import { Controller, Post, Request, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Controller("auth")
export class AuthController {
@Post("login")
// 路由守卫
@UseGuards(AuthGuard("local"))
async login(@Request() request) {
return {
userId: request.user.id,
token: "the token will go here",
};
}
}
9.4. JWT - JSON Web Tokens Introduction
9.5. JWT - Generating Token
npm i --save @nestjs/jwt passport-jwt
npm i --save-dev @types/passport-jwt
// auth.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { LocalStrategy } from "./local.strategy";
import { AuthController } from "./auth.controller";
import { JwtModule } from "@nestjs/jwt";
import { AuthService } from "./auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
// 异步创建,防止环境变量在module创建时还无法读取
useFactory: () => ({
secret: process.env.AUTH_SECRET, // JWT密码(放到.env)
signOptions: {
expiresIn: "60m", // 60min后过期
},
}),
}),
],
providers: [LocalStrategy, AuthService],
controllers: [AuthController],
})
export class AuthModule {}
import { Controller, Inject, Post, Request, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "./auth.service";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("login")
// 路由守卫
@UseGuards(AuthGuard("local"))
async login(@Request() request) {
return {
userId: request.user.id,
token: this.authService.getTokenForUser(request.user),
};
}
}
// auth.service.ts
import { Injectable } from "@nestjs/common";
import { User } from "./user.entity";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
public getTokenForUser(user: User): string {
return this.jwtService.sign({
username: user.username,
sub: user.id,
});
}
}
9.6. JWT - Strategy & Guard - Authenticating with JWT Token
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { InjectRepository } from "@nestjs/typeorm";
import { ExtractJwt, Strategy } from "passport-jwt";
import { User } from "./user.entity";
import { Repository } from "typeorm";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.AUTH_SECRET,
});
}
async validate(payload: any) {
// sub是user.id
return await this.userRepository.findOne({ where: { id: payload.sub } });
}
}
@Get('profile')
// 传递的'jwt'是策略名,
// 对应PassportStrategy(从passport-jwt导入就是jwt(默认值)),
// 可以通过 PassportStrategy(Strategy,'xxx')指定,
// 会自动找实现类
@UseGuards(AuthGuard('jwt'))
async getProfile(@Request() request) {
return request.user
}
9.7. Hashing Passwords with Bcrypt
npm i bcrypt
npm i --save-dev @types/bcrypt
import * as bcrypt from "bcrypt";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
// ...
public async hashPassword(password: string): Promise<string> {
// 参数2是hash的轮数
return await bcrypt.hash(password, 10);
}
}
// ...
import * as bcrypt from "bcrypt";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
// ...
public async validate(username: string, password: string): Promise<any> {
// ...
if (!(await bcrypt.compare(password, user.password))) {
this.logger.debug(`Invalid credentials for user ${username}`);
throw new UnauthorizedException();
}
return user;
}
}
9.8. Custom CurrentUser Decorator
// current-user.decorator.ts
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user ?? null;
}
);
@Post('login')
// 路由守卫
@UseGuards(AuthGuard('local'))
async login(@CurrentUser() user: User) {
return {
userId: user.id,
token: this.authService.getTokenForUser(user)
}
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
async getProfile(@CurrentUser() user: User) {
return user
}
9.9. User Registration
// create.user.dto.ts
import { IsEmail, Length } from "class-validator";
export class CreateUserDto {
@Length(5)
username: string;
@Length(8)
password: string;
@Length(8)
retypedPassword: string;
@Length(2)
firstName: string;
@Length(2)
lastName: string;
@IsEmail()
email: string;
}
import { Injectable } from "@nestjs/common";
import { User } from "./user.entity";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
// ...
public async hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 10);
}
}
import { Controller, Post, Body, BadRequestException } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { CreateUserDto } from "./input/create.user.dto";
import { User } from "./user.entity";
import { Repository } from "typeorm";
import { InjectRepository } from "@nestjs/typeorm";
@Controller("users")
export class UsersController {
constructor(
private readonly authService: AuthService,
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {}
@Post()
async create(@Body() createUserDto: CreateUserDto) {
const user = new User();
if (createUserDto.password !== createUserDto.retypedPassword) {
throw new BadRequestException(["Passwords are not identical"]);
}
// 当根据email或username查找到,说明已存在,不能添加
const existingUser = await this.userRepository.findOne({
where: [
{ username: createUserDto.username },
{ email: createUserDto.email },
],
});
if (existingUser) {
throw new BadRequestException(["username or email is already taken"]);
}
user.username = createUserDto.username;
user.password = await this.authService.hashPassword(createUserDto.password);
user.email = createUserDto.email;
user.firstName = createUserDto.firstName;
user.lastName = createUserDto.lastName;
return {
...(await this.userRepository.save(user)),
token: this.authService.getTokenForUser(user),
};
}
}
9.10. Only Authenticated Users Can Create Events
import {
Column,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { Attendee } from "./attendee.entity";
import { User } from "src/auth/user.entity";
@Entity()
export class Event {
//
@ManyToOne(() => User, (user) => user.organized)
@JoinColumn({ name: "organizerId" })
organizer: User;
@Column({ nullable: true }) // 如果不能null,创建会报错
organizerId: number;
//
}
import {
Column,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Profile } from "./profile.entity";
import { Event } from "src/events/event.entity";
@Entity()
export class User {
//
@OneToMany(() => Event, (event) => event.organizer)
organized: Event[];
}
// event.service.ts
public async createEvent(input: CreateEventDto, user: User): Promise<Event> {
return await this.eventsRepository.save({
...input,
organizer: user,
when: new Date(input.when)
})
}
// auth-guard.local.ts
import { AuthGuard } from "@nestjs/passport";
export class AuthGuardLocal extends AuthGuard("local") {}
// auth-guard.jwt.ts
import { AuthGuard } from "@nestjs/passport";
export class AuthGuardJwt extends AuthGuard("jwt") {}
@UseGuards(AuthGuardLocal)
// ...
@UseGuards(AuthGuardJwt)
@Post()
@UseGuards(AuthGuardJwt)
async create(
@Body() input: CreateEventDto,
@CurrentUser() user: User
) {
return await this.eventsService.createEvent(input, user)
}
9.11. Only The Owners Can Edit or Delete Events
import {
Controller,
Get,
Post,
ForbiddenException,
Delete,
Patch,
Param,
Body,
HttpCode,
ParseIntPipe,
ValidationPipe,
Logger,
NotFoundException,
Query,
UsePipes,
UseGuards,
} from "@nestjs/common";
import { CreateEventDto } from "./input/create-event.dto";
import { UpdateEventDto } from "./input/update-event.dto";
import { Event } from "./event.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { MoreThan } from "typeorm/find-options/operator/MoreThan";
import { Repository } from "typeorm/repository/Repository";
import { Like } from "typeorm";
import { Attendee } from "./attendee.entity";
import { EventsService } from "./event.service";
import { ListEvents } from "./input/list.event";
import { CurrentUser } from "src/auth/current-user.decorate";
import { User } from "src/auth/user.entity";
import { AuthGuardJwt } from "src/auth/auth-guard.jwt";
@Controller("events")
export class EventsController {
// ...
@Patch(":id")
@UseGuards(AuthGuardJwt)
async update(
@Param("id") id,
@Body() input: UpdateEventDto,
@CurrentUser() user: User
) {
const event = await this.eventsService.getEvent(id);
if (!event) {
throw new NotFoundException();
}
if (event.organizerId !== user.id) {
throw new ForbiddenException(
null,
`You are not allowed to change this event`
);
}
return await this.eventsService.updateEvent(event, input);
}
@Delete(":id")
@UseGuards(AuthGuardJwt)
@HttpCode(204)
async remove(@Param("id") id, @CurrentUser() user: User) {
const event = await this.eventsService.getEvent(id);
if (!event) {
throw new NotFoundException();
}
if (event.organizerId !== user.id) {
throw new ForbiddenException(
null,
`You are not allowed to remove this event`
);
}
await this.eventsService.deleteEvent(id);
}
}
// event.service.ts
public async updateEvent(event: Event, input: UpdateEventDto): Promise<Event> {
return await this.eventsRepository.save({
...event, ...input,
when: input.when ? new Date(input.when) : event.when
})
}
10. Data Serialization
10.1. Interceptors and Serialization
10.2. Serializing Data
import {
Controller,
Get,
Post,
ForbiddenException,
Delete,
Patch,
Param,
Body,
HttpCode,
ParseIntPipe,
ValidationPipe,
Logger,
NotFoundException,
Query,
UsePipes,
UseGuards,
SerializeOptions,
UseInterceptors,
ClassSerializerInterceptor,
} from "@nestjs/common";
import { CreateEventDto } from "./input/create-event.dto";
import { UpdateEventDto } from "./input/update-event.dto";
import { Event } from "./event.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm/repository/Repository";
import { EventsService } from "./event.service";
import { ListEvents } from "./input/list.event";
import { CurrentUser } from "src/auth/current-user.decorate";
import { User } from "src/auth/user.entity";
import { AuthGuardJwt } from "src/auth/auth-guard.jwt";
@Controller("events")
@SerializeOptions({
strategy: "excludeAll", // 序列化时排除所有,res为空
})
export class EventsController {
// ...
@Get(":id")
@UseInterceptors(ClassSerializerInterceptor)
async findOne(@Param("id", ParseIntPipe) id: number) {
const event = await this.eventsService.getEvent(id);
if (!event) {
throw new NotFoundException();
}
return event;
}
// ...
}
// user.entity.ts
import {
Column,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Profile } from "./profile.entity";
import { Event } from "src/events/event.entity";
import { Expose } from "class-transformer";
@Entity()
export class User {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column({ unique: true })
@Expose()
username: string;
@Column()
password: string;
@Column({ unique: true })
@Expose()
email: string;
@Column()
@Expose()
firstName: string;
@Column()
@Expose()
lastName: string;
@OneToOne(() => Profile)
@JoinColumn()
@Expose()
profile: Profile;
@OneToMany(() => Event, (event) => event.organizer)
@Expose()
organized: Event[];
}
// attendee.entity.ts
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Event } from "./event.entity";
import { Expose } from "class-transformer";
// 参会意向
export enum AttendeeAnswerEnum {
Accepted = 1,
Maybe,
Rejected,
}
@Entity()
export class Attendee {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column()
@Expose()
name: string;
@ManyToOne(() => Event, (event) => event.attendees, {
nullable: false,
})
// @JoinColumn({
// name: 'event_id',
// referencedColumnName: 'secondary'
// })
@JoinColumn()
event: Event;
@Column("enum", {
enum: AttendeeAnswerEnum,
default: AttendeeAnswerEnum.Accepted,
})
@Expose()
answer: AttendeeAnswerEnum;
}
// event.entity.ts
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { Attendee } from "./attendee.entity";
import { User } from "src/auth/user.entity";
import { Expose } from "class-transformer";
// name: 表名
// @Entity('event', { name: 'event' })
@Entity() // 改变name不会删旧表,而是会创建新表
export class Event {
// @PrimaryGeneratedColumn('uuid')
// @PrimaryGeneratedColumn('rowid')
// @PrimaryColumn() // 如果是复合主键,就在每个主键上加
@PrimaryGeneratedColumn() // 自动生成
@Expose() // 暴露,序列化可以返回
id: number;
@Column({ length: 100 })
@Expose()
name: string;
@Column()
@Expose()
description: string;
// name指定列名
// @Column({ name: 'when_date' })
@Column()
@Expose()
when: Date;
@Column()
@Expose()
address: string;
@OneToMany(() => Attendee, (attendee) => attendee.event, {
// eager: true, // 联表查询
cascade: true,
})
@Expose()
attendees: Attendee[];
@ManyToOne(() => User, (user) => user.organized)
@JoinColumn({ name: "organizerId" })
@Expose()
organizer: User;
@Column({ nullable: true })
organizerId: number;
@Expose()
attendeeCount?: number; // 不会保存到数据库
@Expose()
attendeeRejected?: number;
@Expose()
attendeeMaybe?: number;
@Expose()
attendeeAccepted?: number;
}
!!!不知道为啥 findAll 为空{},findAll 查询结果正常,但是返回序列化出错?
10.3. Serializing Nested Objects
PaginationResult 的结果被 ts 编译,但是返回的不是 js 对象,不会被序列化处理,应该把它变成 js 对象
// ...
export class PaginationResult<T> {
// Partial: 使 中的所有属性可选
constructor(partial: Partial<PaginationResult<T>>) {
// 将所有可枚举自身属性的值从一个或多个源对象复制到 目标对象。返回目标对象
Object.assign(this, partial);
}
@Expose()
first: number;
@Expose()
last: number;
@Expose()
limit: number;
@Expose()
total?: number;
@Expose()
data: T[];
}
export async function paginate<T>(
qb: SelectQueryBuilder<T>,
options: PaginateOptions = {
limit: 10,
currentPage: 1,
}
): Promise<PaginationResult<T>> {
const offset = (options.currentPage - 1) * options.limit;
const data = await qb.limit(options.limit).offset(offset).getMany();
return new PaginationResult({
first: offset + 1,
last: offset + data.length,
limit: options.limit,
total: options.total ? await qb.getCount() : null,
data,
});
}
破案了,要把 PaginationResult 改为 class,能被序列化,findall 才可以返回
11. (Practical) Building Full Events API
11.1. (Practical) Building Full Events API
11.2. Relations Between Entities
import {
Column,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Profile } from "./profile.entity";
import { Event } from "src/events/event.entity";
import { Expose } from "class-transformer";
import { Attendee } from "src/events/attendee.entity";
@Entity()
export class User {
// ...
@OneToMany(() => Event, (event) => event.organizer)
@Expose()
organized: Event[];
@OneToMany(() => Attendee, (attendee) => attendee.user)
@Expose()
attended: Attendee[];
}
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Event } from "./event.entity";
import { Expose } from "class-transformer";
import { User } from "src/auth/user.entity";
@Entity()
export class Attendee {
//...
@ManyToOne(() => Event, (event) => event.attendees, {
nullable: true,
onDelete: "CASCADE",
})
@JoinColumn()
event: Event;
@Column()
eventId: number;
@ManyToOne(() => User, (user) => user.attended)
@Expose()
user: User;
@Column({ nullable: true })
userId: number;
}
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { Attendee } from "./attendee.entity";
import { User } from "src/auth/user.entity";
import { Expose } from "class-transformer";
import { PaginationResult } from "src/pagination/paginator";
export class Event {
// ...
}
// 定义返回类型
export type PaginatedEvents = PaginationResult<Event>;
11.3. Getting Event Attendees
// attendee.service.ts
import { InjectRepository } from "@nestjs/typeorm";
import { Attendee } from "./attendee.entity";
import { Repository } from "typeorm";
export class AttendeesService {
constructor(
@InjectRepository(Attendee)
private readonly attendeeRepository: Repository<Attendee>
) {}
public async findByEventId(eventId: number): Promise<Attendee[]> {
return await this.attendeeRepository.find({
where: {
event: { id: eventId },
},
});
}
}
// event-attendees.controller.ts
import {
Controller,
Get,
UseInterceptors,
Param,
SerializeOptions,
ClassSerializerInterceptor,
} from "@nestjs/common";
import { AttendeesService } from "./attendee.service";
@Controller("events/:eventId/attendees")
@SerializeOptions({ strategy: "excludeAll" })
export class EventAttendeesController {
constructor(private readonly attendeesService: AttendeesService) {}
@Get()
@UseInterceptors(ClassSerializerInterceptor)
async findAll(@Param("eventId") eventId: number) {
return await this.attendeesService.findByEventId(eventId);
}
}
11.4. Getting Events Organized by User
// event.service.ts
public async getEventsOrganizedByUserIdPaginated(
userId: number, paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate<Event>(
this.getEventsOrganizedByUserIdQuery(userId),
paginateOptions
)
}
private getEventsOrganizedByUserIdQuery(
userId: number
) {
return this.getEventsBaseQuery()
.where('e.organizerId = :userId', { userId })
}
// event-organized-by-user.controller.ts
import {
Controller,
Param,
Query,
SerializeOptions,
Get,
UseInterceptors,
ClassSerializerInterceptor,
} from "@nestjs/common";
import { EventsService } from "./event.service";
@Controller("events-organized-by-user/:userId")
@SerializeOptions({ strategy: "excludeAll" })
export class EventsOrganizedByUserController {
constructor(private readonly eventsService: EventsService) {}
@Get()
@UseInterceptors(ClassSerializerInterceptor)
async findAll(@Param("userId") userId: number, @Query("page") page = 1) {
return await this.eventsService.getEventsOrganizedByUserIdPaginated(
userId,
{ currentPage: page, limit: 5 }
);
}
}
// event.service.ts
@Injectable()
export class EventsService {
// ...
public async getEventsOrganizedByUserIdPaginated(
userId: number,
paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate<Event>(
this.getEventsOrganizedByUserIdQuery(userId),
paginateOptions
);
}
private getEventsOrganizedByUserIdQuery(userId: number) {
return this.getEventsBaseQuery().where("e.organizerId = :userId", {
userId,
});
}
}
11.5. Current User Event Attendance - the Business Logic
// event.service.ts
public async getEventsAttendedByUserIdPaginated(
userId: number, paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate<Event>(
this.getEventsAttendedByUserIdQuery(userId),
paginateOptions
)
}
private getEventsAttendedByUserIdQuery(
userId: number
) {
return this.getEventsBaseQuery()
.leftJoinAndSelect('e.attendees', 'a')
.where('a.userId = :userId', { userId })
}
// attendee.service.ts
@Injectable()
export class AttendeesService {
constructor(
@InjectRepository(Attendee)
private readonly attendeeRepository: Repository<Attendee>
) {}
// ...
public async findOneByEventIdAndUserId(
eventId: number,
userId: number
): Promise<Attendee | undefined> {
return await this.attendeeRepository.findOne({
where: {
event: { id: eventId },
user: { id: userId },
},
});
}
public async createOrUpdate(
input: any,
eventId: number,
userId: number
): Promise<Attendee> {
const attendee =
(await this.findOneByEventIdAndUserId(eventId, userId)) ?? new Attendee();
attendee.eventId = eventId;
attendee.userId = userId;
// rest of input
return await this.attendeeRepository.save(attendee);
}
}
11.6. Current User Event Attendance - the Controller
public async createOrUpdate(
input: CreateAttendeeDto, eventId: number, userId: number
): Promise<Attendee> {
const attendee = await this.findOneByEventIdAndUserId(eventId, userId)
?? new Attendee()
attendee.eventId = eventId
attendee.userId = userId
attendee.answer = input.answer
return await this.attendeeRepository.save(attendee)
}
// create-attendee.dto.ts
import { IsEnum } from "class-validator";
import { AttendeeAnswerEnum } from "../attendee.entity";
export class CreateAttendeeDto {
@IsEnum(AttendeeAnswerEnum)
answer: AttendeeAnswerEnum;
}
// create-user-event-attendance.controller.ts
import {
Controller,
Get,
Param,
ParseIntPipe,
Body,
Query,
Put,
UseGuards,
SerializeOptions,
UseInterceptors,
ClassSerializerInterceptor,
NotFoundException,
} from "@nestjs/common";
import { EventsService } from "./event.service";
import { AttendeesService } from "./attendee.service";
import { CreateAttendeeDto } from "./input/create-attendee.dto";
import { CurrentUser } from "src/auth/current-user.decorate";
import { User } from "src/auth/user.entity";
import { AuthGuardJwt } from "src/auth/auth-guard.jwt";
@Controller("events-attendance")
@SerializeOptions({ strategy: "excludeAll" })
export class CurrentUserEventAttendanceController {
constructor(
private readonly eventsService: EventsService,
private readonly attendeesService: AttendeesService
) {}
@Get()
@UseGuards(AuthGuardJwt)
@UseInterceptors(ClassSerializerInterceptor)
async findAll(@CurrentUser() user: User, @Query("page") page = 1) {
return await this.eventsService.getEventsAttendedByUserIdPaginated(
user.id,
{ limit: 6, currentPage: page }
);
}
@Get(":/eventId")
@UseGuards(AuthGuardJwt)
@UseInterceptors(ClassSerializerInterceptor)
async findOne(
@Param("eventId", ParseIntPipe) eventId: number,
@CurrentUser() user: User
) {
const attendee = await this.attendeesService.findOneByEventIdAndUserId(
eventId,
user.id
);
if (!attendee) {
throw new NotFoundException();
}
return attendee;
}
@Put(":/eventId")
@UseGuards(AuthGuardJwt)
@UseInterceptors(ClassSerializerInterceptor)
async createOrUpdate(
@Param("eventId", ParseIntPipe) eventId: number,
@Body() input: CreateAttendeeDto,
@CurrentUser() user: User
) {
return this.attendeesService.createOrUpdate(input, eventId, user.id);
}
}
11.7. Events Refactoring
// 每个方法都有返回值类型定义
// event.service.ts
import { Injectable } from "@nestjs/common";
import { Logger } from "@nestjs/common/services";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "src/auth/user.entity";
import { PaginateOptions, paginate } from "src/pagination/paginator";
import { DeleteResult, Repository, SelectQueryBuilder } from "typeorm";
import { AttendeeAnswerEnum } from "./attendee.entity";
import { Event, PaginatedEvents } from "./event.entity";
import { CreateEventDto } from "./input/create-event.dto";
import { ListEvents, WhenEventFilter } from "./input/list.event";
import { UpdateEventDto } from "./input/update-event.dto";
@Injectable()
export class EventsService {
private readonly logger = new Logger(EventsService.name);
constructor(
@InjectRepository(Event)
private readonly eventsRepository: Repository<Event>
) {}
private getEventsBaseQuery(): SelectQueryBuilder<Event> {
return (
this.eventsRepository
.createQueryBuilder("e")
// .select(['e.id', 'e.name'])
.orderBy("e.id", "ASC")
);
}
public getEventsWithAttendeeCountQuery(): SelectQueryBuilder<Event> {
return this.getEventsBaseQuery() // 查询到所有events
.loadRelationCountAndMap(
// 给每个events的attendees注入值,值为关联的attendee的数量
"e.attendeeCount",
"e.attendees"
)
.loadRelationCountAndMap(
"e.attendeeAccepted",
"e.attendees",
"attendee",
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Accepted,
})
)
.loadRelationCountAndMap(
"e.attendeeRejected", // mapToProperty映射到哪个值
"e.attendees", // relationName关系名称
"attendee", // aliasName数据库名
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Rejected,
})
)
.loadRelationCountAndMap(
"e.attendeeMaybe",
"e.attendees",
"attendee",
(qb) =>
qb.where("attendee.answer = :answer", {
answer: AttendeeAnswerEnum.Maybe,
})
);
}
private getEventsWithAttendeeCountFilteredQuery(
filter?: ListEvents
): SelectQueryBuilder<Event> {
let query = this.getEventsWithAttendeeCountQuery();
if (!filter) {
return query;
}
if (filter.when) {
if (filter.when == WhenEventFilter.Today) {
query = query.andWhere(
`e.when >= CURDATE() AND e.when <= CURDATE() + INTERVAL 1 DAY`
);
}
if (filter.when == WhenEventFilter.Tommorow) {
query = query.andWhere(
`e.when >= CURDATE() + INTERVAL 1 DAY AND e.when <= CURDATE() + INTERVAL 2 DAY`
);
}
if (filter.when == WhenEventFilter.ThisWeek) {
query = query.andWhere("YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1)");
}
if (filter.when == WhenEventFilter.NextWeek) {
query = query.andWhere(
"YEARWEEK(e.when, 1) = YEARWEEK(CURDATE(), 1) + 1"
);
}
}
return query;
}
public async getEventsWithAttendeeCountFilteredPaginated(
filter: ListEvents,
paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate(
await this.getEventsWithAttendeeCountFilteredQuery(filter),
paginateOptions
);
}
public async getEventWithAttendeeCount(
id: number
): Promise<Event | undefined> {
const query = this.getEventsWithAttendeeCountQuery().andWhere(
"e.id = :id",
{ id }
); // 根据id筛选结果
this.logger.debug(await query.getSql());
return await query.getOne();
}
public async findOne(id: number): Promise<Event | undefined> {
return await this.eventsRepository.findOne({ where: { id } });
}
public async createEvent(input: CreateEventDto, user: User): Promise<Event> {
return await this.eventsRepository.save({
...input,
organizer: user,
when: new Date(input.when),
});
}
public async updateEvent(
event: Event,
input: UpdateEventDto
): Promise<Event> {
return await this.eventsRepository.save({
...event,
...input,
when: input.when ? new Date(input.when) : event.when,
});
}
public async deleteEvent(id: number): Promise<DeleteResult> {
return await this.eventsRepository
.createQueryBuilder("e")
.delete()
.where("id = :id", { id })
.execute();
}
public async getEventsOrganizedByUserIdPaginated(
userId: number,
paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate<Event>(
this.getEventsOrganizedByUserIdQuery(userId),
paginateOptions
);
}
private getEventsOrganizedByUserIdQuery(
userId: number
): SelectQueryBuilder<Event> {
return this.getEventsBaseQuery().where("e.organizerId = :userId", {
userId,
});
}
public async getEventsAttendedByUserIdPaginated(
userId: number,
paginateOptions: PaginateOptions
): Promise<PaginatedEvents> {
return await paginate<Event>(
this.getEventsAttendedByUserIdQuery(userId),
paginateOptions
);
}
private getEventsAttendedByUserIdQuery(
userId: number
): SelectQueryBuilder<Event> {
return this.getEventsBaseQuery()
.leftJoinAndSelect("e.attendees", "a")
.where("a.userId = :userId", { userId });
}
}
nestjs 可能出现循环依赖,写代码时要注意
如果返回的是普通对象(没有 constructer),不会被序列化,要转为 js 对象
@Entity()
export class Event {
constructor(parial?: Partial<Event>) {
Object.assign(this, parial);
}
// ...
}
// 定义返回类型
export type PaginatedEvents = PaginationResult<Event>;
定义默认值需要用到 Default…pipe
12. Testing
12.1. Manual Testing with Postman
12.2. Introduction to Automated Testing
12.3. Introduction to Jest
npm i --save-dev @nestjs/testing
// test.spec.ts
test("test is null", () => {
const n = null;
expect(n).toBeNull();
});
npm run test
# or
yarn test
13. Unit Testing
13.1. Basic Unit Test and Code Coverage
测试时,要把 src/目录换成相对路径,不然找不到
// event.entity.ts
import { Event } from "./event.entity";
// test("Event should be initialized through constructor", () => {
// const event = new Event({
// name: "Interesting event",
// description: "That was fun",
// });
// expect(event).toEqual({
// name: "Interesting event",
// description: "That was fun",
// });
// });
import { Event } from "./event.entity";
test("Event should be initialized through constructor", () => {
const event = new Event({
name: "Interesting event",
description: "That was fun",
});
expect(event).toEqual({
name: "Interesting event",
description: "That was fun",
id: undefined,
when: undefined,
address: undefined,
attendees: undefined,
organizer: undefined,
organizerId: undefined,
event: undefined,
attendeeCount: undefined,
attendeeRejected: undefined,
attendeeMaybe: undefined,
attendeeAccepted: undefined,
});
});
npm run test:cov
13.2. Test Grouping, Spies, Mocks, Setup and Teardown
// event.controller.spec.ts
import { Repository } from "typeorm";
import { EventsService } from "./event.service";
import { EventsController } from "./events.controller";
import { Event } from "./event.entity";
import { ListEvents } from "./input/list.event";
import { User } from "../auth/user.entity";
import { NotFoundException } from "@nestjs/common";
describe("EventsController", () => {
let eventsService: EventsService;
let eventsController: EventsController;
let eventsRepository: Repository<Event>;
beforeAll(() => console.log("this logged once"));
beforeEach(() => {
eventsService = new EventsService(eventsRepository);
eventsController = new EventsController(eventsService);
});
// it === test
it("should return a list of events", async () => {
const result = {
first: 1,
last: 1,
limit: 10,
data: [],
};
// 模拟方法,并提供方法实现(不依赖/使用外部方法,只提供模拟调用的结果,查看功能是否可用)
// eventsService.getEventsWithAttendeeCountFilteredPaginated
// = jest.fn().mockImplementation((): any => result)
// 监视并模拟方法
const spy = jest
.spyOn(eventsService, "getEventsWithAttendeeCountFilteredPaginated")
.mockImplementation((): any => result);
expect(await eventsController.findAll(new ListEvents())).toEqual(result);
// 只想调用一次
expect(spy).toBeCalledTimes(1);
});
it("should not delete an event, when it's not found", async () => {
const deleteSpy = jest.spyOn(eventsService, "deleteEvent");
const findSpy = jest
.spyOn(eventsService, "findOne")
.mockImplementation((): any => undefined);
try {
await eventsController.remove(1, new User());
} catch (error) {
expect(error).toBeInstanceOf(NotFoundException);
}
// 找不到就不能删除
expect(deleteSpy).toBeCalledTimes(0);
expect(findSpy).toBeCalledTimes(1);
});
});