見出し画像

Sequelizeを使ってDBマイグレーションをやる方法2つを試してみた

はじめに

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

ORM を使って実装するというのは目新しい事ではないと思いますが、今回は Node.js の ORM であるSequelizeを使って、データベースのマイグレーションを行う方法について、以下の 2 つのやり方を試してみました。

sequelize/cliを利用する
Model synchronizationを利用する

※ORM を使う理由などについては今回は触れません。そちらについてはO/R マッパーを使う理由O/R マッピングは百害あって一利なし!など他の記事を参照ください。

sequelize/cliを利用する

こちらは公式のMigrationsに書かれている通り、マイグレーションを作成してデータベースの変更(バージョン)管理を行っていく方法。以下では、sequelize/cli を利用してモデル・マイグレーションを作成し、その後、① カラムの追加、② インデックスの追加、という流れで、実際にどのようにマイグレーションができるか?を見ていく。

その前に準備として

sequelize/cli は少し設定が必要なのでその設定を行う。手っ取り早く設定するにはProject bootstrappingに書かれている通り、以下のコマンドを実行して初期化をすればいい。

[study@localhost node-express]$ npx sequelize-cli init
...
Created "config/config.json"
Successfully created models folder at "/home/study/workspace/node-express/models".
Successfully created migrations folder at "/home/study/workspace/node-express/migrations".
Successfully created seeders folder at "/home/study/workspace/node-express/seeders".

既に sequelize を利用していて models の場所を決めていたり、プロジェクトの構成を自分で決めたい場合には、The .sequelizerc fileに書かれている通り、.sequelizerc をルートに作成する事で sequelize/cli のカスタム設定ができる。また、ES6 以降の構文を使いたい場合には、以下のように@babel/registerを require する必要がある(詳細はUsing Babelを参照)。

require("@babel/register");
module.exports = {
  config: "db/database.json",
  "models-path": "src/models/sequelize",
  "seeders-path": "db/seeders",
  "migrations-path": "db/migrations",
};

モデル・マイグレーションを作成する

やり方はCreating the first Model (and Migration)に書かれている通り。実際にシンプルなモデル(テーブルの構成定義)とマイグレーションを作成してみると、以下のように作成できる。

[study@localhost node-express]$ npx sequelize-cli model:generate --name users --attributes email:string,password:string
...
New model was created at /home/study/workspace/node-express/src/models/sequelize/users.js .
New migration was created at /home/study/workspace/node-express/db/migrations/20220615021047-create-users.js .

作成してみると分かるが、Javascript の構文が ES5 になっており、またモデルの方にはない id や createdAt などのカラムがマイグレーションの方にはあるので、この辺りは自分で修正する必要がある(今回修正したものは以下で、モデル側は id と created_at・updated_at の追加と PRIMRY KEY 制約のインデックスの定義を行い、マイグレーションの方は created_at・updated_at の type 等の設定を変えている)。

// ./src/models/sequelize/users.js
import { Sequelize, Model } from "sequelize";
import { DateTime } from "luxon";

export default (sequelize, DataTypes) => {
  class users extends Model {}
  users.init(
    {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: DataTypes.INTEGER.UNSIGNED,
      },
      created_at: {
        allowNull: false,
        type: DataTypes.DATE,
        defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
        // わざわざUNIX TIMESTAMPに変換しているが、それは時間にタイムゾーンの概念が入らないようにするため
        get() {
          return DateTime.fromJSDate(
            this.getDataValue("created_at")
          ).toUnixInteger();
        },
      },
      updated_at: {
        allowNull: false,
        type: "TIMESTAMP",
        defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
        get() {
          return DateTime.fromJSDate(
            this.getDataValue("updated_at")
          ).toUnixInteger();
        },
      },
    },
    {
      sequelize,
      tableName: "users",
      timestamps: false,
      indexes: [
        {
          name: "PRIMARY",
          unique: true,
          using: "BTREE",
          fields: [{ name: "id" }],
        },
      ],
    }
  );
  return users;
};
// 20220612021048-create-users.js
export default {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable("users", {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED,
      },
      created_at: {
        allowNull: false,
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
      },
      updated_at: {
        allowNull: false,
        type: "TIMESTAMP",
        defaultValue: Sequelize.literal(
          "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
        ),
      },
    });
  },
  // eslint-disable-next-line no-unused-vars
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable("users");
  },
};

この状態で以下のようにマイグレーションを実行すると、データベースにテーブルが作成できている事が確認できる。

[study@localhost node-express]$ npx sequelize-cli db:migrate
...
Loaded configuration file "db/database.json".
Using environment "development".
== 20220612021048-create-users: migrating =======
== 20220612021048-create-users: migrated (0.695s)

ここまでやってみた後、ちゃんとデータが意図した通り作成・取得できる事を確認する目的で、REST API を簡易的に実装して確認を行った。それついてはおまけの「テーブル作成後に簡易的に REST API を実装して確認する」を参照。

※モデルの方で "using: 'BTREE'" としているにも関わらず、上記で作成されたテーブルには "USING BTREE" が設定されていないように見えるが、これは、13.1.14 CREATE INDEX Statementに書かれている通り、MySQL ではデフォルトのインデックスタイプが BTREE になるので、暗黙的に設定されている事になっている(以下、公式からの引用)。

Where multiple index types are listed, the first one is the default when no index type specifier is given. (複数のインデックスタイプがリストされている場合、インデックスタイプ指定子が与えられていなければ、最初のものがデフォルトとなります。)

MySQL Workbench であれば以下のようにインデックスタイプが BTREE になっている事が確認できる。

Usageのコマンド一覧を見ればわかるが、モデル・マイグレーションを同時に作成するコマンドはあるが、既存のモデルからマイグレーションを作成するコマンドはないので、以下のカラム追加やインデックスの追加の際のマイグレーションは、スケルトンから自分で全部実装していく事になる。

※ "model:generate" 時に createdAt や updatedAt が自動でマイグレーションの方に追加されていたが、これをデフォルトで追加されないようにする方法は以下のコマンドオプションを見る限りなさそう。

・参考:public async createTable(tableName: string, attributes: object, options: object, model: Model): Promise

・参考:public constructor(database: string, username: string, password: string, options: object)

・参考:sequelize.js TIMESTAMP not DATETIME

・参考:Extending Model

・参考:public static init(attributes: object, options: object): Model

カラムを追加してみる

新しくマイグレーションを作成する必要があるので、以下のコマンドでマイグレーションを作成する。スケルトンができるのでこれを実装していく(モデルも変更になるのでモデルも追加で実装する)。

[study@localhost node-express]$ npx sequelize migration:generate --name add-email-password-to-user
...
migrations folder at "/home/study/workspace/node-express/db/migrations" already exists.
New migration was created at /home/study/workspace/node-express/db/migrations/20220615235343-add-email-password-to-user.js .

実際に作成したマイグレーションと、変更後のモデルは以下のようになる(モデルについては差分のみ示している)。モデルの方には追加したい email・password のカラム情報を追加している。マイグレーションに関しては、カラム追加の実装方法がAdding a column to a tableAPI リファレンスに書かれているので、それに倣いテーブル名を指定し、追加するカラム名とその属性情報を定義している。

// ./src/models/sequelize/users.js
// 省略
const encrypt = (v) => sha256(`${v}:${config.get("salt.password")}`).toString();

// 省略
export default (sequelize, DataTypes) => {
  class users extends Model {}
  users.init(
    {
      // 省略
      email: {
        type: DataTypes.STRING(128),
        allowNull: false,
        validate: { isEmail: true },
      },
      password: {
        type: DataTypes.CHAR(64),
        allowNull: false,
        // 以下はパスワードを保存する際のハッシュ化で、今回の記事の内容から逸脱するので詳細については省く
        set(v) {
          this.setDataValue("password", encrypt(v));
        },
      },
      // 省略
    },
    {
      sequelize,
      // 省略
    }
  );
  return users;
};
// 20220615235343-add-email-password-to-user.js
export default {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn("users", "email", {
      allowNull: false,
      type: Sequelize.STRING(128),
    });
    await queryInterface.addColumn("users", "password", {
      allowNull: false,
      type: Sequelize.CHAR(64),
    });
  },

  // eslint-disable-next-line no-unused-vars
  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn("users", "email");
    await queryInterface.removeColumn("users", "password");
  },
};

あとは以下のようにマイグレーションを実行すればいい。

[study@localhost node-express]$ npx sequelize-cli db:migrate
...
Loaded configuration file "db/database.json".
Using environment "development".
== 20220615235343-add-email-password-to-user: migrating =======
== 20220615235343-add-email-password-to-user: migrated (10.219s)

マイグレーションを実行した時の SQL は MySQL のクエリーログから見れ、ALTER TABLE が実行されている事が確認できる。

[study@localhost node-express]$ sudo tail -f data/mysql/general.log
...
2022-06-16T11:44:00.984744Z         5 Query     ALTER TABLE `users` ADD `email` VARCHAR(128) NOT NULL
2022-06-16T11:44:01.093185Z         5 Query     ALTER TABLE `users` ADD `password` CHAR(64) NOT NULL
2022-06-16T01:44:01.169143Z         5 Query     SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = 'SequelizeMeta' AND TABLE_SCHEMA = 'node-express'
2022-06-16T11:44:01.171622Z         5 Query     SHOW INDEX FROM `SequelizeMeta`
2022-06-16T11:44:01.178430Z         5 Prepare   INSERT INTO `SequelizeMeta` (`name`) VALUES (?)
2022-06-16T11:44:01.180392Z         5 Execute   INSERT INTO `SequelizeMeta` (`name`) VALUES ('20220615235343-add-email-password-to-user.js')
...

変更後のテーブル定義を確認すると、以下の通りで、追加した email・password が意図した通り追加できている事が確認できる。

インデックスを追加してみる

次にインデックスを追加してみる。手順は上記の「カラムを追加してみる」と同じで、マイグレーションのスケルトンを作成し、マイグレーションの実装と、モデルの変更を行えばよい。

実際に作成したマイグレーションと、変更後のモデルは以下のようになる(モデルについては差分のみ示している)。モデルの方には追加したいインデックス情報を追加している。マイグレーションの方はAPI リファレンスを参考にインデックスの追加を行うための実装をしている。

// ./src/models/sequelize/users.js
// 省略

export default (sequelize, DataTypes) => {
  class users extends Model {}
  users.init(
    {
      // 省略
    },
    {
      // 省略
      indexes: [
        // 省略
        {
          name: "idx_email",
          unique: true,
          using: "BTREE",
          fields: [{ name: "email" }],
        },
      ],
    }
  );
  return users;
};
// 20220616041107-add-email-index-to-user.js
export default {
  // eslint-disable-next-line no-unused-vars
  async up(queryInterface, Sequelize) {
    await queryInterface.addIndex("users", ["email"], {
      name: "idx_email",
      unique: true,
      using: "BTREE",
    });
  },

  // eslint-disable-next-line no-unused-vars
  async down(queryInterface, Sequelize) {
    await queryInterface.removeIndex("users", "idx_email");
  },
};

上記の実装をした上でマイグレーションを実行すると、以下のように ALTER TABLE が実行され、変更後のテーブル定義を確認するとインデックスが追加されている事が確認できる。

[study@localhost node-express]$ sudo tail -f data/mysql/general.log
...

2022-06-16T12:15:35.648508Z        10 Query     ALTER TABLE `users` ADD UNIQUE INDEX `idx_email` USING BTREE (`email`)
2022-06-16T12:15:35.738018Z        10 Query     SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = 'SequelizeMeta' AND TABLE_SCHEMA = 'node-express'
2022-06-16T12:15:35.740018Z        10 Query     SHOW INDEX FROM `SequelizeMeta`
2022-06-16T12:15:35.746094Z        10 Prepare   INSERT INTO `SequelizeMeta` (`name`) VALUES (?)
2022-06-16T12:15:35.747722Z        10 Execute   INSERT INTO `SequelizeMeta` (`name`) VALUES ('20220616041107-add-email-index-to-user.js')

上記で見てきたように、以上がマイグレーションを作成して DB マイグレーションを行う方法。次に、Model synchronization を利用した DB マイグレーションについてみていく。

Model synchronizationを利用する

次に見ていく方法は、モデルの構造をデータベースに同期してテーブルを作成・更新するというもの。実際にやってみる。
以下のように自分でモデルからデータベースへ同期するためのコードを実装して、それを実行すれば簡単にモデルからデータベースにテーブルを作成する事ができる。sync メソッドのオプションについてはModel synchronizationに書かれているが、今回は既存のテーブルを drop する事なく、モデルに合わせて必要な変更のみを行うようにしている。モデルに関しては「①sequelize/cli を利用する」で見てきたものと同じ。

import Sequelize from "sequelize";
import config from "config";
import initModels from "../src/models/sequelize/init-models";

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

const main = async (_sequelize) => {
  const models = initModels(_sequelize);
  await models.user.sync({ alter: true });
};

main(sequelize)
  .then(async () => {
    console.log("The table for the user model was just (re)created!");
    await sequelize.close();
  })
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

上記のコードをNode.js で import・export(ES6 の構文)を使えるように webpack × Babel の設定をやってみたに書かれている方法でビルドして実行してみると、マイグレーションのパターン同様にテーブルが作成できている事が確認できる(以下を実行する前にデータベースは一度初期化している)。

[study@localhost node-express]$ node dist/migrate.js
Executing (default): SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = 'users' AND TABLE_SCHEMA = 'node-express'
Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER UNSIGNED NOT NULL auto_increment , `email` VARCHAR(128) NOT NULL, `password` CHAR(64) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE `idx_email` (`email`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): SHOW INDEX FROM `users`
The table for the user model was just (re)created!

※1 点、マイグレーションの時と違い "updated_at" が CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ではなく、CURRENT_TIMESTAMP になってしまっている。これはモデルの方で "defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')" のように設定しているからだが、これを "defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')" とする事で、テーブル定義を ① のパターンと同じにする事ができる。

しかし、この設定ではデータを create する際に以下のようなエラーが出てしまう・・・。sync メソッドでテーブルを作成・更新する場合には、一部手動で定義をいじるしかないのかもしれない(Adding CURRENT_TIMESTAMP as a "DEFAULT" and "ON UPDATE" for createdAt, updatedAt on migrationsを参照)。

  original: Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'ON UPDATE CURRENT_TIMESTAMP)' at line 1
      at Packet.asError (/home/study/workspace/node-express/node_modules/mysql2/lib/packets/packet.js:728:17)
      ...
      at Socket.Readable.push (internal/streams/readable.js:206:10) {
    code: 'ER_PARSE_ERROR',
    errno: 1064,
    sqlState: '42000',
    sqlMessage: "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'ON UPDATE CURRENT_TIMESTAMP)' at line 1",
    sql: 'INSERT INTO `users` (`id`,`email`,`password`,`created_at`,`updated_at`) VALUES (DEFAULT,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP);',
    parameters: [
      'sample@example.com',
      'd1ccaa0bf3988373dd1b55956fa47e281a3a5d9530395074cbd2bc0359a3b88e'
    ]
  },
  ...
}

まとめとして

今回は ORM である Sequelize を利用した、DB マイグレーションについてみてきた。マイグレーションを作成する方法はモデルとマイグレーションの両方を作成する必要があり、そこがコストになるが、モデルのみでsyncを行う方法だと手動で直すしかない部分が出てしまい、それぞれメリデメはあると感じた。

上記のマイグレーションでは、既にデータがある場合での更新時の既存行の穴埋め・巻き戻し時の扱いなど、実際に運用開始していたら起きうるであろう場面でのマイグレーションについては触れなかった。今後は、そういった実践的な場面でどのようにマイグレーションを行っていくのかについても理解を深めていきたいと思った。

おまけ

テーブル作成後に簡易的に REST API を実装して確認する

REST API の実装としては以下。

// src/index.js Express
import initModels from "./models/sequelize/init-models";
// 省略

const app = express();
const router = Router();
// 省略
app.use("/api/v1", router);
// 省略
app.locals.models = initModels(new Sequelize(config.get("sequelize")));
// 省略

router.post("/user", async (req, res) => {
  const { models } = req.app.locals;

  try {
    const { id } = await models.user.create({});
    const user = await models.user.findByPk(id);

    res.status(201).json(user.toJSON());
  } catch (error) {
    // 省略
  }
});

router.get("/user/:id", async (req, res) => {
  const { models } = req.app.locals;

  try {
    const user = await models.user.findByPk(req.params.id);

    if (!user) throw new CustomError(404, "Not Found");
    res.status(200).json(user.toJSON());
  } catch (error) {
    // 省略
  }
});
// 省略
app.listen(3000, () => console.log("listening on port 3000!"));
// src/models/sequelize/init-models.js
import { DataTypes } from "sequelize";
import _user from "./user";

export default (sequelize) => {
  const user = _user(sequelize, DataTypes);
  return { user };
};

POST でデータを登録してみると、以下のようにデータが作成できている事が確認できる。

[study@localhost node-express]$ curl -X POST -H "Content-Type: application/json" -d '{}' localhost:3000/api/v1/user
{"created_at":null,"updated_at":null,"id":1}

mysql> SELECT * FROM `users`;
+----+---------------------+---------------------+
| id | created_at          | updated_at          |
+----+---------------------+---------------------+
|  1 | 2022-06-16 20:46:49 | 2022-06-16 20:46:49 |
+----+---------------------+---------------------+
1 row in set (0.00 sec)

また、GET でデータを取得してみると、UNIX TIMESTAMP に変換されてデータが取得できている事が確認できる。

[study@localhost node-express]$ curl localhost:3000/api/v1/user/1
{"created_at":1655336809,"updated_at":1655336809,"id":1}

※最終的に出来上がったテーブルに対するデータ作成の API の完成形は以下のようになるだろう(以下は、Express で REST API を実装する時に req/res の validation を楽に実装できるライブラリを使ってみたに書かれているような、OpenAPI によるバリエーションがある前提で実装している)。

router.post("/user", async (req, res) => {
  const { models } = req.app.locals;
  const { email, password } = req.body;

  try {
    const { id } = await models.user.create({ email, password });
    const user = await models.user.findByPk(id);

    res.status(201).json(user.toJSON());
  } catch (error) {
    // 省略
  }
});

router.get("/user/:id", async (req, res) => {
  const { models } = req.app.locals;

  try {
    const user = await models.user.findByPk(req.params.id);

    if (!user) throw new CustomError(404, "Not Found");
    res.status(200).json(user.toJSON());
  } catch (error) {
    // 省略
  }
});
[study@localhost node-express]$ curl -X POST -H "Content-Type: application/json" -d '{"email": "sample@example.com", "password": "password"}' localhost:3000/api/v1/user
{"created_at":1655422985,"updated_at":1655422985,"id":3,"email":"sample@example.com","password":"d1ccaa0bf3988373dd1b55956fa47e281a3a5d9530395074cbd2bc0359a3b88e"}

モデルの実装方法の他のアイディア

上記のモデルの実装はExtending Modelに書かれているものと全く同じだったが、それが何をしているのか?をもう少し堀り下げてみる事で、少し ES6 クラスの実装としてしっくりくるであろう形に実装を変えてみたいと思う。

ポイントは、"users.init(...)" の "init" で、これは継承している Model が持つ static メソッドであるpublic static init(attributes: object, options: object): Modelを呼び出しているだけ。という事は User クラスの static に init というメソッドを実装して、その中身は継承元の init を実行すれば同じ事が実装できるので、モデルを以下のように実装する事が可能になる。

// 省略
export default class users extends Model {
  static init(sequelize, DataTypes) {
    return super.init(
      {
        // 省略
      },
      {
        sequelize,
        // 省略
      }
    );
  }

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

上記のような実装をすると、例えば、Model が持つメソッドを上書きする際に、以下のように少し、えっ??になる実装をしなくて済むようになる。

// 省略
export default (sequelize, DataTypes) => {
  class users extends Model {
    toJSON(options = {}) {
      const json = super.toJSON();
      if (options.exclude && Array.isArray(options.exclude))
        options.exclude.forEach((key) => delete json[key]);
      return json;
    }
  }

  users.init(
    {
      // 省略
    },
    {
      sequelize,
      // 省略
    }
  );
  return users;
};

参考文献

Node.js の Sequelize で DB の migration を実行する

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


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

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

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

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

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

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/