🎻

[Symfony][Doctrine] 外部キーの付け替えを伴うマイグレーションスクリプトの書き方

2020/07/19に公開

例えば、以下のようなエンティティ構造の変更を考えます。

  • Before
    • 店舗商品 を持っている
  • After
    • 店舗スタッフ が所属していて、 スタッフ ごとに担当の 商品 を持っている

エンティティのコードとしては以下のようなイメージです。

Before

class Shop
{
    /**
     * @ORM\OneToMany(targetEntity=Item::class, mappedBy="shop")
     */
    private $items;
}
class Item
{
    /**
     * @ORM\ManyToOne(targetEntity=Shop::class, inversedBy="items")
     * @ORM\JoinColumn(nullable=false)
     */
    private $shop;
}

After

class Shop
{
    /**
     * @ORM\OneToMany(targetEntity=Staff::class, mappedBy="shop")
     */
    private $staves;
}
class Staff
{
    /**
     * @ORM\ManyToOne(targetEntity=Shop::class, inversedBy="staves")
     * @ORM\JoinColumn(nullable=false)
     */
    private $shop;

    /**
     * @ORM\OneToMany(targetEntity=Item::class, mappedBy="staff")
     */
    private $items;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;
}
class Item
{
    /**
     * @ORM\ManyToOne(targetEntity=Staff::class, inversedBy="items")
     * @ORM\JoinColumn(nullable=false)
     */
    private $staff;
}

マイグレーションスクリプトを自動生成してみる

上記のコード変更を行った場合のマイグレーションについて考えてみましょう。

まずは doctrine:migrations:diff コマンドでマイグレーションスクリプトを自動生成してみます。

bin/console doctrime:migrations:diff

すると以下のようなスクリプトが生成されます。

public function up(Schema $schema) : void
{
    $this->addSql('CREATE TABLE staff (id INT AUTO_INCREMENT NOT NULL, shop_id INT NOT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_11111111111111 (shop_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
    $this->addSql('ALTER TABLE staff ADD CONSTRAINT FK_11111111111111 FOREIGN KEY (shop_id) REFERENCES shop (id)');
    $this->addSql('ALTER TABLE item DROP FOREIGN KEY FK_22222222222222');
    $this->addSql('DROP INDEX IDX_22222222222222 ON item');
    $this->addSql('ALTER TABLE item CHANGE shop_id staff_id INT NOT NULL');
    $this->addSql('ALTER TABLE item ADD CONSTRAINT FK_33333333333333 FOREIGN KEY (staff_id) REFERENCES staff (id)');
    $this->addSql('CREATE INDEX IDX_33333333333333 ON item (staff_id)');
}

public function down(Schema $schema) : void
{
    $this->addSql('ALTER TABLE item DROP FOREIGN KEY FK_33333333333333');
    $this->addSql('DROP TABLE staff');
    $this->addSql('DROP INDEX IDX_33333333333333 ON item');
    $this->addSql('ALTER TABLE item CHANGE staff_id shop_id INT NOT NULL');
    $this->addSql('ALTER TABLE item ADD CONSTRAINT FK_22222222222222 FOREIGN KEY (shop_id) REFERENCES shop (id)');
    $this->addSql('CREATE INDEX IDX_22222222222222 ON item (shop_id)');
}

up() の内容を見てみると、以下のようなことをしています。

  1. staff テーブルを作成
  2. staff テーブルの shop_id カラムに shop テーブルへの外部キー制約を追加
  3. item テーブルの shop_id カラムの shop テーブルへの外部キー制約を削除
  4. item テーブルの shop_id カラムのインデックスを削除
  5. item テーブルの shop_id カラムを staff_id に名前変更
  6. item テーブルの staff_id カラムに staff テーブルへの外部キー制約を追加
  7. item テーブルの staff_id カラムにインデックスを追加

down() はこの逆ですね。

さて、このマイグレーションスクリプトは期待どおりの内容になっているでしょうか?

よく考えてみれば分かりますが、残念ながらこのままではデータが空の状態でもない限り正常にマイグレーションできません😓

staff_id カラムにはもともと shop_id のつもりで入れたIDは入っているので、そこに新しく作った staff テーブルへの外部キー制約を追加しようとしても以下のエラーになるはずです。

SQLSTATE[HY000]: General error: 1215 Cannot add foreign key constraint

マイグレーションスクリプトを書き換える

というわけで、ちゃんと自力でマイグレーションスクリプトを書き換えてあげましょう💪

up() については、以下のような手順を踏めば正常にマイグレーションできるはずです。

  1. staff テーブルを作成して、 shop_id カラムにインデックスと shop テーブルへの外部キー制約を追加
  2. item テーブルに staff_id カラムを追加して、インデックスを作成 (まだ外部キー制約は追加しない)
  3. shop テーブルを巡回して、各 shop に対して必要なだけ staff を作成
    • このとき、もともと shop が持っていた item を何らかの条件に応じて対応する staff に適切に紐づける
    • 巡回を終えると、 staff テーブルに必要なレコードがすべて挿入済みで、各レコードの shop_id には適切に shop のIDが入っており、かつ staffitem からも適切に参照されている状態になる
  4. item テーブルの staff_id カラムに staff テーブルへの外部キー制約を追加 (データが整理できたのでここで外部キー制約を追加する)
  5. item テーブルの shop_id カラムのインデックスと外部キー制約を削除して、 shop_id カラム自体も削除

これをコードにすると、以下のようになるでしょう。

public function up(Schema $schema) : void
{
    // staff テーブルを作成して、shop_id カラムにインデックスと shop テーブルへの外部キー制約を追加
    $this->addSql('CREATE TABLE staff (id INT AUTO_INCREMENT NOT NULL, shop_id INT NOT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_11111111111111 (shop_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
    $this->addSql('ALTER TABLE staff ADD CONSTRAINT FK_11111111111111 FOREIGN KEY (shop_id) REFERENCES shop (id)');

    // item テーブルに staff_id カラムを追加して、インデックスを作成(まだ外部キー制約は追加しない)
    $this->addSql('ALTER TABLE item ADD staff_id INT NOT NULL');
    $this->addSql('CREATE INDEX IDX_33333333333333 ON item (staff_id)');

    // shop テーブルを巡回して、各 shop に対して必要なだけ staff を作成
    $shops = $this->connection->query('SELECT id FROM shop')->fetchAll();
    foreach ($shops as $shop) {
        // shop ごとに作成すべき staff の情報を何かしら一覧などにしておくイメージ.
        foreach ($this->getStavesForShop($shop['id']) as $staff) {
            $this->addSql('INSERT INTO staff (shop_id, name) VALUES (:shop_id, :name)', [
                'shop_id' => $shop['id'],
                'name' => $staff['name'],
            ]);
        }
        // shop がもともと持っていた item それぞれについてどの staff に持たせるかを何かしらのロジックで決めるイメージ.
        $items = $this->connection->query('SELECT i.id FROM item i LEFT JOIN shop s ON i.shop_id = s.id WHERE s.id = :shop_id', [
            'shop_id' => $shop['id'],
        ])->fetchAll();
        foreach ($items as $item) {
            $this->addSql('UPDATE item SET staff_id = :staff_id WHERE id = :item_id', [
                'item_id' => $item['id'],
                'staff_id' => $this->getStaffForItem($item['id']),
            ]);
        }
    }

    // item テーブルの staff_id カラムに staff テーブルへの外部キー制約を追加(データが整理できたのでここで外部キー制約を追加する)
    $this->addSql('ALTER TABLE item ADD CONSTRAINT FK_33333333333333 FOREIGN KEY (staff_id) REFERENCES staff (id)');

    // item テーブルの shop_id カラムのインデックスと外部キーを削除して、shop_id カラム自体も削除
    $this->addSql('DROP INDEX IDX_22222222222222 ON item');
    $this->addSql('ALTER TABLE item DROP FOREIGN KEY FK_22222222222222');
    $this->addSql('ALTER TABLE item DROP shop_id');
}

private function getStavesForShop($shopId)
{
    // スタッフの情報を配列で返す
}

private function getStaffForItem($itemId)
{
    // スタッフの情報を返す
}

これで、既存データを適切に付け替えつつスキーマ変更ができるはずです👍

down() でも逆のことをすればいいだけ

同様に、 down() のほうもコードに落とし込むと以下のようなものになるでしょう。

public function donw(Schema $schema) : void
{
    // item テーブルに shop_id カラムを追加して、インデックスを作成 (まだ外部キー制約は追加しない)
    $this->addSql('ALTER TABLE item ADD shop_id INT NOT NULL');
    $this->addSql('CREATE INDEX IDX_22222222222222 ON item (shop_id)');

    // item テーブルの shop_id に、対応する shop の id をセット
    $this->addSql('UPDATE item a LEFT JOIN staff s ON a.staff_id = s.id SET a.shop_id = s.shop_id');

    // item テーブルの shop_id に外部キー制約を追加(ここで追加)
    $this->addSql('ALTER TABLE item ADD CONSTRAINT FK_22222222222222 FOREIGN KEY (shop_id) REFERENCES shop (id)');

    // item テーブルの staff_id カラムのインデックスと外部キー制約を削除して、staff_id カラム自体も削除
    $this->addSql('DROP INDEX IDX_33333333333333 ON item');
    $this->addSql('ALTER TABLE item DROP FOREIGN KEY FK_33333333333333');
    $this->addSql('ALTER TABLE item DROP staff_id');

    // staff テーブルを削除
    $this->addSql('DROP TABLE staff');
}
GitHubで編集を提案

Discussion