sequelize-autoでDBスキーマからSequlizeのモデルを作成する

## はじめに

[Sequelizeを使ってDBマイグレーションをやる方法2つを試してみた](https://note.com/shift_tech/n/neb212bb61dea)にある通り、ORMのモデルからDBスキーマを作成・更新するというのが一般的だろう。ただその場合、以下のような課題もあり、場合によりけりだが逆（DBスキーマを先に定義し、その後でORMのモデルを作成する）の方が適している場合もあるだろう。

- 複雑な設計の場合、モデルの実装方法への深い理解が必要になる（DBスキーマからモデルを自動生成すればモデルの実装ができなくても何とかなる）
- モデルからDBスキーマを作成の場合、痒い所に手が届かなかったりする

というわけで、今回は[sequelize-auto](https://github.com/sequelize/sequelize-auto/tree/master)を使ってSequlizeのモデルを自動生成するという事をやってみた。

コード全体は以下。

https://github.com/yuta-katayama-23/serverless-express-vue-app/commit/445caf325c9a622979d89d97060ca95be0ca2ca1

https://github.com/yuta-katayama-23/serverless-express-vue-app/commit/df738f9b63bce7d1c7f1e45257d571a540d8b0fa

## ORMのモデルを自動生成する

今回は[sequelize](https://sequelize.org/)をORMとして利用する事にする。また、ORMであればモデル→スキーマ作成の順が一般的だが、今回は痒い所に手が届きやすいスキーマの定義→モデルの作成の順で、後からORMのモデルを作成する方法を取る。

モデルの生成には[sequelize-auto](https://github.com/sequelize/sequelize-auto)を利用する。CLIでの利用が基本のようだが、カスタムでモデル生成時に[getter, setter](https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/)なども自動で定義できるようにするために、プログラムでsequelize-autoを実行する事にする。

オプションの設定は以下のようにした（詳細は[Usage](https://github.com/sequelize/sequelize-auto#usage)の項を参照）。
```js:support/sequelize-auto.js
...
const auto = new SequelizeAuto(sequelize, null, null, {
	directory: appRoot.resolve('/srv/models-auto'),
	caseFile: 'l',
	caseModel: 'c',
	caseProp: 'c',
	lang: 'esm',
	singularize: true,
	views: true,
	additional: { timestamps: false }
});
...
```

上記の設定で少し補足する。

- additional.timestamps
これはSequelizeの[Timestamps](https://sequelize.org/docs/v6/core-concepts/model-basics/#timestamps)による自動的なcreatedAt, updatedAtの追加を無効にす＝明示的にモデルにそれらを追加するようにするための設定

上記の設定で`auto.run()`メソッドを呼び出せば自動でモデルが作成されるが、getter, setterなどを追加したいので、[ここのコード](https://github.com/sequelize/sequelize-auto/blob/master/src/auto.ts#L50)を参考に、モデルの生成処理をカスタマイズしていく。

まず、`auto.build()`でモデルを生成するためのテーブルデータを作成する。buildの結果生成されるオブジェクトは以下の通り。
```js
const td = await auto.build();
console.log(td);
```
```console
TableData {
  tables: {
    todos: {
      id: [Object],
      user_id: [Object],
      title: [Object],
      content: [Object],
      is_complete: [Object],
      updated_at: [Object],
      created_at: [Object]
    },
    users: {
      id: [Object],
      account_type_id: [Object],
      email: [Object],
      last_name: [Object],
      first_name: [Object],
      gender_id: [Object],
      last_logined_at: [Object],
      updated_at: [Object],
      created_at: [Object]
    },
    v_todos: {
      id: [Object],
      title: [Object],
      content: [Object],
      is_complete: [Object],
      user_id: [Object],
      last_name: [Object],
      first_name: [Object],
      updated_at: [Object],
      created_at: [Object]
    }
  },
  foreignKeys: {
    todos: { id: [Object], user_id: [Object] },
    users: { email: [Object], id: [Object] }
  },
  indexes: {
    todos: [ [Object], [Object] ],
    v_todos: [],
    users: [ [Object], [Object] ]
  },
  hasTriggerTables: {},
  relations: []
}
```

tablesの`Object.keys`でループを回して各キー（テーブル）の中のカラムを直接書き換える事でgetter, setterを追加できる。

次に[sequelize-auto/src/auto.ts](https://github.com/sequelize/sequelize-auto/blob/master/src/auto.ts#L52)にあるように`auto.relate(td)`と`auto.generate(td)`の結果、モデルの中のJavascriptが生成される。

```js
td = auto.relate(td);
const tt = auto.generate(td);
td.text = tt;

console.log(td.text);
```
```console
{
  todos: "import _sequelize from 'sequelize';\n" +
    'const { Model, Sequelize } = _sequelize;\n' +
    '\n' +
    'export default class todo extends Model {\n' +
    '  static init(sequelize, DataTypes) {\n' +
    '  return super.init({\n' +
    '    id: {\n' +
    '      autoIncrement: true,\n' +
    '      type: DataTypes.INTEGER.UNSIGNED,\n' +
    '      allowNull: false,\n' +
    '      primaryKey: true\n' +
    '    },\n' +
    '    userId: {\n' +
    '      type: DataTypes.INTEGER.UNSIGNED,\n' +
    '      allowNull: false,\n' +
    '      references: {\n' +
    "        model: 'users',\n" +
    "        key: 'id'\n" +
    '      },\n' +
    "      field: 'user_id'\n" +
    '    },\n' +
    '    title: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: false\n' +
    '    },\n' +
    '    content: {\n' +
    '      type: DataTypes.STRING(256),\n' +
    '      allowNull: true\n' +
    '    },\n' +
    '    isComplete: {\n' +
    '      type: DataTypes.TINYINT,\n' +
    '      allowNull: true,\n' +
    '      defaultValue: 0,\n' +
    "      field: 'is_complete'\n" +
    '    },\n' +
    '    updatedAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    "      defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),\n" +
    "      field: 'updated_at'\n" +
    '    },\n' +
    '    createdAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    "      defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),\n" +
    '      get: function () {\n' +
    '\t\t\treturn DateTime.fromJSDate(\n' +
    "\t\t\t\tthis.getDataValue('createdAt')\n" +
    '\t\t\t).toUnixInteger();\n' +
    '\t\t},\n' +
    '      set: function (v) {\n' +
    '\t\t\t\tthis.setDataValue(\n' +
    "\t\t\t\t\t'createdAt',\n" +
    "\t\t\t\t\tv ? DateTime.fromSeconds(v).toFormat('yyyy-LL-dd HH:mm:ss') : null\n" +
    '\t\t\t\t);\n' +
    '\t\t\t},\n' +
    "      field: 'created_at'\n" +
    '    }\n' +
    '  }, {\n' +
    '    sequelize,\n' +
    "    tableName: 'todos',\n" +
    '    timestamps: false,\n' +
    '    indexes: [\n' +
    '      {\n' +
    '        name: "PRIMARY",\n' +
    '        unique: true,\n' +
    '        using: "BTREE",\n' +
    '        fields: [\n' +
    '          { name: "id" },\n' +
    '        ]\n' +
    '      },\n' +
    '      {\n' +
    '        name: "todos_ibfk_1_idx",\n' +
    '        using: "BTREE",\n' +
    '        fields: [\n' +
    '          { name: "user_id" },\n' +
    '        ]\n' +
    '      },\n' +
    '    ]\n' +
    '  });\n' +
    '  }\n' +
    '}\n',
  users: "import _sequelize from 'sequelize';\n" +
    'const { Model, Sequelize } = _sequelize;\n' +
    '\n' +
    'export default class user extends Model {\n' +
    '  static init(sequelize, DataTypes) {\n' +
    '  return super.init({\n' +
    '    id: {\n' +
    '      type: DataTypes.INTEGER.UNSIGNED,\n' +
    '      allowNull: false,\n' +
    '      primaryKey: true\n' +
    '    },\n' +
    '    accountTypeId: {\n' +
    '      type: DataTypes.TINYINT,\n' +
    '      allowNull: false,\n' +
    '      comment: "1:personal, 2:business",\n' +
    "      field: 'account_type_id'\n" +
    '    },\n' +
    '    email: {\n' +
    '      type: DataTypes.STRING(128),\n' +
    '      allowNull: false,\n' +
    '      unique: "email_idx"\n' +
    '    },\n' +
    '    lastName: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: true,\n' +
    "      field: 'last_name'\n" +
    '    },\n' +
    '    firstName: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: true,\n' +
    "      field: 'first_name'\n" +
    '    },\n' +
    '    genderId: {\n' +
    '      type: DataTypes.TINYINT,\n' +
    '      allowNull: false,\n' +
    '      comment: "1:male, 2:female, 3:notselect",\n' +
    "      field: 'gender_id'\n" +
    '    },\n' +
    '    lastLoginedAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: true,\n' +
    "      field: 'last_logined_at'\n" +
    '    },\n' +
    '    updatedAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    "      defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),\n" +
    "      field: 'updated_at'\n" +
    '    },\n' +
    '    createdAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    "      defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),\n" +
    '      get: function () {\n' +
    '\t\t\treturn DateTime.fromJSDate(\n' +
    "\t\t\t\tthis.getDataValue('createdAt')\n" +
    '\t\t\t).toUnixInteger();\n' +
    '\t\t},\n' +
    '      set: function (v) {\n' +
    '\t\t\t\tthis.setDataValue(\n' +
    "\t\t\t\t\t'createdAt',\n" +
    "\t\t\t\t\tv ? DateTime.fromSeconds(v).toFormat('yyyy-LL-dd HH:mm:ss') : null\n" +
    '\t\t\t\t);\n' +
    '\t\t\t},\n' +
    "      field: 'created_at'\n" +
    '    }\n' +
    '  }, {\n' +
    '    sequelize,\n' +
    "    tableName: 'users',\n" +
    '    timestamps: false,\n' +
    '    indexes: [\n' +
    '      {\n' +
    '        name: "PRIMARY",\n' +
    '        unique: true,\n' +
    '        using: "BTREE",\n' +
    '        fields: [\n' +
    '          { name: "id" },\n' +
    '        ]\n' +
    '      },\n' +
    '      {\n' +
    '        name: "email_idx",\n' +
    '        unique: true,\n' +
    '        using: "BTREE",\n' +
    '        fields: [\n' +
    '          { name: "email" },\n' +
    '        ]\n' +
    '      },\n' +
    '    ]\n' +
    '  });\n' +
    '  }\n' +
    '}\n',
  v_todos: "import _sequelize from 'sequelize';\n" +
    'const { Model, Sequelize } = _sequelize;\n' +
    '\n' +
    'export default class vTodo extends Model {\n' +
    '  static init(sequelize, DataTypes) {\n' +
    '  return super.init({\n' +
    '    id: {\n' +
    '      type: DataTypes.INTEGER.UNSIGNED,\n' +
    '      allowNull: false,\n' +
    '      primaryKey: true\n' +
    '    },\n' +
    '    title: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: false\n' +
    '    },\n' +
    '    content: {\n' +
    '      type: DataTypes.STRING(256),\n' +
    '      allowNull: true\n' +
    '    },\n' +
    '    isComplete: {\n' +
    '      type: DataTypes.TINYINT,\n' +
    '      allowNull: true,\n' +
    '      defaultValue: 0,\n' +
    "      field: 'is_complete'\n" +
    '    },\n' +
    '    userId: {\n' +
    '      type: DataTypes.INTEGER.UNSIGNED,\n' +
    '      allowNull: false,\n' +
    "      field: 'user_id'\n" +
    '    },\n' +
    '    lastName: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: true,\n' +
    "      field: 'last_name'\n" +
    '    },\n' +
    '    firstName: {\n' +
    '      type: DataTypes.STRING(32),\n' +
    '      allowNull: true,\n' +
    "      field: 'first_name'\n" +
    '    },\n' +
    '    updatedAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    "      defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),\n" +
    "      field: 'updated_at'\n" +
    '    },\n' +
    '    createdAt: {\n' +
    '      type: DataTypes.DATE,\n' +
    '      allowNull: false,\n' +
    '      get: function () {\n' +
    '\t\t\treturn DateTime.fromJSDate(\n' +
    "\t\t\t\tthis.getDataValue('createdAt')\n" +
    '\t\t\t).toUnixInteger();\n' +
    '\t\t},\n' +
    "      field: 'created_at'\n" +
    '    }\n' +
    '  }, {\n' +
    '    sequelize,\n' +
    "    tableName: 'v_todos',\n" +
    '    timestamps: false\n' +
    '  });\n' +
    '  }\n' +
    '}\n'
}
```

カスタムのgetter, setterのために今回Luxonを利用しているので、そのための`import`を追加する必要がある。それは上記のモデルのJSのテキストを直接上書きする事で対応する。つまり、`td.text`を`Object.keys`でループしてJavascriptのテキストを変更する。

実装としては以下のようにすればいいだろう。
```js:support/sequelize-auto.js
td = auto.relate(td);
const tt = auto.generate(td);
td.text = tt;

const addImport = (text, importModules) => {
	let t = text;
	importModules.forEach((module) => {
		const matchResult = text.match(module.name);
		if (!matchResult && !Array.isArray(matchResult)) return;
		const target = 'const { Model, Sequelize } = _sequelize;\n';
		t = module.nameImport
			? t.replace(
					target,
					`import { ${module.name} } from '${module.path}';\n${target}`
			  )
			: t.replace(
					target,
					`import ${module.name} from '${module.path}';\n${target}`
			  );
	});

	return t;
};
Object.keys(td.text).forEach((tableName) => {
	td.text[tableName] = addImport(td.text[tableName], [
		{ name: 'DateTime', path: 'luxon', nameImport: true }
	]);
});
```

上記の実装により、カスタムで必要なモジュールのimport分を機械的に追加できる。今回は`Luxon`のみだが、name importとそうでない場合を考えて参考演算子で分岐を実装している。

後は、上記のコードの続きに`await auto.write(td);`を書き、実行すれば自動でモデルが生成される。
```console
$ yarn models-auto
yarn run v1.22.19
$ rm -rf ./srv/models-auto/*.js && node support/sequelize-auto.js && npx eslint ./srv/models-auto --fix && npx prettier --ignore-unknown --write ./srv/models-auto
srv/models-auto/init-models.js 133ms
srv/models-auto/todo.js 42ms
srv/models-auto/user.js 28ms
srv/models-auto/v_todo.js 30ms
Done in 6.82s.
```

※ちなみに、[toJSON()](https://sequelize.org/api/v6/class/src/model.js~model#instance-method-toJSON)メソッドでSequlizeのモデルからJavascriptのピュアオブジェクトを生成できるが、これをオーバーライドして必要なキーのみをJSONに含まれるようにする、という実装を自動で追加することもできる。実装としては以下のようになるだろう（ESLintでチェック＆Prettierでフォーマットされる事を前提にしている）。

```js
Object.keys(td.text).forEach((tableName) => {
	td.text[tableName] = addImport(td.text[tableName], [
		{ name: 'DateTime', path: 'luxon', nameImport: true }
	]);

	const addCustomFunc = `toJSON(options = {}) {
        const json = super.toJSON();
        if(options.exclude && Array.isArray(options.exclude))
            options.exclude.forEach((key) => delete json[key]);
        return json;
    }`;

	td.text[tableName] = td.text[tableName].replace(
		/}\n+$/,
		`\n${addCustomFunc}\n}`
	);
});
```

## まとめ

今回はDBスキーマからSequlizeのモデルを自動生成する、という事をやってみた。コードでsequelize-autoを実行する事で、カスタムのgetter, setterも自動で追加するといった柔軟な対応もできるので、必要に応じで実装をするといいと思われる。

## おまけ
### DataTypes.VIRTUALをモデルに自動で追加する

テーブルにはtinyintで数値として保存しているが、REST APIではenumとしてIFを設計している場合には、モデルの方でID（数値）を意味のある文字列に変換する、という処理があると便利。具体的には以下のように、`account_type_id`というカラムがテーブルにあり、その値が1の場合にはpersonal、2の場合にはbusinessというルールの場合、モデルに`account_type`という[`DataTypes.VIRTUAL`](https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#virtual-fields)があると便利。

```js
Object.keys(td.tables).forEach((tableName) => {
    ...
	/**
	 * enum関係
	 */
	if (columns.account_type_id) {
		columns.account_type = { type: 'DataTypes.VIRTUAL' };
		columns.account_type.get = function () {
			return this.getDataValue('accountTypeId') === '1'
				? 'personal'
				: 'business';
		};
		if (!isView) {
			columns.account_type.set = function (v) {
				this.setDataValue('accountTypeId', v === 'personal' ? 1 : 2);
			};
		}
	}
    ...
});

...

Object.keys(td.text).forEach((tableName) => {
	...
	td.text[tableName] = td.text[tableName].replace(
		/"DataTypes.VIRTUAL"/g,
		'DataTypes.VIRTUAL'
	);
    ...
});
```

これを自動で生成するには少し工夫が必要。
`columns.account_type = { type: 'DataTypes.VIRTUAL' };`の部分を、`import { DataTypes } from 'sequelize';`を定義した上で、`{ type: DataTypes.VIRTUAL }`のようにしても、生成されるモデルは以下のようになってしまう。

```js
				accountType: {
					type: VIRTUAL,
					...
				},
```

期待値としては、`type: DataTypes.VIRTUAL,`になる。

そのため、正規表現で無理くりJavascriptのテキストを置換するという方法で`type: DataTypes.VIRTUAL,`になるようにしている。