🎻

[Symfony] DoctrineのpreUpdateで他のエンティティの生成をやろうとしたけどできなかった話(解決策あり)

2020/06/22に公開

やりたかったこと

  • Foo というエンティティの state というプロパティが変更されたら、その変更内容に応じて Bar というエンティティを自動で作成する

ということがやりたくて、 FoopreUpdate のタイミングで $entityManager->persist($bar) すればいいかと思ったらできませんでした🙄

なぜできないのか

Doctrineの公式ドキュメント を見ると、

PreUpdate is the most restrictive to use event, since it is called right before an update statement is called for an entity inside the EntityManager#flush() method. Note that this event is not triggered when the computed changeset is empty.

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.

とあり、 PreUpdateflush() メソッド内で呼ばれる性質上、このタイミングで関連エンティティの更新はできない仕様のようです。

ちなみにググると

symfony - Persisting other entities inside preUpdate of Doctrine Entity Listener - Stack Overflow

とかが見つかって、「 preUpdate じゃなく onFlush でならできるよ〜」「 getEntityChangeSet() を使えば変更されたプロパティも分かるよ〜」などと書かれているんですが、実際に onFlush でやってみたらやっぱり persist したものがDBに保存されないし、 getEntityChangeSet() の結果も空になります。

どうすればできるのか

preUpdate のタイミングで persist するだとダメなので、コントローラから自分でイベントをディスパッチして自作のEventListenerまたはEventSubscriberで persist するようにすれば目的は果たせます。

具体的なコードのイメージは以下のような感じです。

コントローラ

/**
 * @Route("/foo/{id}/edit", name="foo_edit", methods={"GET","POST"})
 */
public function edit(Request $request, Foo $foo, EntityManagerInterface $em, EventDispatcherInterface $dispatcher)
{
    $form = $this->createForm(FooType::class, $foo);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {

        // stateが変更されていたらイベントをディスパッチ.
        $em->getUnitOfWork()->computeChangeSets();
        $changeSets = $em->getUnitOfWork()->getEntityChangeSet($foo);
        if (isset($changeSets['state']) && $changeSets['state'][0] !== $changeSets['state'][1]) {
            $dispatcher->dispatch(new StateChangedEvent($foo));
        }

        $this->em->flush();

        $this->addFlash('success', '編集が完了しました。');

        return $this->redirectToRoute('foo_show', ['id' => $foo->getId()]);
    }

    return [
        'foo' => $foo,
        'form' => $form->createView(),
    ];
}

Foostate が変更されているかどうかを調べるために、 UnitOfWork から getEntityChangeSet() で変更内容を取得しています。( preUpdate なら hasChangedField() で一撃なのにな〜と思いながら)

getEntityChangeSet() する前に $em->getUnitOfWork()->computeChangeSets(); を先に実行しているところがポイントです。これをやらないとこの時点ではまだチェンジセットが空になっています。

参考:php - Is there a built-in way to get all of the changed/updated fields in a Doctrine 2 entity - Stack Overflow

ちなみに UnitOfWork とかがピンと来ない方は 後藤さんのこのポスト がめちゃめちゃ分かりやすいのでぜひ読んでみてください。

EventSubscriber

class FooSubscriber implements EventSubscriberInterface
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function onStateChanged(StateChangedEvent $event)
    {
        $foo = $event->getFoo();

        $bar = new Bar();

        // ... $foo->getState() の値に応じてBarの内容を設定.

        $this->em->persist($task);
    }

    public static function getSubscribedEvents()
    {
        return [
            StateChangedEvent::class => 'onStateChanged',
        ];
    }
}

Event

class StateChangedEvent extends Event
{
    /**
     * @var Foo
     */
    private $foo;

    public function __construct(Foo $foo)
    {
        $this->foo = $foo;
    }

    public function getFoo(): Foo
    {
        return $this->foo;
    }
}

まとめ

  • Doctrineの preUpdate で他のエンティティの生成をやろうとしたけどできなかった
  • タイミングの問題なので、コントローラから自分でイベントをディスパッチして自作のEventListenerまたはEventSubscriberでエンティティの生成と persist をすれば目的は果たせる
  • 今回は複数のコントローラアクションでイベントをディスパッチする必要があったので、イベントディスパッチのコードが複数箇所に散らばるのが嫌だったけど、どうしようもなかった…
GitHubで編集を提案

Discussion