これは、なにをしたくて書いたもの?
Node.jsのデータベースマイグレーションツールとしてUmzugというものがあるようなので、試してみようかなと。
Node.jsのデータベースマイグレーションツール
Node.jsにおけるデータベースマイグレーションツールとしては、以下の2つがあるようです。
- db-migrate
- Umzug
今回は、Umzugの方を使ってみようという話ですね。
ところで、Node.jsでのORMにはデータベースマイグレーションツールが付属している場合が多いです。
- Prisma
- TypeORM
- Sequelize
- MicroORM
- Knex.js
SequelizeおよびMicroORMのデータベースマイグレーションは、Umzugを統合したもののようです。
今回はこういったORMに付属するものではなく、単独で扱えるものをターゲットにします。
Umzug
GitHub - sequelize/umzug: Framework agnostic migration tool for Node.js
現在のバージョンは3.3.1です。
「Umzug」ってなんだろう?と思ったのですが、ドイツ語で「引越」とか「転居」とかを指すようです。
GitHub Organizationを見るとSequelize Organizationの中にあるのですが、サブプロジェクトの位置づけなんでしょうか。
特徴は以下のようです。
ドキュメントはREADME.mdのようですね。
最小のサンプル。
Umzug / Documentation / Minimal Example
使い方。
CLI。
ざっくり、以下のようにして使う感じみたいです。
- マイグレーションファイル(JavaScript、SQL)を用意する
- マイグレーションファイルにはupとdownの2種類がある
- マイグレーションを実行するコンテキスを指定する
- マイグレーションの結果を保存するストレージを指定する
- ストレージにはJSONファイル、メモリー、Sequelize、MongoDBがあり、自分で作成することもできる
- Umzug / Documentation / Storages
ドキュメント内には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, });
各プロパティの意味と指定する値は、それぞれ以下です。
migrations… マイグレーションファイルの置き場所をglobプロパティで指定する- globの実装にはnode-globがが使われている
context… マイグレーションファイルの実行時に渡すオブジェクトstorage… マイグレーション結果の保存先logger… ログ出力に使用するオブジェクト。info、warn、error、debug関数を持っている必要がある
ストレージには標準で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" ]
失敗するマイグレーションファイルを作成すると、どうなるのでしょうか?
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まで実行できるような雰囲気ですが、気が向いたら見てみるかもしれません。