見出し画像

sequelize-autoでDBスキーマからモデルを作成してみた


はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

以前の記事では、ORM の正攻法であるモデルを作成しそれに基づき DB を構築していく、という手順を試してみた。ただモデルから DB を構築する場合、以下のようなデメリットもある。

  • モデルの定義方法を理解する必要がある(Navicat などの GUI ツールで設計することに慣れている人にとっては生産性が下がる原因になる)

  • 痒い所に手が届かない

そこで、今回はモデル → DB 構築の順番の逆、つまり先に DB を Navicat などのツールで設計し、そのスキーマをモデルに落とし込むということをやってみたいと思う。

利用する ORM はSequelizeになる。

DB スキーマからモデルを作成する

sequelize-autoを利用することで、今回やりたいことは実現できる。CLI でモデルを作成する事がメインの想定ユースケースだと思われるが、モデルの作成時に独自の処理を入れたいなど、よりカスタマイズしやすいように今回はProgrammatic APIの方法でモデルを作成してみたいと思う。

※sequelize-autoとは、以下のように書かれている通り、コマンドラインからSequelize用のモデルを自動生成できるツールで、モデルの実装方法の細かい部分が分からない場合でも、DBスキーマからモデルを生成できるものになる。

Automatically generate models for SequelizeJS via the command line.

モデルにする DB スキーマは以下のようなものになる。

カスタマイズなしでモデルを作成する

まずはベーシックに "auto.run()" でモデルを生成してみたいと思う。コードとしては以下のようになるだろう。

import SequelizeAuto from 'sequelize-auto';
import Sequelize from 'sequelize';
import config from 'config';
import appRoot from 'app-root-path';

const sequelize = new Sequelize(config.get('sequelize'));

const main = async () => {
	const auto = new SequelizeAuto(sequelize, null, null, {
		directory: appRoot.resolve('src/models-auto'),
		caseModel: 'c',
		caseFile: 'l',
		caseProp: 'c',
		lang: 'esm',
		singularize: true,
		views: true,
		additional: {
			timestamps: false
		},
		skipTables: ['SequelizeMeta']
	});
	await auto.run();
};
await main();

いくつか補足をする(各オプションの意味については CLI のオプション通りで、詳細はUsageに書かれている通り)。

  • caseModel モデルの名前を camelCase にする設定

  • caseFile モデルのファイル名を lower case にする設定

  • caseProp モデル内のプロパティ(カラム名)を camelCase にする設定

  • lang ES Module 形式でモデルを出力させる設定

  • singularize テーブル名は複数形になっているのでそれを単数形にする設定

  • views view もモデルの作成対象にする設定

  • additional.timestamps 自動でタイムスタンプをモデルに追加しない(明示的にモデルに createdAt, updatedAt を定義する)を設定

この実装をしたあと、ファイルを実行するとモデルが自動で作成される。

$ yarn models
yarn run v1.22.19
$ rm -rf ./src/models-auto/*.js && npx vite-node ./support/sequelize-auto.js && npx eslint --fix ./src/models-auto/* && npx prettier --ignore-unknown --write ./src/models-auto/*
Executing (default): SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA = 'node-express';
Executing (default): select TABLE_NAME as table_name from information_schema.tables where table_type = 'VIEW' and table_schema = 'node-express'
Executing (default): SELECT K.CONSTRAINT_NAME as constraint_name
      , K.CONSTRAINT_SCHEMA as source_schema
      , K.TABLE_NAME as source_table
      , K.COLUMN_NAME as source_column
      , K.REFERENCED_TABLE_SCHEMA AS target_schema
      , K.REFERENCED_TABLE_NAME AS target_table
      , K.REFERENCED_COLUMN_NAME AS target_column
      , C.EXTRA AS extra
      , C.COLUMN_KEY AS column_key
      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS K
      LEFT JOIN INFORMATION_SCHEMA.COLUMNS AS C
        ON C.TABLE_NAME = K.TABLE_NAME AND C.COLUMN_NAME = K.COLUMN_NAME AND C.TABLE_SCHEMA = K.CONSTRAINT_SCHEMA
      WHERE K.TABLE_NAME = 'likes'
             AND C.TABLE_SCHEMA = 'node-express'
Executing (default): SELECT K.CONSTRAINT_NAME as constraint_name
      , K.CONSTRAINT_SCHEMA as source_schema
      , K.TABLE_NAME as source_table
      , K.COLUMN_NAME as source_column
      , K.REFERENCED_TABLE_SCHEMA AS target_schema
      , K.REFERENCED_TABLE_NAME AS target_table
      , K.REFERENCED_COLUMN_NAME AS target_column
      , C.EXTRA AS extra
      , C.COLUMN_KEY AS column_key
      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS K
      LEFT JOIN INFORMATION_SCHEMA.COLUMNS AS C
        ON C.TABLE_NAME = K.TABLE_NAME AND C.COLUMN_NAME = K.COLUMN_NAME AND C.TABLE_SCHEMA = K.CONSTRAINT_SCHEMA
      WHERE K.TABLE_NAME = 'posts'
             AND C.TABLE_SCHEMA = 'node-express'
Executing (default): SELECT K.CONSTRAINT_NAME as constraint_name
      , K.CONSTRAINT_SCHEMA as source_schema
      , K.TABLE_NAME as source_table
      , K.COLUMN_NAME as source_column
      , K.REFERENCED_TABLE_SCHEMA AS target_schema
      , K.REFERENCED_TABLE_NAME AS target_table
      , K.REFERENCED_COLUMN_NAME AS target_column
      , C.EXTRA AS extra
      , C.COLUMN_KEY AS column_key
      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS K
      LEFT JOIN INFORMATION_SCHEMA.COLUMNS AS C
        ON C.TABLE_NAME = K.TABLE_NAME AND C.COLUMN_NAME = K.COLUMN_NAME AND C.TABLE_SCHEMA = K.CONSTRAINT_SCHEMA
      WHERE K.TABLE_NAME = 'users'
             AND C.TABLE_SCHEMA = 'node-express'
Executing (default): SELECT K.CONSTRAINT_NAME as constraint_name
      , K.CONSTRAINT_SCHEMA as source_schema
      , K.TABLE_NAME as source_table
      , K.COLUMN_NAME as source_column
      , K.REFERENCED_TABLE_SCHEMA AS target_schema
      , K.REFERENCED_TABLE_NAME AS target_table
      , K.REFERENCED_COLUMN_NAME AS target_column
      , C.EXTRA AS extra
      , C.COLUMN_KEY AS column_key
      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS K
      LEFT JOIN INFORMATION_SCHEMA.COLUMNS AS C
        ON C.TABLE_NAME = K.TABLE_NAME AND C.COLUMN_NAME = K.COLUMN_NAME AND C.TABLE_SCHEMA = K.CONSTRAINT_SCHEMA
      WHERE K.TABLE_NAME = 'v_likes'
             AND C.TABLE_SCHEMA = 'node-express'
Executing (default): SHOW FULL COLUMNS FROM `likes`;
Executing (default): SHOW FULL COLUMNS FROM `posts`;
Executing (default): SHOW FULL COLUMNS FROM `users`;
Executing (default): SHOW FULL COLUMNS FROM `v_likes`;
Executing (default): SHOW INDEX FROM `likes`
Executing (default): SHOW INDEX FROM `posts`
Executing (default): SHOW INDEX FROM `v_likes`
Executing (default): SELECT COUNT(0) AS trigger_count
              FROM INFORMATION_SCHEMA.TRIGGERS AS t
             WHERE t.EVENT_OBJECT_TABLE = 'likes'
Executing (default): SHOW INDEX FROM `users`
Executing (default): SELECT COUNT(0) AS trigger_count
              FROM INFORMATION_SCHEMA.TRIGGERS AS t
             WHERE t.EVENT_OBJECT_TABLE = 'v_likes'
Executing (default): SELECT COUNT(0) AS trigger_count
              FROM INFORMATION_SCHEMA.TRIGGERS AS t
             WHERE t.EVENT_OBJECT_TABLE = 'posts'
Executing (default): SELECT COUNT(0) AS trigger_count
              FROM INFORMATION_SCHEMA.TRIGGERS AS t
             WHERE t.EVENT_OBJECT_TABLE = 'users'

/home/study/workspace/node-express/src/models-auto/init-models.js
  2:20  warning  Unexpected use of file extension "js" for "./like.js"    import/extensions
  3:20  warning  Unexpected use of file extension "js" for "./post.js"    import/extensions
  4:20  warning  Unexpected use of file extension "js" for "./user.js"    import/extensions
  5:21  warning  Unexpected use of file extension "js" for "./v_like.js"  import/extensions

✖ 4 problems (0 errors, 4 warnings)

src/models-auto/init-models.js 70ms
src/models-auto/like.js 18ms
src/models-auto/post.js 10ms
src/models-auto/user.js 10ms
src/models-auto/v_like.js 20ms
Done in 5.90s.

特にカスタマイズせずにモデルを作成する場合はこれで完了になるが、せっかく ORM を利用しているので以下のようなことを実現したくなるだろう。

  1. created_at, updated_at はそれぞれ DATETIME・TIMESTAMP だが、ORM のモデル内では unix timestamp として扱いたい

  2. TINYINT であるカラムは ORM のモデル内では Boolean として扱いたい

  3. DB スキーマには存在しないが、モデルにのみ存在するVirtual fieldsを定義したい

以下の章ではこれらの要望を実現する方法についてみていきたいと思う。

※"sequelize-auto.js"を ES Module で実装していたが、sequelize-auto.js を実行していたプロジェクトは CommonJS の設定になっていた。そのため、vite-nodeを利用してあたかも ES Module を直接実行しているかのように sequelize-auto.js を実行するという方法を取っている。

カスタマイズありでモデルを作成する

カスタマイズをするためには、sequelize-auto の"auto.run()"で何をしているか?を理解する必要がある。コードを見ればわかるが、いくつかのステップを経てモデルが作成されていることがわかる。

  async run(): Promise<TableData> {
    let td = await this.build();
    td = this.relate(td);
    const tt = this.generate(td);
    td.text = tt;
    await this.write(td);
    return td;
  }

デバッグをしていくとそれぞれでどのような処理をしているのか?がわかるので、それをヒントに、先ほどモデルのカスタイズ内容で取り上げた 3 つを実現するコードを生成させるための実装 Step としては以下のようになる。

  1. "auto.build()"の結果生成される td の tables プロパティにモデルの情報が格納されているのでそれをプログラムで操作する

  2. カスタムで追加する処理に必要なライブラリの import をプログラムで記述する

  3. "Virtual fields"のエラーを解消する

  4. カスタムで追加したい関数をモデルの末尾に追加する

順番に見ていく。

"auto.build()"の結果生成される td の tables プロパティにモデルの情報が格納されているのでそれをプログラムで操作する

まず、以下のように中身を取り出してみると、"td.tables[tableName]"がモデルの JSON であることがわかる。

let td = await auto.build();

Object.keys(td.tables).forEach((tableName) => {
  console.log(tableName);
  console.log(td.tables[tableName]);
});
posts
{
  id: {
    type: 'INT UNSIGNED',
    allowNull: false,
    defaultValue: null,
    primaryKey: true,
    autoIncrement: true,
    comment: null
  },
  ...
  updated_at: {
    type: 'TIMESTAMP',
    allowNull: false,
    defaultValue: 'CURRENT_TIMESTAMP',
    primaryKey: false,
    autoIncrement: false,
    comment: null
  }
}
likes
{
  id: {
    type: 'INT UNSIGNED',
    allowNull: false,
    defaultValue: null,
    primaryKey: true,
    autoIncrement: true,
    comment: null
  },
  ...
}
users
{
  id: {
    type: 'INT UNSIGNED',
    allowNull: false,
    defaultValue: null,
    primaryKey: true,
    autoIncrement: true,
    comment: null
  },
  ...
}
v_likes
{
  id: {
    type: 'INT UNSIGNED',
    allowNull: false,
    defaultValue: '0',
    primaryKey: true,
    autoIncrement: false,
    comment: null
  },
  ...
  updated_at: {
    type: 'TIMESTAMP',
    allowNull: false,
    defaultValue: 'CURRENT_TIMESTAMP',
    primaryKey: false,
    autoIncrement: false,
    comment: null
  }
}

このモデルの JSON を操作することでモデルをカスタイズすることが可能になる。具体的には以下のような実装になるだろう。

	let td = await auto.build();

	Object.keys(td.tables).forEach((tableName) => {
		const isView = tableName.startsWith('v');
		const columns = td.tables[tableName];

		if (columns.id && isView) delete columns.id.defaultValue;

		// 日付関係
		if (columns.created_at) {
			columns.created_at.get = function () {
				return DateTime.fromJSDate(this.getDataValue('createdAt')).toUnixInteger();
			};
			if (!isView) {
				columns.created_at.set = function (v) {
					this.setDataValue(
						'createdAt',
						v ? DateTime.fromSeconds(v).toFormat('yyyy-LL-dd HH:mm:ss') : null
					);
				};
			}
			if (isView && columns.created_at.defaultValue) delete columns.created_at.defaultValue;
		}

		if (columns.updated_at) {
			...
		}

		// Boolean
		if (columns.enabled) {
			columns.enabled.get = function () {
				return !!this.getDataValue('createdAt');
			};
			if (!isView) {
				columns.enabled.set = function (v) {
					this.setDataValue('enabled', v ? 1 : 0);
				};
			}
		}

		// その他
		if (tableName === 'users') {
			columns.full_name = { type: 'DataTypes.VIRTUAL' };
			columns.full_name.get = function () {
				return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`;
			};
		}
	});

	td = auto.relate(td);
	...

一部補足する。

  • get = function () {...}, set = function (v) {...} これはGettersSettersに書かれている関数を定義している部分で、これにより unix timestamp や Boolean に変換するということが実現できる

  • columns.full_name これは公式にあるようなバーチャルのフィールドを設定している部分で、これがあることで REST API を実装する場合などには楽になるだろう。

ここまで実装したところで同じようにモデルを生成してみると、今度は ESLint でエラーになる。

まず、"import/extensions"と"no-unused-vars"のエラーについては、無視して問題ないので"src/models-auto/.eslintrc.json"を別途作成し、以下のようなルールにすることでエラーレベルを warn に変更する。

// src/models-auto/.eslintrc.json
{
	"rules": { "no-unused-vars": "warn", "import/extensions": "warn" }
}

"no-undef"については次の章で取り上げる。

カスタムで追加する処理に必要なライブラリの import をプログラムで記述する

"no-undef"のエラーは、luxonの DateTime が自動生成された後のモデルに存在していないことが原因なので、これは実装を変更することで対応する。

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 },
  ]);
});

上記のコードについて補足をする。

  • td.text
    この変数には、テーブル名をキーにしたモデルのテキスト情報が格納されている。そのテキストを直接書き換える事で import 文などを追記しようとしている。

  • addImport() => {}
    モデル内にカスタムで必要になるモジュールの import 文を追加するための関数。やっていることはモデルのテキストに検索対象の文字列があるか?を検索し、あれば必要なモジュールを追加するということ。

上記のようなコードにより、ESLint の"no-undef"のエラーは出なくなる。これで完成、かと思いきや生成されたモデルを確認すると、以下のように "'DataTypes.VIRTUAL'" と文字列になってしまっている。

...
export default class user extends Model {
	static init(sequelize, DataTypes) {
		return super.init(
			{
				...
				fullName: {
					type: 'DataTypes.VIRTUAL',
					get() {
						return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`;
					},
					field: 'full_name'
				}
			},
			...
		);
	}
}

このままではエラーになってしまうのでここを修正する。

"Virtual fields"のエラーを解消する

テキストになってしまっているのが問題なので、以下のような実装で置き換えてあげればいい。

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

この実装を追加してモデルを再度生成すると、以下のように "DataTypes.VIRTUAL" になっていることが確認できる。

				fullName: {
					type: DataTypes.VIRTUAL,
					get() {
						return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`;
					},
					field: 'full_name'
				}

最後に「カスタムで追加したい関数をモデルの末尾に追加する」をやっていく。

カスタムで追加したい関数をモデルの末尾に追加する

最後にカスタムの関数をモデルの末尾に追加する、というのをやってみたいと思う。追加する関数としては、デフォルトで存在するtoJSON()を拡張し、exclude するキーを設定できるようにするもの。

実装としては以下のようになる。

	Object.keys(td.text).forEach((tableName) => {
		...
		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}`);
	});

これにより、各モデルに以下のような実装が追加されるので、toJSON()を拡張して利用できるようになる。

export default class user extends Model {
    ...
	toJSON(options = {}) {
		const json = super.toJSON();
		if (options.exclude && Array.isArray(options.exclude))
			options.exclude.forEach((key) => delete json[key]);
		return json;
	}
}

以上でカスタムのモデルを作成するための実装は完成となる。

まとめとして

今回は Sequelize のモデルを DB スキーマから自動で作成できるsequelize-autoを利用してモデルを作成するということをやってみた。これによりモデルの実装方法などが不明でも簡単にモデルを作成できるようになるだろう。

Navicat などの GUI ツールでの設計を行い、そのあとに実装するという開発スタイルにもなじみやすいと思う。

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
認証認可(SHIFTアカウント)や課金決済のプラットフォーム開発に従事。リードエンジニア。 経歴としては、SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。その後現在のプラットフォーム開発に参画。

お問合せはお気軽に

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら

PHOTO:UnsplashPakata Goh