Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sequelize-Automate: 自动生成 Sequelize models #53

Open
nodejh opened this issue Jan 9, 2020 · 0 comments
Open

Sequelize-Automate: 自动生成 Sequelize models #53

nodejh opened this issue Jan 9, 2020 · 0 comments

Comments

@nodejh
Copy link
Owner

nodejh commented Jan 9, 2020

本文的主角是 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 吧!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant