Symfony Advent Calendar 2016 22日目の記事です。

はじめに

Doctrineは多機能で私も開発においてはたいへん便利に使っています。反面、多機能がゆえにどの機能が何を提供してくれるのか把握できていないために苦労することもしばしばです。

ここでは私がいつもどれがなにを示すのかわからなくて毎回ドキュメントを見ながら使っていたcascade={"remove"}onDelete="CASCADE"orphanRemoval=trueの記述がもたらす各振舞についてこの場を借りて整理してみたいと思います。

前提

  • エンティティとしてUserとProfileがあり、UserがProfileを1つ所有しているとする
  • 以下のapp.phpテストスクリプト(注目箇所のみ抜粋)にて、振舞を検証する
<?php
// app.php

//...

// データ登録
$user = new User();
$profile = new Profile();
$user->setProfile($profile);
$profile->setUser($user);

$em->persist($profile);
$em->persist($user);
$em->flush();

// データ削除
$em->remove($user);
$em->flush();

これ以降、エンティティの定義方法を変更することでテストスクリプトの振舞にどう影響するのかを確認してみます。

1. プレーンな状態

まずは考えうるもっともプレーンな状態でエンティティを定義してみます。

<?php

class User
{
    //...
    
    /**
     * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
    
    //...
}

エンティティ定義をもとにスキーマを作成後、テストスクリプトを実行するとエラーが送出され、完遂しません。

# スキーマを作成
$ ./vendor/bin/doctrine orm:schema-tool:create

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

実行時に走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

テストスクリプトのコードではUserエンティティだけを削除しようとしています。しかし、DBにおいてはProfile.user_idからUser.idに外部キーが貼られているため、紐付いた親にあたるUserエンティティが存在しなくなる矛盾からエラーになっていますね。

2. cascade={“remove”}

そこでcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新後テストスクリプトを実行してみます。エラーになることなく完遂しました。 ちなみに走ったクエリは以下です。DELETE FROM Profile WHERE id = 1;はさきほどは見られなかったクエリですね。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

これはテストスクリプトが以下のように書き換えられたのと同様の動きといえます。

// app.php

//..

// データ削除
+ $em->remove($profile);
$em->remove($user);
$em->flush();

つまりcascade={"remove"}におけるremoveEntityManagerに生えているremoveメソッドを指す識別子だったことがわかります。remove以外にもpersistmergeなどが定義できますし複数の定義も可能です。ちなみに各々のメソッドがどのような振舞をするかについては 7. Working with Objects / Doctrine 2 ORM 2 documentation に詳しいです。

3. onDelete=”CASCADE”

removedeleteは同義ですしcascadeという文言もありますし、cascade={"remove"}とやりたいことは似ているように感じます。 実際に動きを見てみましょう。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;
    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id")
+    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
     */
    private $user;
}

上記のように書き換えた後、ここではひとまずスキーマの更新を行わずテストスクリプトを実行してみます。さきほどと同じように外部キーエラーが送出され完遂しません。

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

ちなみに走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

1. プレーンな状態のときと同様ですね。onDelete="CASCADE"は何もしてくれないのでしょうか?そんなはずはありません。以下のようにスキーマを更新してみます。

$ ./vendor/bin/doctrine orm:schema-tool:update --force
 :
ALTER TABLE Profile DROP FOREIGN KEY FK_4EEA9393A76ED395
ALTER TABLE Profile ADD CONSTRAINT FK_4EEA9393A76ED395 FOREIGN KEY (user_id) REFERENCES User (id) ON DELETE CASCADE

再度テストスクリプトを実行すると無事に完遂しました。ちなみに走ったクエリは以下です。エラーになったさきほどと変わりありません。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

つまりonDelete="CASCADE"はDoctrineがORMとしてよしなに何かをしてくれるためではなく、DBのレイヤで対応するようにDDLを生成するための記述でしかないということですね。

4. orphanRemoval=true

次はOrphan Removalです。とりあえず追記して使ってみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
+    * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新してからテストスクリプトを実行してみると以下のクエリが走り、所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

cascade={"remove"}と同様の動きのように見えますね。ではどちらのように書いても同じなのでしょうか?

cascade={“remove”}とorphanRemoval=trueの違い

テストスクリプトの仕様を以下のコードのように「ユーザに新たなプロフィールをセットする」というように変更してみます。

<?php
// app.php

//...

- // データ削除
- $em->remove($user);
+ // 新たなプロフィールをセット
+ $profile2 = new Profile();
+ $user->setProfile($profile2);
+ $em->persist($profile2);
$em->flush();

まずは以下のようにcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

さっそくテストスクリプトを実行してみます。走ったクエリは以下です。DELETE文は発行されていませんのでこれは所望の振舞ではありません。

INSERT INTO User (id) VALUES (null)
INSERT INTO Profile (user_id) VALUES (1)
INSERT INTO Profile (user_id) VALUES (NULL)

そこでorphanRemoval=trueに戻してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

テストスクリプトを実行してみると以下のクエリが走ります。これで所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
INSERT INTO Profile (user_id) VALUES (NULL)
DELETE FROM Profile WHERE id = 1;

以上から、cascade={"remove"}が親が削除されることでそれに伴い子も削除されるのに対して、orphanRemoval=trueは親そのものが存在しても子との関係が断たれた状態になった時点で子が削除されるようですね。

まとめ

cascade={"remove"}onDelete="CASCADE"orphanRemoval=true それぞれの記述のもたらす振舞をそれぞれ確認してみました。目的が同様に見えても状態によっては別の振舞を行うことが明確になりました。 DBに近い振舞はパフォーマンスへの影響も小さくないこともあり手法の選定は慎重になりたいですね。

参考