04-nestjs基础实践,typeorm操作mysql,增删改查,连表查询,错误捕获

647 阅读4分钟

连接mysql数据库

安装插件:npm i -S @nestjs/typeorm typeorm mysql2

在根模块app.module.ts中连接mysql

import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
  imports: [
    /** 连接mysql数据库 */
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: '11.111.111.111',
      port: 3306,
      username: 'root',
      password: 'you-database-password',
      database: 'you-database-name',
      // 扫描本项目中.entity.ts或者.entity.js的文件: [__dirname + '/**/*.entity{.ts,.js}']
      entities: [],
      // 定义数据库表结构与实体类字段同步(这里一旦数据库少了字段就会自动加入,根据需要来使用),一般用于数据库初始化
      synchronize: true,
      // 开发环境打印所有日志
      logging: process.env.NODE_ENV === 'development' ? true : ['error'], 
    }),
  ],
})
export class AppModule {}

现在我们需要读取环境变量设置数据库, 修改app.module.ts数据库连接的写法

import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigEnum } from './enum/config.enum';
@Module({
  imports: [
    /** 配置环境变量 */
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `./.env.${process.env.NODE_ENV}`,
      load: [() => dotenv.config({ path: '.env' })],
      validationSchema: Joi.object({
        DB_TYPE: Joi.string().valid('mysql'),
        DB_DATABASE: Joi.string(),
        DB_PORT: Joi.number().default(3306),
        DB_HOST: Joi.alternatives().try(
          Joi.string().ip(), // ip
          Joi.string().domain(), // 域名
        ),
        DB_USERNAME: Joi.string(),
        DB_PASSWORD: Joi.string(),
        DB_SYNC: Joi.boolean().default(false),
      }),
    }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        return {
          type: configService.get(ConfigEnum.DB_TYPE),
          host: configService.get(ConfigEnum.DB_HOST),
          port: configService.get(ConfigEnum.DB_PORT) || 3306,
          username: configService.get(ConfigEnum.DB_USERNAME),
          password: configService.get(ConfigEnum.DB_PASSWORD),
          database: configService.get(ConfigEnum.DB_DATABASE),
          synchronize: configService.get(ConfigEnum.DB_SYNC) || false,
          entities: [],
          logging: process.env.NODE_ENV === 'development' ? true : ['error'],
        } as TypeOrmModuleOptions;
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

创建配置文件:./enum/config.enum.ts

/** mysql数据库的配置枚举 */
export enum ConfigEnum {
  DB_TYPE = 'DB_TYPE',
  DB_DATABASE = 'DB_DATABASE',
  DB_HOST = 'DB_HOST',
  DB_PORT = 'DB_PORT',
  DB_USERNAME = 'DB_USERNAME',
  DB_PASSWORD = 'DB_PASSWORD',
  DB_SYNC = 'DB_SYNC',
}

typeorm创建表结构

接下来需要创建4个实体类:user.entity.ts,profile.entity.ts,logs.entity.ts,roles.entity.ts,typeorm会每个将每个实体类创建一张对应的表,同时会根据我们稍后的语法生成一张额外的users_roles

下图是用vscode插件Draw.io Integration,用法参考前面的文章

image.png

创建src/user/user.entity.ts实体类

import { Logs } from 'src/logs/logs.entity';
import { Roles } from 'src/roles/roles.entity';
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn,} from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({ unique: true }) // options设置用户名唯一
  username: string;
  @Column()
  password: string;
  
  // user表和logs表是一对多的关系,
  // 在user表中用logs字段保存了该用户所有的日志(用数组保存),
  // 在logs表中用user字段保存了对应的用户信息,并且会在logs中自动生成一个userId字段做关联
  @OneToMany(() => Logs, (logs) => logs.user)
  logs: Logs[];
  
  // user表和roles表是多对多的关系
  @ManyToMany(() => Roles, (roles) => roles.users)
  @JoinTable({ name: 'users_roles' }) // 自动生成一张users_roles表,name参数是给该表命名。这这张表会记录user表和roles表的关联字段
  roles: Roles[];
  
  // profile表和user表是一对一的关系
  @OneToOne(
    () => Profile, 
    (profile) => profile.user // 连表查询的时候对应profile表中的user字段
  )
  profile: Profile
}

创建src/user/profile.entity.ts实体类

import { User } from './user.entity';
@Entity()
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  gender: number;
  @Column()
  photo: string;
  // profile表和user表是一对一的关系
  @OneToOne(() => User)
  @JoinColumn() // 会在这张表中自动生成关联userId字段
  user: User;
}

创建src/logs/logs.entity.ts实体类

@Entity()
export class Logs {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  path: string;
  @Column()
  method: string;
  @Column()
  data: string;
  @Column()
  result: number;
  // logs表和user表是多对一的关系,
  @ManyToOne(() => User, (user) => user.logs)
  @JoinColumn() // 会在这张表中自动生成关联userId字段(这个装饰器可以省略)
  user: User;
}

创建src/roles/roles.entity.ts实体类

import { User } from 'src/user/user.entity';
@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  name: string;
  // user表和logs表是一对多的关系,
  @ManyToMany(() => User, (user) => user.roles)
  users: User[];
}

在入口模块app.module.ts文件中引入上面创建的4个实体类,修改TypeOrmModuleentities配置。

/** 连接mysql数据库 */
TypeOrmModule.forRootAsync({
  ...
  - entities: [],
  + entities: [User, Profile, Logs, Roles],
  ...
}),

查询数据库会发现创建了5张表

image.png

用已有数据库生成typeorm实体类

安装插件linknpm i typeorm-model-generator -D

package.json中添加一条命令

"generate:models": "typeorm-model-generator -h 11.111.111.111 -p 3306 -d testdb -u username -x !password -e mysql -o ./src/entites"

image.png 运行命令:npm run generate:models,会在目录中输出实体类代码文件

image.png

typeorm进行数据库的增删改查

基本的增删改查

src/user/user.module.ts中引入typeorm模块

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserController } from './user.controller';
import { User } from './user.entity';
import { UserService } from './user.service';
@Module({
  + imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

编辑src/user/user.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User) private readonly userRepo: Repository<User>,
  ) {}
  // 查询所有数据
  findAll() {
    return this.userRepo.find();
  }
  // 查询某一条数据
  findOne(id: number) {
    if (!id) return;
    return this.userRepo.findOne({ where: { id } });
  }
  // 创建数据
  async create(username: string, password: string) {
    const user = await this.userRepo.create({ username, password });
    return this.userRepo.save(user);
  }
  // 更新数据
  async update(id: number, attrs: Partial<User>) {
    const user = await this.findOne(id);
    if (!user) throw new NotFoundException('user not found');
    Object.assign(user, attrs);
    await this.userRepo.update(id, user);
    return this.findOne(id);
  }
  // 查询所有数据
  async remove(id: number) {
    return this.userRepo.delete(id);
  }
}

src/user/user.controller.ts中调用userService

import { Controller, Get, Post } from '@nestjs/common';
import { UserService } from './user.service';
import { ConfigService } from '@nestjs/config';
@Controller('user')
export class UserController {
  constructor(
    private userService: UserService,
    private configService: ConfigService,
  ) {}
  @Get()
  getUsers() {
    return this.userService.findAll();
  }
  @Post()
  addUser() {
    return this.userService.create('kgm', 'password');
  }
}

一对一关联查询

src/user/user.service.ts中增加方法

  // 关联查询用户信息
  findProfile(id: number) {
    return this.userRepo.findOne({
      where: { id },
      relations: { profile: true, }, // 连表查询出profile的信息,如果不连表查询就没有profile字段信息
    })
  }

image.png

一对多关联查询

src/user/user.service.ts中增加方法

// 关联查询用户信息
return this.userRepo.find({
  where: { id },
  relations: {
    logs: true, // 连表查询出所有的logs的信息,如果不连表查询就没有logs字
  },
})

image.png

多对一关联查询

src/user/user.service.ts中增加方法(需要在user.module.ts中引入Logs实体类,此处省略代码)

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User) private readonly userRepo: Repository<User>,
    @InjectRepository(Logs) private readonly logsRepo: Repository<Logs>,
  ) {}

  // 查询当前用户日志
  async findUserLogs(id: number) {
    const user = await this.findOne(id)
    return this.logsRepo.find({
      where: { user },
      relations: { user: true, }, // 连表查询出user的信息,如果不连表查询就没有user字段信息
    })
  }
}

image.png

使用QueryBuilder进行联合查询

typeorm官方文档

src/user/user.service.ts添加方法

  // 获取日志并分组
  findLogsByGroup(id: number) {
    return this.logsRepo
      .createQueryBuilder('logs')
      .select('logs.result', 'result') // 查找出result字段
      .addSelect('COUNT("logs.result")', 'count') // 统计数量
      .leftJoinAndSelect('logs.user', 'user')
      .where('user.id = :id', { id }) // 通过id查找用户信息
      .groupBy('logs.result') // 分组
      .orderBy('count', 'DESC') // 倒序排列
      .addOrderBy('result', 'DESC') // 添加倒序排列
      .offset(1) // 跳过几条
      .limit(2) // 只查2条
      .getRawMany()
  }

上面的查询最后生成的sql语句如下

SELECT `logs`.`result` AS `result`, `user`.`id` AS `user_id`, `user`.`username` AS `user_username`, `user`.`password` AS `user_password`, COUNT("logs.result") AS `count` FROM `logs` `logs` LEFT JOIN `user` `user` ON `user`.`id`=`logs`.`userId` WHERE `user`.`id` = ? GROUP BY `logs`.`result` ORDER BY count DESC, result DESC LIMIT 2 OFFSET 1

image.png

使用原生sql语法,在src/user/user.service.ts修改方法

  // 获取日志并分组
  findLogsByGroup(id: number) {
    return this.logsRepo.query('SELECT * FROM logs WHERE userId=2')
    // 或者下面的查询
    return this.logsRepo.query(
      'SELECT logs.result as result, COUNT(logs.result) as count from logs, user WHERE user.id = logs.userId AND user.id = 2 GROUP BY logs.result',
    )
  }

实践案例

列表分页查询:GET /api/user?current=1&pageSize=4&role=1&gender=1&username=kjw

存在user|profile|roles表,查询满足条件的用户列表

  • current: 查询第几页
  • pageSize:每一页查询数量
  • role: 要查询的用户角色id
  • gender:用户的性别
  • username: 用户名

查询方案1:

  // 查询所有用户数据
  async findAll(query: getUserDto) {
    const { current = 1, pageSize = 10, username, role, gender } = query
    const skip = (current - 1) * pageSize 
    // 主表是user表
    return this.userRepo.find({
      relations: { profile: true, roles: true}, // 要关联查询的表
      // 配置输出的字段
      select: {
        id: true, username: true, // 输出: user.id, user.username
        profile: { gender: true }, // 输出: profile.gender
        roles: { name: true }, // 输出: roles.name
      },
      // 查询筛选条件,满足以下条件才会筛选出来
      where: { username, profile: { gender },  roles: { id: role } },
      take: pageSize, // 每页的查询条数
      skip, // 跳过多少条查询
    })
  }

查询方案2:

...
const queryBuilder = this.userRepo
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.profile', 'profile')
  .leftJoinAndSelect('user.roles', 'roles')
  // 三元表达式,处理查询条件不存在产生查询有误的问题
  .where(username ? 'user.username = :username' : '1=1', { username })
  .andWhere(gender ? 'profile.gender = :gender' : '1=1', { gender })
  .andWhere(role ? 'roles.id = :role' : '1=1', { role })
return queryBuilder.take(pageSize).skip(skip).getMany()
...

查询方案3:

// 创建工具函数
import { SelectQueryBuilder } from 'typeorm'
export const conditionUtils = <T>(queryBuilder: SelectQueryBuilder<T>, obj: Record<string, unknown>): SelectQueryBuilder<T> => {
  Object.keys(obj).forEach((key) => {
    if (obj[key]) queryBuilder.andWhere(`${key} = :${key}`, { [key]: obj[key] })
  })
  return queryBuilder
}
// 下面是查询的真正实现
...
const obj = { 'user.username': username, 'profile.gender': gender, 'roles.id': role }
const queryBuilder = this.userRepo
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.profile', 'profile')
  .leftJoinAndSelect('user.roles', 'roles')
const newQuery = conditionUtils<User>(queryBuilder, obj)
return newQuery.take(pageSize).skip(skip).getMany()
...

添加用户数据:POST /api/user

请求体body:{"username":"kgm@qq.com", "password":"123456"}

在添加用户过程中,需要验证username字段的唯一性,可以通过修改user.entity.ts中user实例的username字段定义简化验证操作

@Entity()
export class User {
  ...
  @Column({unique: true}) // 配置{unique: true},告诉数据库username唯一
  username: string
  ...
}

异常处理方式1: 直接捕获错误

// 创建数据
async create(dto: User) {
  const user = await this.userRepo.create(dto)
  try {
    return await this.userRepo.save(user)
  } catch (error) {
    // 错误处理(用户名重复错误)
    if (error.errno && error.errno === 1062) throw new HttpException(error?.sqlMessage, 500) 
  }
}

异常处理方式2:全局捕获错误

// 创建数据
async create(dto: User) {
  const user = await this.userRepo.create(dto)
  return this.userRepo.save(user)
}

修改全局异常捕获文件./src/filters/all-exception.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, LoggerService } from '@nestjs/common'
import { HttpAdapterHost } from '@nestjs/core'
import { Request, Response } from 'express'
import * as requestIp from 'request-ip'
import { QueryFailedError } from 'typeorm'

@Catch()
export class AllExceptionFilter implements ExceptionFilter {
  constructor(
    private logger: LoggerService,
    private httpAdapterHost: HttpAdapterHost,
  ) {}
  catch(exception: unknown, host: ArgumentsHost) {
    const { httpAdapter } = this.httpAdapterHost
    const ctx = host.switchToHttp()
    const req: Request = ctx.getRequest() // 请求对象
    const res: Response = ctx.getResponse() // 响应对象
    // http状态码
    const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
    // 加入更多错误逻辑-------------------------------------开始
    let errorMsg = exception['response'] || 'Internal Server Error'
    if (exception instanceof QueryFailedError) {
      errorMsg = exception.message
      // if (exception.driverError.errno === 1062) errorMsg = '唯一索引冲突' // 更详细
    }
    // 加入更多错误逻辑-------------------------------------结束
    const resBody = {
      Headers: req.headers,
      query: req.query,
      body: req.body,
      params: req.params,
      timestamp: new Date().toISOString(),
      ip: requestIp.getClientIp(req),
      exception: exception['name'],
      error: errorMsg,
    }
    // 打印日志
    this.logger.error('[toimic]', resBody)
    // 返回接口
    const resData = {
      code: status, msg: resBody.error,
      // timestamp: new Date().toDateString(),
    }
    httpAdapter.reply(response, resData, status)
  }
}

main.ts中配置全局异常处理器

import { HttpAdapterHost, NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'
import { AllExceptionFilter } from './filters/all-exception.filter'
async function bootstrap() {
  const app = await NestFactory.create(AppModule, {})
  const logger = app.get(WINSTON_MODULE_NEST_PROVIDER)
  app.useLogger(logger)
  app.setGlobalPrefix('api') // 给每一个接口添加前缀'/api'
  // 捕获所有异常
  + app.useGlobalFilters(new AllExceptionFilter(logger, app.get(HttpAdapterHost))) 
  await app.listen(9010)
}
bootstrap()

异常处理方式3:局部捕获错误

创建一个异常处理过滤器nest g f filters/typeorm --flat --no-spec,会生成一个src/filters/typeorm.filter.ts文件

import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'
import { QueryFailedError, TypeORMError } from 'typeorm'
import { Response } from 'express'

@Catch(TypeORMError)
export class TypeormFilter implements ExceptionFilter {
  catch(exception: TypeORMError, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const response: Response = ctx.getResponse() // 响应对象
    // 打印日志
    let code = 500
    if (exception instanceof QueryFailedError) code = exception.driverError.errno
    response.status(500).json({
      code: code,
      // timestamp: new Date().toDateString(),
      // path: request.url,  method: request.method,
      msg: exception.message,
    })
  }
}

user.controller.ts中使用

@Controller('user')
+ @UseFilters(new TypeormFilter())
export class UserController {
  constructor(
    private userService: UserService,
  ) {
  }
}

更新有关联关系的用户

请求信息如下,会同时修改user表中的用户和该用户对应的profile表中的信息

PATCH  {{host}}/api/user/2
content-type: application/json
Authorization: 1

{
  "id": 2,
  "username": "lyl@qq.com",
  "password": "1234567",
  "profile": {"id": 2, "gender": 2, "photo": "lyl photo 111", "address": "lyl address 111"}
}

改造user.entity.ts

@Entity()
export class User {
  ...
  @OneToMany(() => Logs, (logs) => logs.user, {
    // 更新的时候会连同logs信息一起修改
    cascade: true,
  })
  logs: Logs[]

  @ManyToMany(() => Roles, (roles) => roles.users, {
    // 更新的时候会连同roles信息一起修改,只有在'insert','update'的情况下才会同步更新
    cascade: ['insert', 'update'],
  })
  @JoinTable({ name: 'users_roles' }) // 生成中间表,name(给中间表命名)
  roles: Roles[]
  
  @OneToOne(() => Profile, (profile) => profile.user, {
    + cascade: true, // 更新的时候会连同profile信息一起修改
  })
  profile: Profile
}

改造profile.entity.ts

@Entity()
export class Profile {
  ...
  @OneToOne(() => User, {
    // 当实例user被删除时删除,有权删除对应的profile数据(呼应上面user表中关于profile的cascade配置)
    // 这里为什么不能设置cascade: true呢,应为可能导致profile和user循环删除而报错
    + onDelete: 'CASCADE', 
  })
  ...
}

修改user.service.ts

...
  // 查询所有数据
  async update(id: number, attrs: Partial<User>) {
    const userTemp = await this.findProfile(id)
    const newUser = this.userRepo.merge(userTemp, attrs)
    // 联合模型更新,需要使用save方法或者queryBuilder
    return this.userRepo.save(newUser)
  }
...

修改user.controller.ts

/**
 * 更新用户信息
 * @param dto
 * @param id
 * @returns
 */
@Patch(':id')
updateUser(@Body() dto: any, @Param('id') id: number, @Headers('Authorization') Authorization: any) {
  // 权限1,判断用户是否是自己
  if (id === Authorization) {
    // 权限2,判断用户是否有更新的权限
    // 返回数据不能包含password等信息
    const user = dto as Partial<User>
    return this.userService.update(id, user)
  } else {
    // 不是同一个用户
    throw new UnauthorizedException()
  }
}