CLOVER🍀

That was when it all began.

Node.jsのデータベースマイグレーションツール、Umzugを試す

これは、なにをしたくて書いたもの?

Node.jsのデータベースマイグレーションツールとしてUmzugというものがあるようなので、試してみようかなと。

Node.jsのデータベースマイグレーションツール

Node.jsにおけるデータベースマイグレーションツールとしては、以下の2つがあるようです。

今回は、Umzugの方を使ってみようという話ですね。

ところで、Node.jsでのORMにはデータベースマイグレーションツールが付属している場合が多いです。

SequelizeおよびMicroORMのデータベースマイグレーションは、Umzugを統合したもののようです。

今回はこういったORMに付属するものではなく、単独で扱えるものをターゲットにします。

Umzug

UmzugのGitHubリポジトリーはこちら。

GitHub - sequelize/umzug: Framework agnostic migration tool for Node.js

現在のバージョンは3.3.1です。

「Umzug」ってなんだろう?と思ったのですが、ドイツ語で「引越」とか「転居」とかを指すようです。

GitHub Organizationを見るとSequelize Organizationの中にあるのですが、サブプロジェクトの位置づけなんでしょうか。

特徴は以下のようです。

Umzug / Highlights

ドキュメントはREADME.mdのようですね。

Umzug / Documentation

最小のサンプル。

Umzug / Documentation / Minimal Example

使い方。

Umzug / Documentation / Usage

CLI。

Umzug / Documentation / CLI

ざっくり、以下のようにして使う感じみたいです。

ドキュメント内にはSequelizeが多く登場するのですが、必ずしもSequelizeと組み合わせて使わなければならない、ということでは
なさそうです。

あくまでストレージの一種だという位置づけです。

Note that although this uses Sequelize, Umzug isn't coupled to Sequelize, it's just one of the (most commonly-used) supported storages.

とはいえ、Sequelizeに依存しないデータベースストレージも標準であって欲しかった気はしますが…。

お題

今回は、以下のお題でUmzugを試してみたいと思います。

環境

今回の環境はこちら。

$ node --version
v18.18.0


$ npm --version
9.8.1

MySQLは172.17.0.2で動作しているものとし、データベースやユーザーは作成済みとします。

 MySQL  localhost:3306 ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.0.34    |
+-----------+
1 row in set (0.0006 sec)

Node.jsプロジェクトを作成する

まずは、Node.jsプロジェクトを作成します。

$ npm init -y
$ npm i -D typescript
$ npm i -D @types/node@v18
$ npm i -D prettier
$ mkdir src

依存関係は、あとで載せましょう。

scripts。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "format": "prettier --write src"
  },

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

Umzugを使ってみる

インストール

Umzugのインストール。MySQLへの接続には、mysql2を使います。

$ npm i umzug
$ npm i mysql2

依存関係は、このようになりました。

  "devDependencies": {
    "@types/node": "^18.18.4",
    "prettier": "^3.0.3",
    "typescript": "^5.2.2"
  },
  "dependencies": {
    "mysql2": "^3.6.1",
    "umzug": "^3.3.1"
  }

マイグレーションファイルは、migrationsというディレクトリに配置することにします。

$ mkdir migrations
Umzugを使ったプログラムを作成する

作成したソースコードはこちら。

src/run-umzug.ts

import fs from 'node:fs/promises';
import mysql from 'mysql2/promise';
import { Umzug, JSONStorage, MigrationParams, Resolver } from 'umzug';

const main = async () => {
  const conn = await mysql.createConnection({
    host: '172.17.0.2',
    port: 3306,
    user: 'kazuhira',
    password: 'password',
    database: 'practice',
    multipleStatements: true, // マイグレーションファイル(SQL)に複数のSQLを含める場合
  });

  try {
    const resolver: Resolver<mysql.Connection> = (params: MigrationParams<mysql.Connection>) => ({
      name: params.name,
      up: async () => {
        log(`target migration file => ${params.path}`);

        const sql = (await fs.readFile(params.path!)).toString();
        return await params.context.query(sql);
      },
      down: async () => {
        // downは今回は除外
      },
    });

    const umzug = new Umzug({
      migrations: { glob: 'migrations/**/*.sql', resolve: resolver },
      context: conn,
      storage: new JSONStorage(), // デフォルトではumzug.jsonというファイルに保存
      logger: console,
    });

    // 適用済みのマイグレーションを表示
    const executedMetas = await umzug.executed();
    if (executedMetas.length > 0) {
      log('current executed migrations');
      for (const meta of executedMetas) {
        log(`executed, name = ${meta.name}, path = ${meta.path}`);
      }
    }

    // 未適用のマイグレーションを表示
    const pendingMetas = await umzug.pending();
    if (pendingMetas.length > 0) {
      log('current pending migrations');
      for (const meta of pendingMetas) {
        log(`pending, name = ${meta.name}, path = ${meta.path}`);
      }
    }

    log('start Umzug migration...');
    await umzug.up();
    log('end Umzug migration');
  } finally {
    await conn.end();
  }
};

main().catch((e) => console.error(e));

function log(message: string) {
  console.info(`[${new Date().toISOString()}] ${message}`);
}

今回は、以下のようにasyncな関数を作成して呼び出す形式にしました。

const main = async () => {
    // ここに処理を書く
};

main().catch((e) => console.error(e));

mainの中を説明していきます。

データベース接続です。今回はMySQLへ接続するので、mysql2を使用します。

  const conn = await mysql.createConnection({
    host: '172.17.0.2',
    port: 3306,
    user: 'kazuhira',
    password: 'password',
    database: 'practice',
    multipleStatements: true, // マイグレーションファイル(SQL)に複数のSQLを含める場合
  });

multipleStatementsですが、デフォルトはfalseで複数のSQLを実行できません。

あとで記載しますが、以下のような複数のSQL文を含むマイグレーションファイルを作成して実行すると

migrations/20231009-003.sql

insert into book(isbn, title, price) values('978-4798161488', 'MySQL徹底入門 第4版 MySQL 8.0対応', 4180);
insert into book(isbn, title, price) values('978-4798147406', '詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド', 3960);
insert into book(isbn, title, price) values('978-4873116389', '実践ハイパフォーマンスMySQL 第3版', 5280);

以下のように構文エラーになります。multipleStatementsをtrueにすることで、このようなマイグレーションファイルも実行できるように
なります。

MigrationError: Migration 20231009-003.sql (up) failed: 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 'insert into book(isbn, title, price) values('978-4798147406', '詳解MySQL 5.7 æ' at line 2

次はResolverの設定を書いているのですが、その前に先にUmzug自体の設定を説明しましょう。

    const umzug = new Umzug({
      migrations: { glob: 'migrations/**/*.sql', resolve: resolver },
      context: conn,
      storage: new JSONStorage(), // デフォルトではumzug.jsonというファイルに保存
      logger: console,
    });

各プロパティの意味と指定する値は、それぞれ以下です。

ストレージには標準でJSONファイル、メモリー、Sequelize、MongoDBを選択できるという話でしたが、今回はJSONファイルにします。

migrationsのプロパティにあるresolveですが、これにはResolverというインスタンスを渡します。

マイグレーションファイルの拡張子が.js、.cjsの場合は特に指定は不要ですが、.tsの場合はts-nodeをインストールする必要があり、
.sqlの場合は自分でResolverを作成する必要があります。

https://github.com/sequelize/umzug/blob/v3.3.1/src/umzug.ts#L110-L115

そして、作成したのがこちらですね。

    const resolver: Resolver<mysql.Connection> = (params: MigrationParams<mysql.Connection>) => ({
      name: params.name,
      up: async () => {
        log(`target migration file => ${params.path}`);

        const sql = (await fs.readFile(params.path!)).toString();
        return await params.context.query(sql);
      },
      down: async () => {
        // downは今回は除外
      },

nameでマイグレーション名、up、downでマイグレーションファイル実行時の処理を記述します。

今回は、マイグレーションの実行はupのみ実装しました。

      up: async () => {
        log(`target migration file => ${params.path}`);

        const sql = (await fs.readFile(params.path!)).toString();
        return await params.context.query(sql);
      },

また、Umzugのcontextで指定した値は、このResolverおよびMigrationParamsに反映されることになります。

    const resolver: Resolver<mysql.Connection> = (params: MigrationParams<mysql.Connection>) => ({

このcontextは

        return await params.context.query(sql);

ここで指定したオブジェクトです。

      context: conn,

あとは、見つかったマイグレーションファイルを適用します。

    await umzug.up();

その前に、適用済みのマイグレーションと未適用のマイグレーションを表示するようにしました。

    // 適用済みのマイグレーションを表示
    const executedMetas = await umzug.executed();
    if (executedMetas.length > 0) {
      log('current executed migrations');
      for (const meta of executedMetas) {
        log(`executed, name = ${meta.name}, path = ${meta.path}`);
      }
    }

    // 未適用のマイグレーションを表示
    const pendingMetas = await umzug.pending();
    if (pendingMetas.length > 0) {
      log('current pending migrations');
      for (const meta of pendingMetas) {
        log(`pending, name = ${meta.name}, path = ${meta.path}`);
      }
    }

ビルド。

$ npm run build

以降は、以下のコマンドでマイグレーションを適用します。

$ node dist/run-umzug.js
マイグレーションファイルを作成して、適用してみる

それでは、マイグレーションファイルを作成して適用していってみましょう。

データベースの状態。

 MySQL  localhost:3306 ssl  practice  SQL > show tables;
Empty set (0.0044 sec)

まだテーブルはありません。

最初は、こんなマイグレーションファイルを用意。

migrations/20231009-001.sql

create table book (
  isbn varchar(14),
  title varchar(255),
  price int,
  primary key(isbn)
);

実行。

$ node dist/run-umzug.js
[2023-10-09T15:02:47.785Z] current pending migrations
[2023-10-09T15:02:47.786Z] pending, name = 20231009-001.sql, path = /path/to/migrations/20231009-001.sql
[2023-10-09T15:02:47.786Z] start Umzug migration...
{ event: 'migrating', name: '20231009-001.sql' }
[2023-10-09T15:02:47.789Z] target migration file => /path/to/migrations/20231009-001.sql
{ event: 'migrated', name: '20231009-001.sql', durationSeconds: 0.114 }
[2023-10-09T15:02:47.902Z] end Umzug migration

未適用のマイグレーションが出力された後、マイグレーションが適用されたようです。

テーブルも作成されています。

 MySQL  localhost:3306 ssl  practice  SQL > show tables;
+--------------------+
| Tables_in_practice |
+--------------------+
| book               |
+--------------------+
1 row in set (0.0033 sec)

この時、プログラムを実行したカレントディレクトリに、マイグレーションの状態を記録したumzug.jsonというファイルが作成されています。

umzug.json

[
  "20231009-001.sql"
]

ストレージにはJSONを選択しましたからね。

もうひとつマイグレーションファイルを作成。

migrations/20231009-002.sql

create table account (
  id int,
  name varchar(50),
  registered datetime,
  about varchar(255),
  primary key(id)
);

実行。

$ node dist/run-umzug.js
[2023-10-09T15:06:42.802Z] current executed migrations
[2023-10-09T15:06:42.802Z] executed, name = 20231009-001.sql, path = /path/to/migrations/20231009-001.sql
[2023-10-09T15:06:42.804Z] current pending migrations
[2023-10-09T15:06:42.804Z] pending, name = 20231009-002.sql, path = /path/to/migrations/20231009-002.sql
[2023-10-09T15:06:42.804Z] start Umzug migration...
{ event: 'migrating', name: '20231009-002.sql' }
[2023-10-09T15:06:42.806Z] target migration file => /path/to/migrations/20231009-002.sql
{ event: 'migrated', name: '20231009-002.sql', durationSeconds: 0.096 }
[2023-10-09T15:06:42.902Z] end Umzug migration

今回は、適用済みのマイグレーションと未適用のマイグレーションの両方が表示されました。

テーブルが作成されたことの確認。

 MySQL  localhost:3306 ssl  practice  SQL > show tables;
+--------------------+
| Tables_in_practice |
+--------------------+
| account            |
| book               |
+--------------------+

ストレージの状態。

umzug.json

[
  "20231009-001.sql",
  "20231009-002.sql"
]

データを登録するマイグレーションファイルも用意してみましょう。

migrations/20231009-003.sql

insert into book(isbn, title, price) values('978-4798161488', 'MySQL徹底入門 第4版 MySQL 8.0対応', 4180);
insert into book(isbn, title, price) values('978-4798147406', '詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド', 3960);
insert into book(isbn, title, price) values('978-4873116389', '実践ハイパフォーマンスMySQL 第3版', 5280);

実行。

$ node dist/run-umzug.js
[2023-10-09T15:09:24.388Z] current executed migrations
[2023-10-09T15:09:24.388Z] executed, name = 20231009-001.sql, path = /path/to/migrations/20231009-001.sql
[2023-10-09T15:09:24.388Z] executed, name = 20231009-002.sql, path = /path/to/migrations/20231009-002.sql
[2023-10-09T15:09:24.390Z] current pending migrations
[2023-10-09T15:09:24.390Z] pending, name = 20231009-003.sql, path = /path/to/migrations/20231009-003.sql
[2023-10-09T15:09:24.390Z] start Umzug migration...
{ event: 'migrating', name: '20231009-003.sql' }
[2023-10-09T15:09:24.393Z] target migration file => /path/to/migrations/20231009-003.sql
{ event: 'migrated', name: '20231009-003.sql', durationSeconds: 0.07 }
[2023-10-09T15:09:24.462Z] end Umzug migration

データが入りました。

 MySQL  localhost:3306 ssl  practice  SQL > select * from book;
+----------------+------------------------------------------------------------------------------------------+-------+
| isbn           | title                                                                                    | price |
+----------------+------------------------------------------------------------------------------------------+-------+
| 978-4798147406 | 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド |  3960 |
| 978-4798161488 | MySQL徹底入門 第4版 MySQL 8.0対応                                                |  4180 |
| 978-4873116389 | 実践ハイパフォーマンスMySQL 第3版                                           |  5280 |
+----------------+------------------------------------------------------------------------------------------+-------+
3 rows in set (0.0011 sec)

ストレージの状態。

umzug.json

[
  "20231009-001.sql",
  "20231009-002.sql",
  "20231009-003.sql"
]

失敗するマイグレーションファイルを作成すると、どうなるのでしょうか?

SQLとして誤ったマイグレーションファイルを作成。

migrations/20231009-004.sql

invalid sql statement

実行してみます。

dist/run-umzug.js
[2023-10-09T15:11:30.622Z] current executed migrations
[2023-10-09T15:11:30.622Z] executed, name = 20231009-001.sql, path = /path/to/migrations/20231009-001.sql
[2023-10-09T15:11:30.622Z] executed, name = 20231009-002.sql, path = /path/to/migrations/20231009-002.sql
[2023-10-09T15:11:30.622Z] executed, name = 20231009-003.sql, path = /path/to/migrations/20231009-003.sql
[2023-10-09T15:11:30.624Z] current pending migrations
[2023-10-09T15:11:30.624Z] pending, name = 20231009-004.sql, path = /path/to/migrations/20231009-004.sql
[2023-10-09T15:11:30.624Z] start Umzug migration...
{ event: 'migrating', name: '20231009-004.sql' }
[2023-10-09T15:11:30.627Z] target migration file => /path/to/migrations/20231009-004.sql
MigrationError: Migration 20231009-004.sql (up) failed: 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 'invalid sql statement' at line 1
    at /path/to/node_modules/umzug/lib/umzug.js:151:27
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Umzug.runCommand (/path/to/node_modules/umzug/lib/umzug.js:107:20)
    at async main (/path/to/dist/run-umzug.js:53:9) {
  cause: 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 'invalid sql statement' at line 1
      at PromiseConnection.query (/path/to/node_modules/mysql2/promise.js:94:22)
      at Object.up (/path/to/dist/run-umzug.js:24:45)
      at async /path/to/node_modules/umzug/lib/umzug.js:148:21
      at async Umzug.runCommand (/path/to/node_modules/umzug/lib/umzug.js:107:20)
      at async main (/path/to/dist/run-umzug.js:53:9) {
    code: 'ER_PARSE_ERROR',
    errno: 1064,
    sql: 'invalid sql statement\n',
    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 'invalid sql statement' at line 1"
  },
  jse_cause: 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 'invalid sql statement' at line 1
      at PromiseConnection.query (/path/to/node_modules/mysql2/promise.js:94:22)
      at Object.up (/path/to/dist/run-umzug.js:24:45)
      at async /path/to/node_modules/umzug/lib/umzug.js:148:21
      at async Umzug.runCommand (/path/to/node_modules/umzug/lib/umzug.js:107:20)
      at async main (/path/to/dist/run-umzug.js:53:9) {
    code: 'ER_PARSE_ERROR',
    errno: 1064,
    sql: 'invalid sql statement\n',
    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 'invalid sql statement' at line 1"
  },
  migration: {
    direction: 'up',
    name: '20231009-004.sql',
    path: '/path/to/migrations/20231009-004.sql',
    context: PromiseConnection {
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      connection: [Connection],
      Promise: [Function: Promise],
      [Symbol(kCapture)]: false
    }
  }
}

当然ですが、実行に失敗します。構文エラーですね。

この時のストレージの状態ですが、失敗したマイグレーションは記録されていません。

umzug.json

[
  "20231009-001.sql",
  "20231009-002.sql",
  "20231009-003.sql"
]

なので、もう1度プログラムを実行しようとすると、再度このマイグレーションを適用しようとします(修正するまでは成功しませんが)。

とりあえず、こんなところかなと思います。CLIも試してみたかった気がしますが、SQLファイルはこの感じだとプログラムでResolverを
作成しないといけない気がするので、今回はいいでしょう。

おわりに

Node.jsのマイグレーションツールである、Umzugを試してみました。

マイグレーションのためにソースコードを書くということをやったことがなかったので、ちょっと新鮮でした。
標準でSequelize経由以外でデータベースをストレージにできるとよかった気はしましたが…。

db-migrateの方だとコマンドでSQLまで実行できるような雰囲気ですが、気が向いたら見てみるかもしれません。