外部キーだらけの旧DBを新DBへ移行する — Seederの依存解決とDB定義書からのDDL自動生成

概要

ある業務システムのリニューアル案件で、旧DBから新DBへデータを移し替える「シーディング(初期データ投入)基盤」をPHPで整備しました。設計の核は2つです。

  1. 外部キー制約を壊さないための Seeder実行順序の自動解決
  2. 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.sql
  • ddl/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つは他の移行案件でも使い回せる型だと思います。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。