外部キーだらけの旧DBを新DBへ移行する — Seederの依存解決とDB定義書からのDDL自動生成
概要
ある業務システムのリニューアル案件で、旧DBから新DBへデータを移し替える「シーディング(初期データ投入)基盤」をPHPで整備しました。設計の核は2つです。
- 外部キー制約を壊さないための Seeder実行順序の自動解決
- Excelで管理されたDB定義書から DDL(CREATE文・制約定義)を自動生成する仕組み
レガシー資産を抱えたまま新スキーマへ移行するとき、この2つが揃うとローカル環境の再構築が「1コマンド」になります。その設計と実装のポイントをまとめます。
(仮置き: PHPは 5.6 / 7.0 系、MySQL、ローカルはVagrant想定)
環境
- PHP
^5.6 || ^7.0 - MySQL(localhost)
- Composer(依存管理)
- Vagrant(シーディング実行環境)
- 拡張:
ctype,dom,gd,iconv,libxml,mbstring,simplexml,xml,xmlreader,xmlwriter,zip,zlib psr/simple-cache: ^1.0(DDL生成側で使用)
発生した問題
旧システムのDBを新スキーマへ移行する際、次の課題がありました。
- 旧DBのデータを新DBのテーブル構造に合わせて流し込む必要がある
- 新DBのテーブルには外部キー制約がある。親テーブルより先に子テーブルへINSERTすると制約エラーで落ちる
- テーブル数が多く、投入順序を人手で管理するのは破綻する
- スキーマそのものはExcelのDB定義書が「正」。手書きのDDLはすぐ定義書とズレる
「順序」と「スキーマの二重管理」という2つの問題が同時に効いてきます。
原因
外部キー制約があるスキーマでのデータ投入は、本質的にトポロジカルソートの問題です。「AはBに依存する」という関係を宣言的に持たせ、依存先を先に流す仕組みがないと、Seederの実行順序がファイル名やハードコードされた配列順に依存してしまい、テーブルが増えるたびに壊れます。
スキーマの二重管理も同じ構造の問題で、定義書(人間が読む正本)と実際のDDL(機械が実行するもの)を別々に手で保つ限り、必ずどこかでズレます。
対応方法
1. Seederの基底クラスと依存宣言
すべてのSeederは基底クラス Seeder を継承し、依存関係は $after プロパティで宣言的に持たせます。
abstract class Seeder
{
/** このSeederより先に実行されるべきSeeder名 */
protected $after = [];
abstract public function run();
public function dependencies(): array
{
return $this->after;
}
}
具体的なSeederでは依存先を配列で書くだけです。たとえば「レシピ材料」テーブルは「レシピ」テーブルに外部キーを持つため、レシピ側が先に走る必要があります。
class RecipeIngredientTblSeeder extends Seeder
{
// Recipe が投入済みでないと外部キー制約エラーになる
protected $after = ['RecipeTblSeeder'];
public function run()
{
// 旧DBから読み出して新DBのスキーマへ変換・INSERT
}
}
2. 依存解決(トポロジカルソート)
登録された全Seederを、$after を辺として並べ替えてから実行します。
function resolveOrder(array $seeders): array
{
$resolved = [];
$visiting = [];
$visit = function ($name) use (&$visit, $seeders, &$resolved, &$visiting) {
if (isset($resolved[$name])) {
return;
}
if (isset($visiting[$name])) {
throw new RuntimeException("循環依存を検出: {$name}");
}
$visiting[$name] = true;
foreach ($seeders[$name]->dependencies() as $dep) {
$visit($dep);
}
unset($visiting[$name]);
$resolved[$name] = true;
};
foreach (array_keys($seeders) as $name) {
$visit($name);
}
return array_keys($resolved);
}
新しいSeederを追加するときは $after を書くだけでよく、実行順序を人手で管理する必要がなくなります。循環依存はここで例外として弾けます。
3. リフレッシュスクリプト
ローカルのDB再構築はワンコマンドにまとめました。
$ ./refresh.sh dev
このスクリプトは新DBをDROPしてCREATEし直します(=既存データは全消去)。壊れた状態からでも常にクリーンな初期状態へ戻せるのが狙いです。破壊的操作なので、本番接続先では絶対に走らせない前提で dev のような環境名を必須にしています。
4. DB定義書からのDDL自動生成
スキーマの二重管理を避けるため、Excel(xlsx)のDB定義書をパースして CREATE TABLE と制約定義を生成します。
$ php exportDDL.php
生成物:
ddl/create-tables.sqlddl/create-constraints.sql
テーブル本体と外部キー制約を別ファイルに分けるのがコツです。まず全テーブルを作り、その後で制約を貼れば、テーブル作成時に「まだ存在しない参照先テーブル」を気にせず済みます。
xlsxのパースには simplexml / zip 系の拡張を使います(xlsxはzip圧縮されたXMLの集合体です)。だからComposerの require に前述のPHP拡張群を並べています。
実装例
前提となるローカルセットアップは次の通りです。
# 新DBを作成し、既存の webdbuser でアクセスできるようにする mysql> CREATE DATABASE new_schema_db; mysql> GRANT ALL ON new_schema_db.* TO webdbuser@localhost IDENTIFIED BY '(既存パスワード)';
シーディングの流れをコードで俯瞰するとこうなります。
$seeders = [
'RecipeTblSeeder' => new RecipeTblSeeder(),
'RecipeIngredientTblSeeder' => new RecipeIngredientTblSeeder(),
// ... 追加時はここに登録し、$after を書くだけ
];
foreach (resolveOrder($seeders) as $name) {
echo "seeding: {$name}\n";
$seeders[$name]->run();
}
また、用途によって composer install を分けています。
# シーディング用(本番相当の依存だけ) $ composer install --no-dev # DDL生成用(開発依存も含める) $ composer install --dev
確認方法
./refresh.sh dev実行後、外部キー制約エラーが出ずに全Seederが完了すること- 意図的に
RecipeIngredientTblSeederの$afterを空にすると、依存解決が効かず制約エラーになる(=順序制御が実際に効いていることの裏取り) exportDDL.phpを実行し、ddl/create-tables.sqlとddl/create-constraints.sqlが生成され、定義書を1行変えると出力SQLも変わること
注意点
refresh.shはDROPを含む破壊的操作です。接続先を必ず確認し、本番・共有環境では走らせないこと- DDL生成にはxlsxパース用のPHP拡張が必要です。CI等で足りないとexportDDL側だけ落ちます
- 依存宣言(
$after)は「外部キーの向き」と一致させます。逆に書くと循環扱いになったり順序が狂います - シーディングは
--no-dev、DDL生成は--devと、用途でcomposer installを分けています
まとめ
レガシーDBの移行で効いたのは、地味ですが次の2点でした。
$afterによる依存の宣言 + トポロジカルソートで、Seeder追加時の順序管理を人手から外した- DB定義書(Excel)を正本にしてDDLを自動生成し、スキーマの二重管理をやめた
「ローカルを1コマンドでいつでも初期化できる」状態は、移行案件の開発速度をそのまま底上げします。順序の自動解決とスキーマの単一情報源、この2つは他の移行案件でも使い回せる型だと思います。
