Sequelize-Automate: 自动生成 Sequelize Models

6,135 阅读11分钟

本文的主角是 sequelize-automate

背景

Sequelize 是 Node.js 中常用的 ORM 库,其作用就是对数据库表和代码中的对象做一个映射,让我们能够通过面向对象的方式去查询和操作数据库。

举个例子,数据库可能有一张 user 表,使用 Sequelize 将其映射为一个 UserModel,之后我们就可以通过 UserModel.findAll() 去查询数据库,Sequelize 会将该方法转换为 SQL:select * from user

当我们使用 Sequelize 时,首先要手动定义一个 Model,如:

class UserModel extends Model {}
User.init({
  id: DataTypes.INTEGER,
  name: DataTypes.STRING,
  birthday: DataTypes.DATE
}, { sequelize, modelName: 'userModel' });

然后可以通过 sequelize.sync()UserModel 同步到数据库中。简而言之就是,先在代码中定义 Models,再通过 Models 创建/更新表结构。

但通常我们开发时,是先创建表,然后再写业务代码。而且我们的表结构不能轻易变更,变更表结构可能有单独的流程。所以大部分情况下,我们都是根据表结构手动写 Models,而不能直接使用 sequelize.sync() 去更新表结构。

然而当表非常多的时候,手动写 Models 是一件非常繁琐的事情,并且都是低级的重复性的事情。显然这种事情应该交由工具来做,这个工具就是 sequelize-automate

Sequelize-Automate 简介

sequelize-automate 是一个根据表结构自动创建 models 的工具。主要功能特性如下:

  • 支持 MySQL / PostgreSQL / Sqlite / MariaDB / Microsoft SQL Server 等 Sequelize 支持的所有数据库

  • 支持生成 JavaScript / TypeScript / Egg.js / Midway.js 等不同风格的 Models,并且可扩展

  • 支持主键、外键、自增、字段注释等属性

  • 支持自定义变量命名、文件名风格

以 MySQL 为例,假设表结构如下:

CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary ket',
  `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'user name',
  `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'user email',
  `created_at` datetime NOT NULL COMMENT 'created datetime',
  `updated_at` datetime NOT NULL COMMENT 'updated datetime',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='User table'

则使用 sequelize-automate 可以自动生成的 Model 文件为 models/user.js

const {
  DataTypes
} = require('sequelize');

module.exports = sequelize => {
  const attributes = {
    id: {
      type: DataTypes.INTEGER(11).UNSIGNED,
      allowNull: false,
      defaultValue: null,
      primaryKey: true,
      autoIncrement: true,
      comment: "primary key",
      field: "id"
    },
    name: {
      type: DataTypes.STRING(100),
      allowNull: false,
      defaultValue: null,
      primaryKey: false,
      autoIncrement: false,
      comment: "user name",
      field: "name",
      unique: "uk_name"
    },
    email: {
      type: DataTypes.STRING(255),
      allowNull: false,
      defaultValue: null,
      primaryKey: false,
      autoIncrement: false,
      comment: "user email",
      field: "email"
    },
    created_at: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: null,
      primaryKey: false,
      autoIncrement: false,
      comment: "created datetime",
      field: "created_at"
    },
    updated_at: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: null,
      primaryKey: false,
      autoIncrement: false,
      comment: "updated datetime",
      field: "updated_at"
    }
  };
  const options = {
    tableName: "user",
    comment: "",
    indexes: []
  };
  const UserModel = sequelize.define("user_model", attributes, options);
  return UserModel;
};

这样我们就可以在项目中直接使用了:

const Sequelize = require('sequelize');
const UserModel = require('./models/user');

// Option 1: Passing parameters separately
const sequelize = new Sequelize('database', 'username', 'password', {
  host: 'localhost',
  dialect: /* one of 'mysql' | 'mariadb' | 'postgres' | 'mssql' | 'sqlite' */
});

const userModel = UserModel(sequelize);
const users = await userModel.findAll();

Sequelize-Automate 使用

sequelize-automate 提供了 sequelize-automate 这个命令,可以全局安装也可以只在项目中安装。

全局安装

首先需要安装 sequelize-automate

$ npm install -g sequelize-automate

然后还需要安装使用的数据库对应的依赖包,这点与 sequelize 一致:

# 根据你使用的数据库,从下面的命令中选一个安装即可
$ npm install -g pg pg-hstore # Postgres
$ npm install -g mysql2
$ npm install -g mariadb
$ npm install -g sqlite3
$ npm install -g tedious # Microsoft SQL Server

之所以这样设计,是因为如果我使用的是 MySQL,则我只需要安装 mysql2 (sequelize 使用 mysql2 操作 MySQL 数据库),不需要也没必要安装其他包。

仅在项目中安装

可能你不喜欢全局安装 sequelize-automate,因为还需要全局安装 mysql2 或其他依赖,或者你可能只想在某个项目中使用它,则可以仅在项目中安装使用:

$ cd your_project_dir
$ npm install sequelize-automate --save

然后同样需要安装对应的数据库依赖包:

# 根据你使用的数据库,从下面的命令中选一个安装即可
$ npm install --save pg pg-hstore # Postgres
$ npm install --save mysql2
$ npm install --save mariadb
$ npm install --save sqlite3
$ npm install --save tedious # Microsoft SQL Server

当然,如果你已经在项目中使用了 sequelize,则一定会安装一个对应的数据库依赖包。

安装成功后,你就可以在项目目录中通过 ./node_modules/.bin/sequelize-automate 使用 sequelize-automate 了 。然而我们经常不会这样做。

推荐的做法是,在 package.json 中添加一个 script

 "scripts": {
    "sequelize-automate": "sequelize-automate"
  },

这样就可以通过 npm run sequelize-automate 来间接执行项目中的 sequelize-automate 这个命令。

sequelize-automate 命令详解

sequelize-automate 命令支持的参数主要有:

  • --type, -t 指定 models 代码风格,当前可选值:js ts egg midway
  • --dialect, -e 数据库类型,可选值:mysql sqlite postgres mssql mariadb
  • --host, -h 数据库 host
  • --database, -d 数据库名
  • --user, -u 数据库用户名
  • --password, -p 数据库密码
  • --port, -P 数据库端口,默认:MySQL/MariaDB 3306,Postgres 5432,SSQL: 1433
  • --output, -o 指定输出 models 文件的目录,默认会生成在当前目录下 models 文件夹中
  • --camel, -C models 文件中代码是否使用驼峰发命名,默认 false
  • --emptyDir, -r 是否清空 models 目录(即 -o 指定的目录),如果为 true,则生成 models 之前会清空对应目录,默认 false
  • --config, -c 指定配置文件,可以在一个配置文件中指定命令的参数

更详细的参数介绍可参考文档:sequelize-automate

使用示例

全局命令:

sequelize-automate -t js -h localhost -d test -u root -p root -P 3306  -e mysql -o models

如果在项目中使用的话,则可以将改命令添加到 package.json 中:

"scripts": {
    "sequelize-automate": "sequelize-automate -t js -h localhost -d test -u root -p root -P 3306  -e mysql -o models"
  },

然后通过 tnpm run sequelize-automate 来自动生成 models。

指定配置文件

因为命令的参数较多,所以支持了在 JSON 配置文件中指定参数。

首先需要创建一个配置文件,比如在当前目录下新建名为 sequelize-automate.config.json 的配置文件:

{
  "dbOptions": {
    "database": "test",
    "username": "root",
    "password": "root",
    "dialect": "mysql",
    "host": "localhost",
    "port": 3306,
    "logging": false
  },
  "options": {
    "type": "js",
    "dir": "models"
  }
}

当然也可以使用 JS 文件:

module.exports = {
  dbOptions: {
    database: "test",
    username: "root",
    password: "root",
    dialect: "mysql",
    host: "localhost",
    port: 3306,
    logging: false
  },
  options: {
    type: "js",
    dir: "models"
 }
}

然后就可以通过 sequelize-automate -c sequelize-automate.config.json 来使用。

配置文件中主要有 dbOptionsoptions 两个对象。

dbOptions

dbOptionssequelize 构造函数 的参数完全一致,是数据库相关信息。sequelize-automate 将会以 dbOptions 为参数去创建一个 Sequelize 实例,详见:src/index.js#L43

这里简单列举 dbOptions 的部分属性:

dbOptions: {
  database: 'test',
  username: 'root',
  password: 'root',
  dialect: 'mysql',
  host: '127.0.0.1',
  port: 3306,
  define: {
    underscored: false,
    freezeTableName: false,
    charset: 'utf8mb4',
    timezone: '+00:00',
    dialectOptions: {
      collate: 'utf8_general_ci',
    },
    timestamps: false,
  },
};

通常我们会用到的就是 database username password dialect host port

options

optionssequelize-automate 本身的一些配置。主要有如下属性:

options: {
  type: 'js', // 指定 models 代码风格
  camelCase: false, // Models 文件中代码是否使用驼峰发命名
  fileNameCamelCase: true, // Model 文件名是否使用驼峰法命名,默认文件名会使用表名,如 `user_post.js`;如果为 true,则文件名为 `userPost.js`
  dir: 'models', // 指定输出 models 文件的目录
  typesDir: 'models', // 指定输出 TypeScript 类型定义的文件目录,只有 TypeScript / Midway 等会有类型定义
  emptyDir: false, // 生成 models 之前是否清空 `dir` 以及 `typesDir`
  tables: null, // 指定生成哪些表的 models,如 ['user', 'user_post'];如果为 null,则忽略改属性
  skipTables: null, // 指定跳过哪些表的 models,如 ['user'];如果为 null,则忽略改属性
  tsNoCheck: false, // 是否添加 `@ts-nocheck` 注释到 models 文件中
}

所有参数可以参考源码:src/index.js#L13

这里补充一点,之所以有 tsNoCheck 属性,是因为 Sequelize 的类型定义中,不支持 type: DataTypes.INTEGER(255) 这种写法,只支持 ``type: DataTypes.INTEGER。这样在 TypeScript 中就会报错。所以添加了tsNoCheck属性,如果为true,则会自动在 model 文件头部添加@ts-nocheck`,如:

// @ts-nocheck
import { IApplicationContext, providerWrapper } from 'midway';
import { DataTypes } from 'sequelize';
import { IDB } from './db';
export default async function setupModel(context: IApplicationContext) {
  const db: IDB = await context.getAsync('DB');
  const attributes = {
	 id: {
      type: DataTypes.BIGINT.UNSIGNED,
      allowNull: false,
      defaultValue: null,
      primaryKey: true,
      autoIncrement: true,
      comment: '主键',
      field: 'id',
    },
    name: {
      type: DataTypes.STRING(100),
      allowNull: false,
      defaultValue: null,
      primaryKey: false,
      autoIncrement: false,
      comment: null,
      field: 'name',
    },
  };
  const options = {
    tableName: 'flow',
    comment: '',
    indexs: [],
  };
  return db.sequelize.define('userModel', attributes, options);
}
providerWrapper([{
  id: 'UserModel',
  provider: setupModel,
}]);

API

上面主要讲了 sequelize-automate 的命令行使用方式,sequelize-automate 本身也提供了接口,让使用者自定义开发。主要有两个:

  • automate.getDefinitions 将数据库表转换为 JSON
  • automate.run 生成 models 代码

使用方法如下:

const Automate = require('sequelize-automate');

// dbOptions 和 options 前面已经提到,这里不再赘述
const dbOptions = {
  // ...
};
const options = {}
  // ...
}

// 创建一个 automate 实例
const automate = new Automate(dbOptions, options);

(async function main() {
  // // 获取 Models JSON 定义
  // const definitions = await automate.getDefinitions();
  // console.log(definitions);

  // 或生成代码
  const code = await automate.run();
  console.log(code);
})()

Sequelize-Automate 的实现

sequelize-automate 的实现思路很简单,就是首先从数据库中查询到所有表信息,包括表结构、索引、外键等,然后将表信息转换为一个 JSON 定义,最后使用 AST 根据 JSON 定义去生成代码。

获取表信息

查询表信息依赖了 sequelize 的一些方法,这也是为什么 sequelize-automate 依赖了 sequelize,并且有个参数是 dbOptions。用到的相关 API 主要是:

  • QueryInterface.showAllTables
  • QueryInterface.describeTable
  • QueryInterface.showIndex
  • QueryInterface.getForeignKeyReferencesForTable

很多 API 在 Sequelize 的文档中并没有写出来,我也是看了它的源码才找到。

Sequelize 做的比较好的一点,就是对开发者屏蔽了不同数据库之间的差异,比如所有使用 this.queryInterface.showIndex 的返回值都是一样的格式。当然,有些 API 它并未完全做到,比如 showAllTables,有的数据库返回是 [ 'tableName' ],而有的数据库返回 { tableName, schema },详见 sequelize#11451

查询并聚合后的表信息如下:

{
    "user":{
        "structures":{
            "id":{
                "type":"INT(11) UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":true,
                "autoIncrement":true,
                "comment":"primary ket"
            },
            "name":{
                "type":"VARCHAR(100)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user name"
            },
            "email":{
                "type":"VARCHAR(255)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user email"
            },
            "created_at":{
                "type":"DATETIME",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"created datetime"
            },
            "updated_at":{
                "type":"DATETIME",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"updated datetime"
            }
        },
        "indexes":[
            {
                "primary":true,
                "fields":[
                    {
                        "attribute":"id",
                        "order":"ASC"
                    }
                ],
                "name":"PRIMARY",
                "tableName":"user",
                "unique":true,
                "type":"BTREE"
            },
            {
                "primary":false,
                "fields":[
                    {
                        "attribute":"name",
                        "order":"ASC"
                    }
                ],
                "name":"uk_name",
                "tableName":"user",
                "unique":true,
                "type":"BTREE"
            }
        ],
        "foreignKeys":[

        ]
    },
    "user_post":{
        "structures":{
            "id":{
                "type":"INT(11) UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":true,
                "autoIncrement":true,
                "comment":"primary key"
            },
            "user_id":{
                "type":"INT(11) UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user id"
            },
            "title":{
                "type":"VARCHAR(255)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"post title"
            },
            "content":{
                "type":"TEXT",
                "allowNull":true,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"post content"
            },
            "created_at":{
                "type":"DATETIME",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"created datetime"
            },
            "updated_at":{
                "type":"DATETIME",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"updated datetime"
            }
        },
        "indexes":[
            {
                "primary":true,
                "fields":[
                    {
                        "attribute":"id",
                        "order":"ASC"
                    }
                ],
                "name":"PRIMARY",
                "tableName":"user_post",
                "unique":true,
                "type":"BTREE"
            },
            {
                "primary":false,
                "fields":[
                    {
                        "attribute":"user_id",
                        "order":"ASC"
                    }
                ],
                "name":"fk_user_id",
                "tableName":"user_post",
                "unique":false,
                "type":"BTREE"
            }
        ],
        "foreignKeys":[
            {
                "constraint_name":"fk_user_id",
                "constraintName":"fk_user_id",
                "constraintSchema":"test",
                "constraintCatalog":"test",
                "tableName":"user_post",
                "tableSchema":"test",
                "tableCatalog":"test",
                "columnName":"user_id",
                "referencedTableSchema":"test",
                "referencedTableCatalog":"test",
                "referencedTableName":"user",
                "referencedColumnName":"id"
            }
        ]
    }
}

处理 Models JSON 定义

得到表信息后,就需要将表信息转换为 sequelize models 的定义。比如:将 "type":"INT(11) UNSIGNED" 转换为 "type":"DataTypes.INTEGER(11).UNSIGNED" 、处理索引、处理外键等。并且还涉及到不同数据库之间的差异,比如 MySQL 的自增,只需要设置 AUTO_INCREMENT 即可,而 PostgreSQL 则是通过 serial 实现,将 defaultValue 设置为 extval(my_data_id_seq::regclass),详见:sequelize-automate#9

最终得到的 models 定义也就是 getDefinitions 返回的 JSON 如下所示:

[
    {
        "modelName":"user_model",
        "modelFileName":"user",
        "tableName":"user",
        "attributes":{
            "id":{
                "type":"DataTypes.INTEGER(11).UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":true,
                "autoIncrement":true,
                "comment":"primary ket",
                "field":"id"
            },
            "name":{
                "type":"DataTypes.STRING(100)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user name",
                "field":"name",
                "unique":"uk_name"
            },
            "email":{
                "type":"DataTypes.STRING(255)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user email",
                "field":"email"
            },
            "created_at":{
                "type":"DataTypes.DATE",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"created datetime",
                "field":"created_at"
            },
            "updated_at":{
                "type":"DataTypes.DATE",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"updated datetime",
                "field":"updated_at"
            }
        },
        "indexes":[

        ]
    },
    {
        "modelName":"user_post_model",
        "modelFileName":"user_post",
        "tableName":"user_post",
        "attributes":{
            "id":{
                "type":"DataTypes.INTEGER(11).UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":true,
                "autoIncrement":true,
                "comment":"primary key",
                "field":"id"
            },
            "user_id":{
                "type":"DataTypes.INTEGER(11).UNSIGNED",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"user id",
                "field":"user_id",
                "references":{
                    "key":"id",
                    "model":"user_model"
                }
            },
            "title":{
                "type":"DataTypes.STRING(255)",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"post title",
                "field":"title"
            },
            "content":{
                "type":"DataTypes.TEXT",
                "allowNull":true,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"post content",
                "field":"content"
            },
            "created_at":{
                "type":"DataTypes.DATE",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"created datetime",
                "field":"created_at"
            },
            "updated_at":{
                "type":"DataTypes.DATE",
                "allowNull":false,
                "defaultValue":null,
                "primaryKey":false,
                "autoIncrement":false,
                "comment":"updated datetime",
                "field":"updated_at"
            }
        },
        "indexes":[
            {
                "name":"fk_user_id",
                "unique":false,
                "type":"BTREE",
                "fields":[
                    "user_id"
                ]
            }
        ]
    }
]

而后续的 run 方法,就是根据该 JSON 去生成不同风格的 models 代码,如 JS、TS 或 Egg.js。当然,开发者也可以根据这份 JSON 定义,去生成别的风格的代码。

使用 AST 生成 models

得到 models 的 JSON 定义后,就可以根据定义生成 models。这个过程我选择的是用 AST,先生成 models 的 AST,然后根据 AST 生成代码。主要用到的工具有 @babel/parser @babel/generator @babel/types @babel/traverse

比如生成字符串 "primary key" 的 AST:

const t = require('@babel/types');

const str = t.stringLiteral('Primary key');
// { type: 'StringLiteral', value: 'Primary key' }

生成对象 { comment: "primary key" } 的 AST:

const obj = t.objectProperty(t.identifier('comment'), t.stringLiteral("Primary key"));
/**
{
  type: 'ObjectProperty',
  key: { type: 'Identifier', name: 'comment' },
  value: { type: 'StringLiteral', value: 'Primary' },
  computed: false,
  shorthand: false,
  decorators: null
}
*/

然后就可以根据 AST 生成代码:

const generator = require('@babel/generator').default;

const code = generate(obj);
// { code: 'comment: "Primary"', map: null, rawMappings: null }

需要注意的是,如果要支持中文,则需要设置 jsescOption.minimaltrue,否则输出的是 unicode 字符:

const obj = t.objectProperty(t.identifier('comment'), t.stringLiteral("主键"));

const code1 = generate(obj);
{ code: 'comment: "\\u4E3B\\u952E"', map: null, rawMappings: null }

const code2 = generate(obj, {
  jsescOption: {
    minimal: true,
  },
});
// { code: 'comment: "主键"', map: null, rawMappings: null }

总结

最开始写 sequelize-automate 是因为每次表结构修改了,都需要手动在代码里面修改 models ,修改起来非常繁琐而且容易写错,当表非常多的时候,写起来就更麻烦了。所以开发了这个小工具,能够让工具做的事情,就尽量让工具去做。

在写 sequelize-automate 之前,其实我也发现了 sequelize/sequelize-auto 也可以用来自动生成 models,但这个包已经几年没有更新了,使用的 sequelize 还是 3.30 版本,现在 sequelize 已经更新到 6.0 了;并且它还有很多 BUG 没有修复,很难使用起来。我也去看了它的代码,感觉很混乱,全是回调嵌套,难以维护。其生成代码也是用的字符串拼接的方式,没有 AST 先进、高端、准确且可预测。所以,毫不犹豫的选择并使用 sequelize-automate 吧!