likes
comments
collection
share

TypeORM中更新数据库的坑

作者站长头像
站长
· 阅读数 11

1 背景介绍

TypeORM 是非常流行的ORM库,是我们使用数据库的利器。里边的每一个实体对应数据库中一个表,每一个实体的字段对应数据库表中的字段,通过提供的各种装饰器可以随意的通过代码去操作数据库的更改,但这种灵活性也隐藏了巨大的风险。

在我们的项目不断迭代的过程中,一定不可避免的需要增加表 修改字段 增加字段等等操作,如果没有深入研究过TypeORM的使用方式,可能导致数据库中的数据丢失,造成不可挽回的损失。

2 synchronize是个雷

使用TypeORM中的关键配置就是DataSourceOptions,用来指定连接的数据库及一些操作的选项,常用选项如下:

其中的synchronize 表示数据库的结构是否和代码保持同步,官方文档说要小心这个选项,不要用在生产环境,除非你的数据是可以丢失的,但是我建议开发的时候也不要开启,下面来看一下在各种代码操作下的开启synchronize不符合预期的表现。

我们以一个User实体做实验

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

  @Column()
  name: string
}

插入数据:

const dataSource = new DataSource(options)
dataSource.initialize().then(
    async (dataSource) => {
        const user = new User()
        user.name = "Joe Smith"
        await dataSource.manager.save(user)
    },
    (error) => console.log("Cannot connect: ", error),
)

查看数据库, 自动创建了user表和对应代码中的id和name column: TypeORM中更新数据库的坑

表中内容:

TypeORM中更新数据库的坑

  • 1 修改列名 把刚才的name字段改成title
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  title: string
}

同步之后的数据库把刚才name换成了title,但是旧的数据已经不存在了。

TypeORM中更新数据库的坑

这到底发生了什么? 正常来说改个字段名称数据应该能保留啊,来看下执行的sql日志。

query: ALTER TABLE `user` CHANGE `name` `title` varchar(255) NOT NULL
query: ALTER TABLE `user` DROP COLUMN `title`
query: ALTER TABLE `user` ADD `title` varchar(255) NOT NULL

好吧 问题就出现这里,如果只是执行第一句的话是没问题的,但是它还把列删除了再添加,所以会导致列中的数据丢失。

  • 2 修改列属性 测试一下如果只是改列的属性, 把name字段的长度设置为64。
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column({length: 64})
  name: string
}

数据库中同样清空了数据:

TypeORM中更新数据库的坑

执行的SQL如下, 进行了删除列然后添加列。

query: ALTER TABLE `user` DROP COLUMN `name`
query: ALTER TABLE `user` ADD `name` varchar(64) NOT NULL

正常我们去改一个表的属性时 只需要修改这一列即可,能够保存原来的数据,而不会先删除列再添加列。

ALTER TABLE `user` modify column name varchar(64) NOT NULL;

以上例子说明 只要改列的名称或者属性都会导致该列的被删除重新创建,所以该列的数据也会丢失,更不要说删除了一列的代码。如果在开发过程中你是开启的watch模式,代码每次改动都会重新执行同步,这样危险就更大了。

3 synchronize原理分析

下面我们通过源码探究一下开启synchronize内部执行的操作。

DataSource.tsinitialize()

// if option is set - automatically synchronize a schema
if (this.options.synchronize) await this.synchronize()

调用schemaBuilder.build()

async synchronize(dropBeforeSync: boolean = false): Promise<void> {
    ......
    const schemaBuilder = this.driver.createSchemaBuilder()
    await schemaBuilder.build()
}

RdbmsSchemaBuilder.tsbuild最关键的调用

await this.executeSchemaSyncOperationsInProperOrder()

最后终于找到了最核心的方法.

/**
 * Executes schema sync operations in a proper order.
 * Order of operations matter here.
 */
protected async executeSchemaSyncOperationsInProperOrder(): Promise<void> {
    await this.dropOldViews()
    await this.dropOldForeignKeys()
    await this.dropOldIndices()
    await this.dropOldChecks()
    await this.dropOldExclusions()
    await this.dropCompositeUniqueConstraints()
    // await this.renameTables();
    await this.renameColumns()
    await this.createNewTables()
    await this.dropRemovedColumns()
    await this.addNewColumns()
    await this.updatePrimaryKeys()
    await this.updateExistColumns()
    await this.createNewIndices()
    await this.createNewChecks()
    await this.createNewExclusions()
    await this.createCompositeUniqueConstraints()
    await this.createForeignKeys()
    await this.createViews()
}

我们改列长度会进入updateExistColumns(), 首先是找到改变的列,产生新旧两个column, 最后继续调用this.queryRunner.changeColumns

async updateExistColumns(){
    ......
    const changedColumns = this.connection.driver.findChangedColumns(table.columns, metadata.columns);
    // generate a map of new/old columns
    const newAndOldTableColumns = changedColumns.map((changedColumn) => {
        const oldTableColumn = table.columns.find((column) => column.name === changedColumn.databaseName);
        const newTableColumnOptions = TableUtils_1.TableUtils.createTableColumnOptions(changedColumn, this.connection.driver);
        const newTableColumn = new TableColumn_1.TableColumn(newTableColumnOptions);
        return {
            oldColumn: oldTableColumn,
            newColumn: newTableColumn,
        };
    });
    await this.queryRunner.changeColumns(table, newAndOldTableColumns);
}

在最后MysqlQueryRunner.tschangeColumn可以看到 长度不一致就会删除和重新创建列。

if (
    (newColumn.isGenerated !== oldColumn.isGenerated &&
        newColumn.generationStrategy !== "uuid") ||
    oldColumn.type !== newColumn.type ||
    oldColumn.length !== newColumn.length ||
    (oldColumn.generatedType &&
        newColumn.generatedType &&
        oldColumn.generatedType !== newColumn.generatedType) ||
    (!oldColumn.generatedType &&
        newColumn.generatedType === "VIRTUAL") ||
    (oldColumn.generatedType === "VIRTUAL" && !newColumn.generatedType)
) {
    await this.dropColumn(table, oldColumn)
    await this.addColumn(table, newColumn)
} 

所以总结来看就是TypeORM中对于数据库的更新没有做到最优更改。

4 TypeORM的migrations

如果不开启synchronize, TypeORM中还提供了migrations来数据库进行更新。

4.1 如何创建migrations

TypeORM提供了cli帮助我们创建migrations.

  • 创建空的迁移文件
npx typeorm migration:create ./path-to-migrations-dir/PostRefactoring

该命令会帮助你在指定位置创建一个迁移文件, 内容如下:

import { MigrationInterface, QueryRunner } from "typeorm"

export class PostRefactoring1657418446962 implements MigrationInterface {
    public async up(queryRunner: QueryRunner): Promise<void> {
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
    }
}

这个命令只是帮助你创建一个空的文件模版,没有太大用途,需要你自己去写迁移的SQL,其中up方法中是本次迁移的SQL语句,down方法中是对应的回滚的SQL语句。

  • 自动创建迁移语句 我们可以创建一个单独的data-source文件, 导出DataSource对象

然后执行

npx typeorm-ts-node-esm migration:generate ./custom-migrations/update-post-table -d ./data-source.ts

就会自动帮助我们产生更新的SQL语句:

export class updatePostTable1657418884633 implements MigrationInterface {
    name = 'updatePostTable1657418884633'
    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`name\``);
        await queryRunner.query(`ALTER TABLE \`user\` ADD \`name\` varchar(20) NOT NULL`);
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`name\``);
        await queryRunner.query(`ALTER TABLE \`user\` ADD \`name\` varchar(255) NOT NULL`);
    }
}

其中有一个问题是产生的SQL语句和开启synchronize中的一样,不是最优的SQL语句。

执行migration有两种方法,1种是通过cli 另一种是通过API的方式。 首先在DataSource中指定migration相关的两个参数

migrationsTableName: 'custom_migrations_table', // 指定记录migrations的表 如果不设置默认名称是`migrations`.
migrations: [__dirname + "/custom-migrations/*{.js,.ts}"] // 指定migrations文件的位置

使用命令行执行

npx typeorm-ts-node-esm migration:run  -d ./data-source.ts

执行之后可以看到数据库中多了一个custom_migrations_table表,里边记录了历史的迁移文件,这样才能保证不重复执行和回滚。

TypeORM中更新数据库的坑

执行回滚

npx typeorm-ts-node-esm migration:revert  -d ./data-source.ts

用API的方式:

await dataSource.runMigrations() // 执行所有未进行过的迁移
await dataSource.undoLastMigration() // 回滚最新的一次迁移

所以用migration相比于开启synchronize的好处是自己控制更新的,可以自己控制更新的SQL。但是自己生成的SQL仍然不是最优的。

4 先有数据库再用TypeORM

如果我们是先有了数据库,然后才想使用TypeORM,那么可以使用typeorm-model-generator帮助我们生成Entity。

npx typeorm-model-generator -h localhost -d tempdb -u sa -x !Passw0rd -e mssql -o .

5 总结

本文列举了TypeORM中synchronize的坑,从源码中找到了问题的原因。详细讲解了TypeORM中migrations的使用方式,同时推荐了typeorm-model-generator可帮助我们迁移使用TypeORM。