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たで実行できるような雰囲気ですが、気が向いたら芋おみるかもしれたせん。