nestjs-关系数据库(mysql)模型、关系、操作

808 阅读34分钟

前言

mysql 也算是比较经典的免费的关系型数据库了,很多人都在用,且很好用,MongoDB是一个介于关系型和非关系型数据库之间,性能表现不错

由于主要学习数据库,我们就从最经典的 mysql 开始,使用 typeorm 映射的 mysql

mysqlMongoDB

SQL基础教程 (如果数据库基础不是很好,可以参考这里,我也是参考这里回忆学习

十个mysql常用函数

ps:数据库是不区分大小写的,因此我们设置字段时无需过分关注大小写,我们只要保持和数据库字段一致即可,另外传参时为了避免给客户端带来大小写的麻烦,最好采用蛇形变量(_)

数据库工具仍然使用的是 Database Client

数据表 Entity

编写之前,注意引入 typeormmysql,前面有介绍

yarn add typeorm @nestjs/typeorm mysql2

是我们用于映射到数据库字段的入口,可以理解为,声明模型等于建立数据库表格

下面定义一个基础用户表格,需要注意的是我们自定义的字段,一定要设置默认值 default,否则可能会报错

ps:存放小数时很多老手都是推荐存放字符串,否则不同平台不同编译环境,会出现意想不到的精度问题,虽然了解后在意料之中,避免小数直接用数字是最好的,如果有些需要用到小数,后面改没办法,可以以将该参数转化为字符串返回即可(用的地方多了会稍微麻烦点,但也确实是一个好方案)

@Entity()  //默认带的 entity
//@Entity('user')  //可以设置数据库表格名称
export class User {
    //作为主键且创建时自动生成,默认自增
    @PrimaryGeneratedColumn()
    id: number

    //用户名,unique 唯一值, default 默认值
    @Column({ unique: true, default: null })
    account: string

    //默认数据库的列,会根据 ts 类型,自动创建自定类型,默认字符串 255 byte,也就是255个unicode字符
    @Column({default: null})
    nickname: string

    //可以设置唯一值,参数可以点进去看详情
    @Column({ unique: true, default: null })
    wxId: string

    //设置默认值
    @Column({ default: null })
    age: number

    //设置枚举,实际推荐数字 + 文档即可,方便又实惠,这里仅仅看着方便
    //枚举到数据库中实际上是字符串,因此如果设置成数字查询要额外注意了
    // @Column('simple-enum', { enum: ['0', 'woman', 'unknow'], default: 'unknow' })
    // sex: string
    @Column('simple-enum', { enum: [1, 2, 0], default: 0 })
    sex: number

    @Column({ default: null }) //默认最大字符串255字节,能储存255个unicode字符
    mobile: string

    //默认都是可变字节,如果设置最大长度比较小,但内容比较大,也能写入,但是效率可能会变低
    //默认最大字节数比较大,65535为text,另一个更大,也可以根据自行设置大小
    // @Column('mediumtext', {default: null})
    @Column('text', { default: null })
    desc: string

    //下面是创建内容自动生成,和更新时自动更新的时间戳,分别代表该条记录创建时间和上次更新时间
    @CreateDateColumn({ type: 'timestamp' })
    createTime: Date
    
    //更新内容时会自动更新时间戳
    @UpdateDateColumn({ type: 'timestamp' })
    updateTime: Date

    ...
}

复合主键primary、复合Unique

前面看到了主键使用 PrimaryGeneratedColumn(),唯一使用 unique: true

而我们需要用到复合主键的和复合唯一校验的怎么处理

复合主键:有些显示关联表可能不需要自动生成主键,其主键有外键组成,那么就需要使用复合主键

//复合主键,需要再复合的键值上设置 @PrimaryColumn(),里面参数正常设置即可,一般主键不会设置那么多
//如果一个显示关联表不设置复合主键,则可能无法同步数据库成功
@PrimaryColumn()
id1: number
@PrimaryColumn()
id2: number

复合唯一校验:有些唯一值校验多个值同时重复才重复,例如:局部唯一的需求,假删需求

@Entity()
@Unique(['nickname', 'remove_time'])
export class User {
}

索引 index

索引是一个数据库优化手段,通过在外部建立索引的方式提高关键字的查询效率(像目录一样),其使用平衡树中的B树来进行的优化,缺点是需要额外占用一些空间,需要优化时可以用上,如果用的人少,空间也少,可以不用此优化

独立索引,使用 @Index 设置独立索引,同时也可以设置 unique

@Entity()  
export class User {  
    @PrimaryGeneratedColumn()  
    id: number;  

    @Index({ unique: true })  
    @Column()  
    firstName: string;  

    @Column()  
    @Index({ unique: true })  
    lastName: string;  
}

联合索引,当一个内容需要多列组合时,需要用到联合索引,同时也可以设置为 unique 复合唯一

@Entity()  
@Index(["firstName", "lastName"])  
@Index(["firstName", "lastName"], { unique: true })  
export class User {  
    @PrimaryGeneratedColumn()  
    id: number;  

    @Column()  
    firstName: string;  
    
    @Column()  
    lastName: string;  
}

数据表关系

数据表中有下面几种关系一对一、一对多、多对一、多对多,并且他们的关系往往是双向存在的(除非单纯的包含关系,那样可以是单向,无法反向查询)

OneToOne、OneToMany、ManyToOne、ManyToMany

一对一

上面会发现我们 user表 没有密码等信息,为了演示一对一,这里额外建立了一个 author表 用它来隐藏我们的隐私信息,也方便管理了,查询时也无需隐藏了,方便了很多

可能有人说一对一影响查询速度,没有存在的必要,我想说,我们选择使用关系型数据库,不只是为了性能,更多是为了安全,为了处理繁杂的关系而存在,我们的很多表格都是根据社会分工等关系建立的,否则很多东西全混杂到一个表格那才叫难受

例如:用户表 -- 在职企业信息(假设一个用户只有一个企业) -- 学生信息 等,要不要建立一对一呢,还是揉到一起呢

下面我们用上面的用户表,将 userauth 表关联起来,

user 表 设置 OneToOne 关联

//user表 添加 auth,并设置 一对一关系

@Entity()  //默认带的 entity
export class User {
    ...
    //用户隐私信息表,Auth类型,其中author 的 user 属性对应的自己
    @OneToOne(() => Auth, auth => auth.user)
    auth: Auth
}

给我们的 auth 表 设置 OneToOne 关联,使用@JoinColumn()设置外键,如果想自定义外键名称可以传递参数

//auth 表,添加用户信息
@Entity()  //默认带的 entity
export class Auth {
    //作为主键且创建时自动生成,默认自增
    @PrimaryGeneratedColumn()
    id: number

    //密码
    @Column({ length: 30, default: null }) //可以设置长度30个字节
    password: string

    //身份证
    @Column({ length: 20, default: null})
    idCard: string

    ...

    //设置 User 类型,user 的 auth 属性指向自己
    @OneToOne(() => User, (user) => user.auth)
    @JoinColumn() //添加外键,建立表关联,会自动生成userId,这个id就是外键为另一个user表的primaryid
    user: User
    
    //这样给外键赋值时无需传递对象了,并且获取时还可以额外获得该属性,不需内容时无需连表查询
    //增加该属性并不会额外减少或者增加数据库字段,建立关系后数据库会自然的生成该字段,只不过我们没有映射
    @Column({ default: null })
    userId: number
}

需要注意的是,一定要在一个表格中设置 @JoinColumn(),其意思是设置外键关联,会给设置的表格自动添加一个外键id,并指向另外一个表格的 id 属性,

本案例给 auth 添加的外键,在数据库中,默认名字为 userId, 即,默认外键名称为: 参数 + Id,并且,我们赋值时,需要给设置外键的auth表的关联属性user赋值,这样就会自动取另一个对象userid属性会赋值给我们的外键 userId保存到数据库中

//给谁添加的外键(JoinColumn),谁需要被赋值,这个靠映射关联,因此保存之前确定映射关系也没问题
auth.user = user;
await this.authRepository.save(auth);

为什么给 author 添加外键呢,我们user是一个重要的表格,又可以称为主表,auth作为一个子表扩展是比较合理的(ps:外键是用于和其他表建立关系扩展功能用的,因此又叫做子表、关联表)

上面会发现我们我们建立关联时,给外键表格 auth 额外增加了一个 userId 字段,前面说了,这个字段即使不声明数据库也会存在,我们设置他也是为了获取数据时,额外获取一个userId,某些时候只需要对比 id,则可以不用联表查询了,另外建立关联的逻辑 是 给外键赋予另一个表的 id,所以,我们也可以直接给该 id 直接赋值建立关系(一定要确认另一个表的 id 存在哈)

auth.userId = user.id;//给谁添加的外键(JoinColumn),谁需要被赋值,注意userid保存前id不存在
await this.authRepository.save(auth);

看看我们建立的数据库表格吧

image.png

一对多/多对一

这个也是我们常用的功能,我们就以掘金为例,一个用户可以编写多篇文章,每篇文章只会对应一个用户,这就是典型的一对多/多对一,他们两个实际上是一样的,只不过从哪个角度看,一个是多对一、另一个必然是一对多

就以掘金的文章和用户为例,建立 一对多/多对一 关系表

需要注意的是,一对多/多对一外键一定是建立在 多对一 的一方,因为数据多的那方只需要设置一个外键,即可关联另外一个表格,而另一个表格获取多个时,只需要将外键和自己匹配的全部获取即可

给文章 article表 添加 多对一,并添加外键

//文章article表
@Entity()
export class Article {
    @PrimaryGeneratedColumn()
    id: number

    //内容
    @Column({ default: null })
    title: string

    //描述
    @Column({ default: null })
    desc: string

    //内容
    @Column('mediumtext', { default: null })
    content: string

    //文章状态,默认创建即编辑中、等待审核、审核中、成功、失败
    //平时可以数字枚举或者个别字符,以提升实际效率和空间,文档注释最重要,这里纯粹为了看着清晰
    @Column('simple-enum', { 
        enum: [ArticleStatus.editing, ArticleStatus.waiting, ArticleStatus.checking, ArticleStatus.success, ArticleStatus.failure], 
        default: ArticleStatus.editing,
    })
    status: ArticleStatus

    ...

    //设置多对一的关系,顺道添加一个外键
    @ManyToOne(() => User, user => user.articles)
    @JoinColumn() //设置外键
    user: User
    @Column({ default: null })
    userId: number

    //额外设置一个软删除,当必要时可以恢复
    @Column({ default: false, select: false })
    isDelete: boolean
}

给 user 表添加一对多关联

//user表
@Entity()
export class User {
    ...
    //我发布的文章,一个用户多篇文章,一对多
    @OneToMany(() => Article, article => article.user)
    articles: Article[]
}

多对多(隐式)

ps显式多对多不多介绍了,就是中间表和另外两个表分别一对多/多对一的关系罢了(一般我们关系表需要存放额外字段这么写)

多对多就一种,我们还是以掘金的文章和表格为例,一篇文章可以被多个不同的人收藏、一个人可以收藏多篇文章,这就是典型的一对多

多对多没有那么多要求,两个都是多对多,我们的关联参数都只能是数组,无法设置外键,因此我们就要使用 @JoinTable()新建立一个关系表格了(就是单纯的 article、user的id建立关系表)

//user表
@Entity()
export class User {
    ...
    //收藏文章
    //一篇文章会被多个人收藏,一个人可以收藏多篇文章
    @ManyToMany(() => Article, article => article.collects)
    collects: Article[]
}

这里我就以文章为主(多对多不分主次表,但使用的位置不同生成的数据库默认名称不同哈),在里面使用 @JoinTable()建立关联表格

//文章article表
@Entity()
export class Article {
    ...
    //假设需要显示收藏数很频繁,但收藏操作很不频繁,可以额外维护一个收藏数count
    @Column({ default: 0 })
    collectCount: number
    //一篇文章会被多个人收藏,一个人可以收藏多篇文章
    @ManyToMany(() => User, user => user.collects)
    @JoinTable() //多对多,会自动生成两个{nameId} + 主键的新表,表名:当前表名_当前键名_关联表名 例如:article_collects_user
    collects: User[]
}

这里在 articlecollects 上创建的关联 user 的关系表,因此系统给我们的关系表默认起的名字也很中规中矩,article_collects_user,参数分别为 articleIduserId

使用 @JoinTable()时, 我们一般无需自定义名称,毕竟本来就很清晰了(如果我们真的有强迫症,需要改名字改表格,外键也要驼峰式,与@JoinColumn()类似),如下所示

@JoinTable({
    name: 'article_collects_user', //表名,前后表名,后面键名,方便某时候直接快速查询关系表
    joinColumns: { name: 'article_id' }, //本表外键,默认是驼峰式
    inverseJoinColumns: { name: 'user_id' }, //另一个表的外键
})

看看我们新建立的关系表吧

image.png

ps:JoinTable虽然是最后出现,但一定是多对多才使用的么,可以思考一下

ps2: 创建关联的主要手段就是设置外键,将一个表中设置的外键指向另外一个表中的主键,那么这两个表就关联上了,也就是只需要赋值外键即可,或者在 typeorm 中,使用关联赋值整个 entity 对象

常用增删改查

我们的增删改查都是用的 typeorm,因此要参考他的文档进行学习和使用,具体的我们可以在使用时点进方法,相信能看到我们想要的

常用的增删改查,我们就介绍 nestjs 给的 Repository 方法直接进行操作

本篇后面也会介绍,带有部分数据库查询语句的 QueryBuilder, 对复杂操作对部分基础查询和查询进行优化

image.png 官网中文网

service编写前的配置

前面讲过,我们的逻辑基本上都是在 service 中编写的,因此 service 中也要进行基本配置,需要哪个模块引入那个模块即可

//service
@Injectable()
export class AuthService {
    constructor(
        //使用我们的的 userRepository 仓库,按照下面方式改成自己的类即可,需要哪个引用哪个即可
        @InjectRepository(User)
        private userRepository: Repository<User>,
        @InjectRepository(Auth)
        private authRepository: Repository<Auth>,
        @InjectRepository(User)
        private featureRepository: Repository<Feature>,
        @InjectRepository(Article)
        private articleRepository: Repository<Article>,
    ) {}
}

另外我们使用时,需要引入其他模块的的数据库 EntityService(我的service一开始也没多引用,好像也没事哈,但是数据不引用就会出问题,最好都引用了)

//module
@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    TypeOrmModule.forFeature([Auth]),
    TypeOrmModule.forFeature([Article]),
  ],
  controllers: [UserController],
  providers: [
    UserService, 
    AuthService,
    ArticleService,
  ],
})

增改

增加和改动我们都是直接使用的 saveinsert、更新也可以使用 updatesave 最方便实用

ps:需要注意的是,新增、保存的时候会自动调用查询,不然参数中的 id 和 保存的信息是获取不到的,同时也可能会引出查询的错误,定位错误的时候需要注意

//增加
const auth = new Auth();
auth.password = loginInfo.password;
//或者根据body参数创建,不需要手动赋值了
const auth = this.authRepository.create(body);
...
await this.authRepository.save(auth);
await this.authRepository.insert(auth);

//增加多条
await this.authRepository.save([auth]);
await this.authRepository.insert([auth]);

//设置头像关联
const file = new File()
...
//直接设置外键即可关联
auth.head_id = file.id
//也可以通过对象对应关系,直接传递对象,操作简单,适合单次直接设置关系的操作
//这样的操作有个优势,当一次创建主表和关联表时,只需要设置好关系,保存时只需要按(默认表、外键表)顺序save即可,不需要取出id设置外键
auth.head = file

更新和增加不太一样,一般需要先查询,后更改,方便返回数据,其中 update 是不会返回我们的新数据的,另外手动赋值最累,效果有时候最理想

因为save保存会自动额外调用查询,所以 update 单纯从效率上来说要高于 save一些,可以看情况使用

因此如果想直接更新,不需要返回内容,可以直接使用 update + 查询条件 + 内容 即可,还能提升效率

async updateUser(
        userInfo: UserUpdateDto,
        user: User,
    ) {
    // update更新(推荐),传递的参数,不传递的不影响,不返回那条新user
    // 需要注意的是,如果参数不正确,会报错,id不正确,不会报错,有些如果传递有误,可能会丢关系或者报错,怕错手动选择更新条件即可,此外还有优点是不需要额外查询,不严谨时减少开销
    // await this.userRepository.update({
    //     id: userId, //查询条件
    // }, userInfo)
    // await tuserRepository.update(userId, userInfo)//也可以默认传递id参数
    
    
    //如果再原有对象更新的话,需要先查询
    user = await this.userRepository.findOneBy({
        id: user.id
    })
    if (!user) {
        // 可以抛出一个异常告诉没找到,一般直接返回
        // throw new HttpException('该用户不存在', 204)
        return ResponseData.fail('该用户不存在')
    }
    
    // 下面两种需要注意的是存在关联时不建议下面的 save 模式,参数如果有误,可能会丢关系或者报错
    // 可以直接save,简单粗暴,需要注意新数据的筛选,避免覆盖不该覆盖的
    //  await this.userRepository.save({
    //     ...user,
    //     ...userInfo
    //  })

    // 手动赋值,最累,处理复杂情况时使用,可以配合save或者update使用
    if (userInfo.age) user.age = userInfo.age;
    if (userInfo.mobile) user.mobile = userInfo.mobile;
    if (userInfo.nickname) user.nickname = userInfo.nickname;
    if (userInfo.sex) user.sex = userInfo.sex;
    await this.userRepository.save(user)
    return user
}

假如需要统一格式,赋予空时,可能传递为空字符串或者 null,如果想统一数据库格式,无论是手动赋值,还是update,那么最好手动处理一下数据,再更新

ps

删除

delete 直接根据某个参数删除, remove 根据 entity 删除,一般直接用 delete

//直接通过某个字段真删除,需要谨慎,一般重要数据软删除的比较多,不重要的能删则删,不然会降低性能
await this.userRepository.delete({
    account: loginInfo.account
})

查询(条件、排序、分页)

查询是用的最多的,也是最杂的,findOneByfindOnefindByfindfindAndCountByfindAndCount这些也是用的最多的

by 系列的都是根据 entity 字段直接查询的,因此不具备条件、关系查询

findOne系列查询一个,返回对象

find系列查询多个,返回数组

findAndCount系列查询多个的同时,统计总数量,结果返回二维数组,第一个参数为查询数组内容,第二个为统计总数量

by 系列查询

//by系列查询
let user = await this.userRepository.findOneBy({
    account: loginInfo.account,
});

非 by 系列条件关系查询,whererelationsorder(包含主次关系排序)

let user = await this.userRepository.findOne({
    //默认选项都为 null 或 undefined时,搜索条件为没条件
    //为了避免选填内容,可以加上 Equal(name),如果需要查询为 null 的,用 IsNull()即可
    where: {
        account: "admin", //设置条件相符
    },
    relations: {
        auth: true, //设置了之后,会自动连接查询,我们设置的外键那整对象都会被赋值了
    },
    order: {
        createTime: 'DESC', //我们按照时间降序排列,升序 ASC,大小写没区别都支持
    },
    //如果需要主次排序,那么可以传递对象,更复杂的需要用到builder查询了
    // order: {
    //     //我们按照时间降序排列,次级更新时间升序排列
    //     createTime: {
    //         direction: 'DESC',
    //         nulls: 'FIRST'
    //     },
    //     updateTime: {
    //         direction: 'ASC',
    //         nulls: 'LAST'
    //     }
    // },
    //relations: ['auth'], //也可以通过这种方式
});

分页查询 skiptake

let articles = await this.articleRepository.find({
    skip: 1, //自己计算页码和数量,这个是偏移量,第一页默认就是0,第二页默认就是 1 * 10
    take: 10, //页码 10
})

或查询,即:多个 where 查询条件取或

let user = await this.userRepository.findOne({
    where: [{
        account: 'admin', //同时查询用户名为 admin 或 年龄为 20 的用户
    },{
        age: 20
    }]
});

关联表条件查询(对关系表中的数据筛选),直接嵌套一层关联对象条件即可

let user = await this.userRepository.findOne({
    where: {
        account: "admin", //设置条件相符
        auth: {
            password: '123456', //对关联表进行查询
        },
    },
    relations: {
        auth: true, //设置了之后,会自动连接查询,我们设置的外键那整对象都会被赋值了
    },
});

统计

count:统计有多少条,支持分页

countBy:和count一样但直接查询,不支持排序等

sum:单列求和

average:单列求平均值

minimum:单列求最小值

maximum:单列求最大值

没有看到分组等功能,这个只能自己统计了

QueryBuilder 增删改查

中文网官网

如果是日常的查询等功能,上面的就足够用了,对于一些比较比较复杂的查询,通过 QueryBuilder 可以简化代码,或者减少查询操作提高效率

插入

用的不多,可代替性很强,下面演示一下批量插入

await this.userRepository.
    .createQueryBuilder()  
    .insert()  
    .into(User)  
    .values([{ account: "admin" }, { account: "admin1" }])  
    .execute();

更新

直接更新某条数据,可代替性很强

await this.userRepository.
    .createQueryBuilder()  
    .update(User)  
    .set({ account: "admin" })  
    .where("id = :id", { id: 1 })  
    .execute();

删除

需要注意的是 from 和 where,别一不小心把全部的都删了哈

await this.userRepository. 
    .createQueryBuilder()  
    .delete()  
    .from(User)
    .where("id = :id", { id: 1 })  
    .execute();

查询(重点)

先直接上一个普通的 where、relations 查询,也就是 where、leftjoin,我们的 where 语句可以使用 AND 也可以直接 andWhere(或是 OR、orWhere)

//查询id为某一个的
await this.userRepository. 
    .createQueryBuilder()
    .where("id = :id", { id: 1 })  
    .getOne();
    //.getMany();

getRawOne、getRawMany

有时会用到 select 相关功能,此时使用 getOne、getMany就不行了,需要使用getRawOne、getRawMany才能获取到内容

//统计所有人年龄之和
//其用到了entity之外的字段,因此需要 getRawOne
await this.userRepository. 
    .createQueryBuilder('user')
    .select("SUM(user.age)", "sum")
    .getRawOne();
    //.getRawMany();

queryBuilder where 数据转义

默认查询参数替换只需要 :[name],后面参数给出实际值 { [name]: [value] },如下所示

//一般这么写
xxx.where('article.id=:id AND article.isDelete=:isDelete', {
    id: body.id,
    isDelete: false,
})
//也可以下面这样
.where("article.id=:id")  
.setParameter("id", "body.id")

一般参数都好说,对于 in 来说需要传递一个数组,使用:...[name],实际值{ [name]: [value1, value2, ...] },可以如下方式传递

.where("user.name in (:...names)", { 
    names: [ "Timber", "Cristal", "Lina" ] 
})

简单关联查询案例对比

先上一个默认的关联查询

await this.articleRepository.findOne({
    where: {
      id: body.id,
      isDelete: false,
    },
    relations: {
      user: true,
    },
})

翻一下尝试一下,发现上面的更加清晰一些

let article = await this.articleRepository
  .createQueryBuilder('article')
   //连接对象映射,第一个为映射到赋值的属性名,第二个为其重命名,方便条件查询
  .leftJoinAndSelect('article.user', 'user')
  .where('article.id=:id AND article.isDelete=:isDelete', {
    id: body.id,
    isDelete: false,
  })
  .getOne()
  
  //也可以这样直接key-value的形式,要是或就是数组了.where([{id: body.id}])
  //需要注意涉及到没有数据 is null 的跟上面一样,直接字符串就行了,案例就不这么写了
  //这个条件为 null 时不会省略该语句(也不需要Equal(body.id))
  .where({
    id: body.id,
    isDelete: false,
  })

中等关联条件查询案例对比

我们有这么一个需求,做一个收藏功能,需要判断某个用户是否收藏了某篇文章,再多一个表连接和查询条件,复杂了一点,发现也没问题,这次嵌套了关联查询条件

let article = await this.articleRepository.findOne({
    where: {
      id: body.id,
      isDelete: false,
      collects: {
        id: user.id
      }
    },
    relations: {
      collects: true,
      user: true,
    },
  })
if (!article) {
  return '该文章不存在'
}
//查看是否收藏(这样做是否感觉性能稍低?)
let isCollect = false
if (article.collects?.length > 0) {
  isCollect = true
}
article.collects = undefined;//不给用户返回这个
//可以查一下文章是否被收藏
return {
  ...article,
  isCollect
};

完美翻译,发现还是上面的更清晰,我们多添加了一列,发现还是很ok的,只是看着代码多了

let article = await this.articleRepository
  .createQueryBuilder('article')
  .leftJoinAndSelect('article.user', 'user')
  .leftJoinAndSelect('article.collects', 'collect')
  // .innerJoinAndSelect('article.collects', 'user')//这个一旦找不到主体也不返回,不适合
  .where('article.id=:id AND article.isDelete=:isDelete', {
    id: body.id,
    isDelete: false,
  })
  .andWhere('collect.id=:userId', {
    userId: user.id
  })
  .getOne()
if (!article) {
  return '该文章不存在'
}
let isCollect = false
if (article.collects.length > 0) {
  isCollect = true;
}
article.collects = undefined;
return {
  ...article,
  isCollect
};

复杂关联且或混合查询案例对比

在看看下面即有且又有或的例子,表现就不是那么优秀了,QueryBuilder灵活性获得了大满贯

我们有这么一个需求,查询一篇文章,支持 id、status、内容三者联合查询取交集,但内容需要根据 title、desc、content模糊查询;搜用户昵称时,支持模糊查询,满足即可,与前面是或的关系

我们使用常用的查询查询一下,发现逻辑很复杂

let articles = await this.articleRepository.findAndCount({
  //中括号是或的关系
  where: [
    {
      //大括号内,同级是且,单个或使用In,也可使用其他查询
      //判断是否相符,如果不存在,就会自动忽略该条件了,由于模糊查询非常优秀
      //如果非要插 null 的,那么需要使用 Equal() 包装了
      id: body.id,
      title: body.name && Like(`%${body.name}%`), //Ilike忽略大小写,like不忽略大小写
      status: body.status && In(body.status), //这里面是或的关系
    },
    {
      id: body.id,
      desc: body.name && Like(`%${body.name}%`), //Ilike忽略大小写,like不忽略大小写
      status: body.status && In(body.status), //这里面是或的关系
    },
    {
      id: body.id,
      content: body.name && Like(`%${body.name}%`), //Ilike忽略大小写,like不忽略大小写
      status: body.status && In(body.status), //这里面是或的关系
    },
    { //查询用户和其他选项是或的关系
      user: {
        //忽略大小写,模糊查询,对于一些不支持ILike的,那么LIKE和ILIKE一样都是不区分大小写
        nickname: ILike(`%${body.nickname}%`),
      }
    }
  ],
  relations: {
    user: true
  },
});

使用 QueryBuilder 改进,发现代码简洁了很多(需要注意的是可选参数的条件,没有时不应当存在)

let articles = await this.articleRepository
  .createQueryBuilder('article')
  .leftJoinAndSelect('article.user', 'user')
  .where('article.id=:id AND article.status=:status', {
    id: body.id,
    status: body.status,
  })
  .andWhere('article.title=:name OR article.desc=:name OR article.content=:name', {
    name: body.name
  })
  .orWhere('user.nickname LIKE :nickname', {
    nickname: `%${body.nickname}%`, //注意一些数据库不支持 ILIKE,谨慎使用
  })
  .getManyAndCount();

多级排序

前面介绍了基础的排序只支持两级(first、last),实际上可能有三级甚至更多,这是就要用到 builder 来进行多级排序了,如下所示就可以实现多级排序了(但如果出现了按照指定类型优先级排序问题,为了避免假分页的问题,可以将类型按照实际排序优先级设计大小,这样就可以更方便实现了),数据库也有更加详细的条件等排序可以搜索查看,这里 写的挺不错

let articles = await this.articleRepository
      .createQueryBuilder('article')
      .orderBy('article.status', 'DESC')
      .addOrderBy('article.updateTime', 'DESC')
      .addOrderBy('article.createTIme', 'ASC')

中文排序 CONVERT

有时候会按照中文排序,直接使用 order by 会发现,排序看起来毫无章法(可能是按照编码排序,因此和拼音没关系),因此需要用到专门的参数 CONVERT 来排序

let articles = await this.articleRepository
      .createQueryBuilder('article')
      .orderBy('CONVERT(article.name USING gbk)', 'ASC')

子查询

有时候查询需要根据其实际内容进行筛选的,有时就会用到子查询,且大多数子查询是可以转化为其他形式的表连接,可以根据需要转化(有时感觉也不是那么必要)

EXISTS、NOT EXISTS可以作为判断条件

//用子查询
const articles = await this.articleRepository
    .createQueryBuilder('article')
    .where(
        'EXISTS (select * from user where user.id=article.userId AND user.age is not null AND user.age>40)',
    )
  .getMany();
    
//直接转为连接查询
const articles = await this.articleRepository
    .createQueryBuilder('article')
    .leftJoin('article.user', 'user')
    .where(
        'user.age is not null AND user.age>40',
    )
    .getMany();
        
const articles = await this.articleRepository.find({
    where: {
        user: {
            age: And(Not(IsNull()), MoreThan(40)),
        },
    },
});

进阶查询

这里只介绍一部分,如果缺少,可以参考数据库的一些,另外可以使用一个,点进去看看支持哪些即可

Equal:是否相等,我们默认的筛选虽然也可以,但是无法查询为 null 的参数(如果参数为 null 那么默认查询的对比,其会自动踢掉,会认为没有该筛选条件,即:如果全为 null,则返回数据库第一条数据或者全部数据),因此要上 Equal,数据库没有 =null操作,如果需要查询 null 的,那么需要使用 is null 查询语句(上 query builder吧,个人测试 Equal 不行)

let account = ...
let user = await this.userRepository.findOne({
    where: {
        account: Equal(account) //下面的也都是用到这里
    }
});

NotNot("admin") !=

  • 注意:Not 不是 == 的对立关系,== 对立关系为 != 和 is null,即 == 对立关系为 Not(...) 和 IsNull(),另外似乎没有 IsNotNull 这个东西,但是可以嵌套 Not(IsNull())

AndAnd(条件A, 条件B),表示的关系,需要全部全部条件,即:条件A and 条件BAnd(Not("admin"), Not("admin1"))

OrOr(条件A, 条件B) ,表示的关系,满足一个条件即可,即:条件A or 条件BOr(Not("admin"), Not("admin1"))

LessThanLessThan(10) <

LessThanOrEqualLessThan(10) >=

MoreThanLessThan(10) >

MoreThanOrEqualLessThan(10) >=

LikeLike("%mm_dd%") like "%mm_dd"

  • 模糊查询 LIKE,区分大小写,%为匹配多个,_匹配一个

ILikeILike("%mm_dd%") ilike "%mm_dd"

  • 模糊查询 ILIKE ,不区分大小写,%为匹配多个,_匹配一个

BetweenBetween(1, 10) between 1 and 10

  • 介于 1~10 之间 BETWEEN...AND [1, 10],后面一般是开区间,mysql是两端闭区间

InIn([1, 2, 3]) in(1, 2, 3)

  • 为包含关系 IN,包含一个符合条件

IsNull or NotIsNull() (is null、否定的 is not null,这两个不是单纯的取反关系)

  • 相当于Equal(null),但是Equal(null)是查询不出来的,查询为空时的操作 is null(数据库中没有=NULL的操作,is not null查询不为空的

RawRaw("age+10=maxAge")

  • 使用数据库语句作为判断,但参数不能使用用户输入,避免注入攻击

distinct(DISTINCT) 有不同的意思,意思就是可以让某些字段去重,只能使用 QueryBuilderSelect 来处理

  • queryBuilder.select("distinct name"),就是通过select查询时,让 name 字段不重复

注意:很多数据库不区分大小写(mysql也是),且 mysql 中 LIKE、ILIKE 没有区别的,因此 LIKE 和 ILike 查出来的结果一样都是不区分大小写,且可能因为 ILIKE 关键字报错

查询进阶(补充)

上面有提到查询进阶的内容,那是 typeorm 默认给定的,且发现非quilderBuilderGROUP BY 分组 功能、HAVING 句尾连接 都没有

这两个在基础封装没有,但是 QueryBuilder 中却是有的,不信尝试一下

GROUP BY: 分组统计,结合统计函数,对一列或者多列对结果集进行分组统计

  • SELECT 列名A,统计函数(列名B)
  • FROM 表
  • WHERE ..
  • GROUP BY 列名A
  • 假设 A 为 名字,B为去过的地址,统计函数为Count,则可以统计某个人去到的地址数量

HAVING:前面的查询是默认可以使用 where 的,但是对于使用分组统计后的结果,却没有筛选条件,此时可以使用 HAVING 来对统计后的结果进行追加筛选,HAVING

  • SELECT 列名A,统计函数(列名B)
  • FROM 表
  • WHERE ..
  • GROUP BY 列名A
  • HAVING 统计函数(列名B) 查询条件
    • 假设 A 为 名字,B为去过的地址,统计函数为Count,查询条件为 > 10, 则可以统计某个人去到的地址数量超过10个的

关联表更新(重点)

你以为上面就结束了么,关联表更新的操作才是重中之重,这个除了优化代码,还是能优化效率

我们又有了一个简单的需求,文章需要支持收藏功能,根据传递的参数决定是否收藏

//我们发现无法直接更新,我们无法直接查询关联表(建立映射直接报错)
//并且还需要先查询文章的收藏者,我们需要判断自身的索引,然后添加删除根据这个来进行跟新
//倒是可以无需判断索引增加,利用抛出错误来解决,可是删除呢?没办法
let article = await this.articleRepository.findOne({
  where: {
    id: body.id,
    isDelete: false,
  },
  relations: {
    collects: true,
  }
})
if (!article) {
  return ResponseData.fail('该文章不存在');
}
//查询收藏者
let userId = user.id
let index = article.collects.findIndex(e => e.id == userId)
let isCollect = index >= 0

//既然都对比了,我们可以减少无效操作
if (body.is_collect && !isCollect) {
  //尚未收藏、进行收藏
  article.collects.push(user);
  // article.collects.push({id: user.id} as User);//如果用的不是user,是userid的话,转化一下,关系实际只也用到了主键id
} else if (!body.is_collect && isCollect) {
  //已收藏、取消收藏
  article.collects.splice(index, 1)
}
await this.articleRepository.save(article)
return ResponseData.ok();

使用 QueryBuilder 改进,发现逻辑清晰了很多

//我们创建builder,后面在动态天添加、删除
let builder = this.articleRepository
  .createQueryBuilder()
  .relation(Article, 'collects')
  .of(article) //我们的文章实体和user多对多,我们给collects添加新的user关联
if (body.is_collect == 1) {
  try {
    //直接收藏,添加已存在的会报错,errno: 1062
    await builder.add(user)
  } catch(err) {
    if (err.errno === 1062) {
      //重复,也就是已经收藏
      return 'ok'
    }else {
      //其他的错误还没碰到,做了就做完善点
      let msg = err.msg
      return err.msg
    }
  }
}else {
  //删除不存在的不会报错
  await builder.remove(user)
}

上面就是根据QueryBuilder 对关联表操作的优势,我们直接看看怎么用吧,其他的基本用不到了

this.articleRepository
  .createQueryBuilder()
  .relation(Article, 'collects')
  .of(article) //我们的文章实体和user多对多,我们给collects添加新的user关联
  // 多对多,一对多的情况使用 add、remove
  // .add(user) //添加关联
  // .remove(user) //移除关联
  // 一对一、多对一的情况使用 set
  // .set(user) //只有一个,即添加、更新关联
  // .set(null) //删除该关联
  // .loadOne(); //需要加载的话,可以直接加载一个到多个关联对象

时间区间查询

前面提到过between在mysql的问题,如果是开区间的,可以直接使用 querybuilder + 运算符来判断,能避免时间判断问题

let day = dayjs(datetime)
console.log(day.toDate(), day.add(1, 'day').toDate())
// let articles = await this.articleRepository.findAndCount({
//   where: {
//     updateTime: Between(day.toDate(), day.add(1, 'day').toDate())
//   },
//   order: {
//     updateTime: 'DESC'
//   }
// })
let articles = await this.articleRepository.createQueryBuilder('user')
  .where('updateTime>=:start AND updateTime<:end', {
    start: day.toDate(),
    end: day.add(1, 'day').toDate()
  })
  .orderBy('user.updateTime', 'DESC')
  .getManyAndCount()

数据库表操作

对于线上的功能,我们一般不会直接开启同步功能,因此会使用一些常用的表操作,然后更新 entity 表,以减少损失的可能性,下面介绍一些常见的表操作

基础查询表内数据

//查询user表符合条件的十条数据
SELECT * FROM user
//where [条件]
LIMIT 10 

更新表内数据

UPDATE user
set column=''
where [条件]

删除表内数据

//这个 where 语句一定要加,不需要的时候也要慎重看看表名,否则可能会把整张表清理了
DELETE FROM user
where [条件]

创建表

CREATE TABLE user(
 id INT PRIMARY KEY AUTO_INCREMENT,
 name VARCHAR(100)
);

删除表(谨慎操作)

DROP TABLE aaaaaa;

添加列

其中 ALTER TABLE 表示修改的表,后面是更改语句

ALTER TABLE user
ADD idcard VARCHAR(18);

修改列类型

ALTER TABLE user
MODIFY name VARCHAR(200);

ps:这个里面删除 uni 等约束直接改完走同步即可,不必使用操作语句,添加约束涉及到内容不满足的会失败,可以走迁移逻辑😂

更改列名

ALTER TABLE user
RENAME COLUMN idcard to mobile;

删除列(谨慎操作)

ALTER TABLE user
DROP COLUMN mobile;

DML、DDL、DCL

数据库中我们可能会经常听到 DML、DDL、DCL 中相关的词,实际其意思也很简单

DML:DML(data manipulation language)数据操纵语言,像前面对表内数据增删改查的又被称为

DDL:DDL(data definition language)是数据定义语言 例如后面的对整个表格的 create、alter、drop 等操作等

DCL:DCL(data definition language)是数据库控制语言:是用来设置或更改数据库用户或角色权限的语句(本文并没有接触到)

多人开发与数据表更改注意

平时我们独立开发的时候,一般都是 mysqlsynchronize: true,并且 yarn start:dev,这样个人开发是很舒服的

但团队开发时尽量不要这样,这个 synchronize 会直接将我们更改的 entity 更新同步到数据库,配合yarn start:dev的及时更新,对于多人开发来说可能就是一个灾难(主要针对于对列的增改)

例如:

  • 两个人同时连接一个远端数据库,一个人增加了entity 和数据库字段,另一个人还没来得及拉取,那么会出现没有拉取的人会把拉取的人的数据库字段更改同步没了(他那里并没有新增字段,因此可能会导致新增的一列数据全没了),甚至出现报错
  • 对于删除影响不大,最多报个错,多个字段
  • 而对于修改字段类型、修改名称,这一个简直是一个灾难片,系统无法进行精准判断,会直接删除该列,然后创建新的一列,这一操作会直接导致原有数据列直接清空,并且多人开发过程中,即使这边进行了正确操作,另外一边没有及时拉取,那边如果开启同步运行的话,会出现修改会原字段的情况

那么我们该如何操作呢

有人说直接使用命令通过数据库,将数据库字段映射成entity,不开启synchronize,但仍然会出现多人开发时出现的问题,会把自己新增或者修改的字段直接全部覆盖掉,因此仍然存在隐性问题,并且会降低开发速度,很不舒适

那怎么解决最好呢?

解决方案一

个人感觉,多人开发过程中,synchronize 可以关闭(synchronize:false),然后每次想同步的时候,拉去一下最新代码(别人更新的代码),然后直接临时将 synchronize 开启(synchronize:true),然后yarn start运行,运行完之后会自动同步,同步后关了 synchronize,再重新运行即可

解决方案二

在我们的 src 中建立下面文件,就起名叫 typeorm-config.ts 吧,缺点是需要手动导入 entities

export const TypeormConfig: TypeOrmModuleOptions = {
    type: 'mysql',
    host: ...,
    port: ...,
    username: ...,
    password: ...,
    database: ...,
    autoLoadEntities: true, //自动查找entity实体,手动的需要手动导入,自动的适合设置 
    synchronize:false, //手动记得设置为false
    entities: [User, Auth], //这个需要手动导入,不然不会同步该数据库表格
};
//这个是给脚本用的,脚本会根据这个参数来更新一次
export const AppDataSource: any = new DataSource(TypeormConfig as DataSourceOptions);

import 导入该 config,即可以手动 synchronzie 也可以执行脚本命令手动运行

imports: [
    TypeOrmModule.forRoot(TypeormConfig),
]

然后增加脚本,每次想同步时,先拉去git代码更新,然后执行下面命令手动更新即可(yarn sync),需要注意的是 entity 引入外键的路径,要使用相对路径(后面简化的就不必)

"scripts": {
    "db:sync": "typeorm-ts-node-commonjs -d ./src/typeorm-config.ts schema:sync"
},

简化 entities 引入

上面那个发现每次都要自己引入 entities,很麻烦,我们可以直接通过设置目录的方式来引入,不仅如此,这里还无需使用相对路径,只要编译不报错就没事

export const TypeormConfig: TypeOrmModuleOptions = {
    type: 'mysql',
    host: ...,
    port: ...,
    username: ...,
    password: ...,
    database: ...,
    autoLoadEntities: true, //自动查找entity实体,手动的需要手动导入,自动的适合设置 
    synchronize:false, //手动记得设置为false
    //为什么是这个目录,我呢的 typeorm-config.ts 在src 目录下第一级
    //为什么是js,具体不太了解,ts不行,因此那就是编译后的 js 文件了,其目录就是如此,官方案例基本也是js
    //因此同步之前需要build一下
    entities: ['./**/entities/*.entity.js'],
};
//这个是给脚本用的,脚本会根据这个参数来更新一次
export const AppDataSource: any = new DataSource(TypeormConfig as DataSourceOptions);


//设置脚本,由于需要先build,所以可以直接命令设置到一起
"scripts": {
    ...
    //"db:sync": "typeorm-ts-node-commonjs -d ./src/typeorm-config.ts schema:sync",
    "db:sync": "nest build && typeorm-ts-node-commonjs -d ./src/typeorm-config.ts schema:sync"
},

存在上线应用的操作

当需要部署应用到线上时,并且我们的线上应用已经存在数据库的情况,我们需要对比使用命令手动操作数据库(一般对于修改列名的操作比较谨慎,因此记录一下修改哪里了,记录下来),即可,即:对于修改列名,只需要先手动修改数据库列名,使其和entity一致就行了,对于新增和删除的的这个不会有问题,直接手动 synchronize 就没问题了(方案一、二都没问题)

此外,对于存在线上数据库,还有另外一个操作对于我们来说相对比较安全,基本上不存在上述的同步问题,那就是数据迁移,当我们进行后续开发时,更改元数据表时,直接新增一个 entity,名字稍微改的不一样,例如:user -> user1,后面可以理解为版本号,然后全改成使用 新 entity 就行了,这样对于线上的表格就不会有任何影响了,然后需要做的数据迁移也很简单,我们直接写一个数据迁移接口(脚本),手动取出原数据库中所有数据,然后映射保存到最新的数据表即可(内存不够大就分页迁移,假如一次迁移10w条),此时,如果还想用之前的用户表名,直接再反向操作一次即可

最后,为了谨慎,部署前我们最好备份我们的数据库,避免出现其他意外情况,必要时可以恢复

对此你还有什么更好的方案呢,欢迎讨论

课后习题

如果有一个功能, 一个用户可以上传多个图片信息,一个订单可以传递多个图片信息,一个表格同时多对一两个表格,这样的你怎样设计呢?(个人提示:关联表),你有什么更好的思路么?

最后

这篇文章东西还挺多的,但是如果有了点数据库基础,那么就真的不多了,甚至这篇文章可以收藏当做一个问题参考点,看了这些基本上复杂一些的操作也可以做了

最后,祝大家学习愉快!