読者です 読者をやめる 読者になる 読者になる

Symfony2でEntityの更新前に処理を挟んでみる

よくある登録日時や更新日時は毎回書くのは面倒臭いので、自動で入れたかったりしますね。
Symfony2でもやりたかったので調べて見た。

下記の方法を用いれば出来るっぽい。

  • ライフサイクルコールバックを使う
  • イベントリスナーを使う

ライフサイクルコールバックは非常に簡単なので、このへんを参照してください。
で、今回はイベントリスナーを使って実装しました。

別にライフサイクルコールバックでもよかったんですが、全部のEntityに書くのは面倒くさいのと、登録者・更新者情報も入れたくてコンテナを経由してユーザー情報を取ってきたかったのでイベントリスナーで作ったほうがスマートかなと思ってそっちでやりました。

まずはこのへんを読みながらprePersistで登録日時を入れてみた。
すげー簡単に出来た!

でも自分は登録者情報も入れたかったので下記のように変更。

config/services.yml

services:
    # Persist時に呼び出されるイベント
    my.listener.pre.persist:
        class: Csp\Bundle\AdminBundle\Listener\EntityListener
        arguments: ['@service_container']
        tags:
            - { name: doctrine.event_listener, event: prePersist }

    # Update時に呼び出されるイベント
    my.listener.pre.update:
        class: Csp\Bundle\AdminBundle\Listener\EntityListener
        arguments: ['@service_container']
        tags:
            - { name: doctrine.event_listener, event: preUpdate }

ユーザー情報を取りたいので、argumentsでコンテナを渡すように設定。
で、次に肝心のイベントリスナークラスの実装。

<?php
// ...

use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\DependencyInjection\ContainerInterface;

class Product
{
    protected $container;

    /**
     * Construct
     *
     * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
     */
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    /**
     * prePersist event
     *
     * @param \Doctrine\ORM\Event\LifecycleEventArgs $args
     */
    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        $entityManager = $args->getEntityManager();

        // createdAtプロパティがあれば登録日時を自動で入れる
        if (property_exists($entity, 'createdAt')) {
            $entity->setCreatedAt(new \DateTime());
        }

        // createdUserプロパティがあれば登録ユーザーを自動で入れる
        if (property_exists($entity, 'createdUser')) {
            $user = $this->container->get('security.context')->getToken()->getUser();
            $entity->setCreatedUser($user);
        }
    }

    // ...
}

これで登録時には登録日時と登録ユーザー情報がちゃんと入った。
これと同じノリでpreUpdateメソッドを実装して見たんだけど、Update時に呼び出されるけどデータベースの値が書き換えられない。

なんでだーとか悩んでてもしょうがないので、ドキュメントを読んでみる。
英語なんて読めないので、google翻訳先生とコードを見ながらああ、こんな感じにやるのねっていうのを把握。
で、下記のようにしてみました。

<?php
// ...

    public function preUpdate(LifecycleEventArgs $args)
    {
        $user = $this->container->get('security.context')->getToken()->getUser();

        $args->setNewValue('updatedAt', new \DateTime());
        $args->setNewValue('updatedUser', $user);
    }

で、動かしてみると下記のようなエラーが発生。

Field 'updatedAt' is not a valid field of the entity 'My\Bundle\DemoBundle\Entity\Product' in PreInsertUpdateEventArgs.

なんかこのアクションで、変更されたプロパティを更にバリデーションしたり値を変えたりは出来るけど、何も変更がないプロパティはまずそもそもchangedFieldとして扱われてない。
じゃあどうすりゃいいんだーとか言いながら更に調べてみるが分からないので、ちゃんとドキュメントを読みなおしてみる。

Changes to associations of the updated entity are never allowed in this event, since Doctrine cannot guarantee to correctly handle referential integrity at this point of the flush operation. This event has a powerful feature however, it is executed with a PreUpdateEventArgs instance, which contains a reference to the computed change-set of this entity.

http://www.doctrine-project.org/docs/orm/2.1/en/reference/events.html#preupdate

なんか修正したいなら再計算しろとか書かれてる?
で、もっと調べてみてようやく分かったのはUnitOfWorkを使って再計算すれば良いっぽい。
下記が直したコード。

<?php
// ...

    public function preUpdate(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        $em = $args->getEntityManager();

        $recompute = false;

        // updatedAtプロパティがあれば更新日時を自動で入れる
        if (property_exists($entity, 'updatedAt')) {
            $entity->setUpdatedAt(new \DateTime());
            $recompute = true;
        }

        // updatedUserプロパティがあれば更新ユーザーを自動で入れる
        if (property_exists($entity, 'updatedUser')) {
            $user = $this->container->get('security.context')->getToken()->getUser();
            $entity->setUpdatedUser($user);
            $recompute = true;
        }

        if ($recompute) {
            // 再計算
            $meta = $em->getClassMetadata(get_class($entity));
            $em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
        }
    }

ちゃんと動いた!

一応まとめ。

  • prePersistの場合はEntityをそのまま加工すればいい
  • preUpdateの場合、changedFieldにプロパティが含まれない場合、UnitOfWorkを使って再計算する

Doctrine2系の日本語記事が全然ないので結構苦労する…