[Symfony] Doctrine Migrationsでデータ更新のマイグレーションスクリプトを書く
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()といったメソッドが用意されているので、上手く使うときれいに書ける
Discussion