🎻

[Symfony] Doctrine Migrationsでデータ更新のマイグレーションスクリプトを書く

2020/04/19に公開

Symfonyで DoctrineMigrationsBundle は導入してるけど、

$ bin/console doctrine:migrations:diff
$ bin/console doctrine:migrations:migrate

しか使ってない人向けの記事です。

スキーマの変更だけでなく、既存データの整形などをマイグレーションスクリプトで行いたいこともある

例えば json DBAL Typeのカラムを array DBAL Typeに変更するようなマイグレーションを行うとします。

bin/console doctrine:migrations:diff すると、以下のようなマイグレーションスクリプトが作られます。

final class Version20200418144157 extends AbstractMigration
{
    public function getDescription() : string
    {
        return '';
    }

    public function up(Schema $schema) : void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('ALTER TABLE user CHANGE roles roles LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\'');
    }

    public function down(Schema $schema) : void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL');
    }
}

この例では、 user テーブルの roles カラムを変更しています。

カラムの型を JSON から LONGTEXT に変更して '(DC2Type:array)' とDoctrine用のコメントを追加していますね。

このマイグレーションスクリプトを実行すれば、確かにスキーマは適切に変更できます。

が、もしすでに roles カラムにjson型式のデータが入っていたら、スキーマ変更後にはアプリからデータが正しく読み出せなくなってしまいます。

なぜなら、DBAL Typeが json の場合と array の場合とで、Doctrineが期待しているDBの実データの形式が異なるからです。

json の場合は、 ["ROLE_ADMIN"] のようなjson型式の文字列がDBに入っていることを期待しているのに対し、 array の場合は a:1:{i:0;s:10:"ROLE_ADMIN";} のように serialize された文字列を期待しています。

スキーマだけを array に変更して中身が json のままだと、Doctrineは ["ROLE_ADMIN"] という文字列を unserialize しようとして、当然エラーになります。

つまり、こういう場合はマイグレーションスクリプトで スキーマの変更だけでなくデータの整形も一緒に実施してあげる必要がある のです。

具体的なやり方

やり方はとても簡単です。今回の場合なら、マイグレーションスクリプトに postUp()preDown() メソッドの実装を追加して、その中で既存のデータを更新するスクリプトを実行するようにすればよいです。

具体的には、以下のようなコードで実現できます。

final class Version20200418144157 extends AbstractMigration
{
    // ...

    public function postUp(Schema $schema): void
    {
        $paramSets = [];
        $users = $this->connection->query('SELECT id, roles FROM user')->fetchAll();
        foreach ($users as $user) {
            $paramSets[] = [
                'id' => $user['id'],
                'roles' => serialize(json_decode($user['roles'])), // ここがポイント
            ];
        }
        
        foreach ($paramSets as $paramSet) {
            $this->addSql('UPDATE user SET roles = :roles WHERE id = :id', $paramSet);
        }
    }

    public function preDown(Schema $schema): void
    {
        $paramSets = [];
        $users = $this->connection->query('SELECT id, roles FROM user')->fetchAll();
        foreach ($users as $user) {
            $paramSets[] = [
                'id' => $user['id'],
                'roles' => json_encode(unserialize($user['roles'])), // ここがポイント
            ];
        }
        
        foreach ($paramSets as $paramSet) {
            $this->addSql('UPDATE user SET roles = :roles WHERE id = :id', $paramSet);
        }
    }
}

// ここがポイント と書いた箇所がポイントです。

もともとDBに入っていたデータを、

  • postUp() では 1. json_decode() してから 2. serialize() している
  • preDown() では 1. unserialize() してから 2. json_encode() している

ことが分かるでしょう。

つまり、

  • up の際は
    • まずスキーマが json から array に変更される
    • その後、もともとjson型式だった実データをserializeされた型式に変換して更新する
  • down の際は
    • まずserializeされた型式の実データをjson形式に変換して更新する
    • その後、スキーマが array から json に変更される

ということが起こります。

スキーマが json の状態でserializeされた型式の文字列を書き込もうとするとDBレイヤーで「正しいjson型式じゃないですよ」というエラーになってしまうので、順番に要注意です。

ちなみに、今回はデータ整形の内容がスキーマ変更に関連したものだったので postUp()preDown() の中で行いましたが、データ整形のマイグレーションスクリプトを別ファイル(スキーマ変更の直後のバージョン)にしてしまっても別に構いません。

おまけ:SQLの実行方法について

上記の例で、データ更新のためのSQLは以下のように $this->addSql() メソッドを使って実行しました。

foreach ($paramSets as $paramSet) {
    $this->addSql('UPDATE user SET roles = :roles WHERE id = :id', $paramSet);
}

ここも、 SELECT の部分と同じように直接 $this->connection を触って実行させることも出来るには出来ます。

$this->connection->beginTransaction();
try {
    $updateStatement = $this->connection->prepare('UPDATE user SET roles = :roles WHERE id = :id');
    foreach ($paramSets as $paramSet) {
        $updateStatement->execute($paramSet);
    }

    $this->connection->commit();
} catch (\Throwable $e) {
    $this->connection->rollBack();
    throw $e;
}

が、これだと --dry-run オプションをつけていても実際に UPDATE クエリが走ってしまう ので、更新系のクエリは $this->addSql() に登録して遅延実行させるようにするべきでしょう。

まとめ

  • Doctrine Migrationのマイグレーションスクリプトは自由にSQLを書けるので、スキーマの変更だけでなくデータの整形をしたい場合は手動でスクリプトを書けばOK
  • preUp() postUp() preDown() postDown() といったメソッドが用意されているので、上手く使うときれいに書ける
GitHubで編集を提案

Discussion