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

Doctrine2.3がすごくよくなってる

Symfony2.1を使っていて感動した!
Symfony2.0系ではformでEntityを利用した場合、同じform内に同一のEntityがあった場合でもデータは別途取得される仕様だった。

<?php
    $builder
            ->add('product1', 'entity', [
                'class' => 'Acme\\DemoBundle\\Entity\\Product',
            ])
            ->add('product2', 'entity', [
                'class' => 'Acme\\DemoBundle\\Entity\\Product',
            ])
            ->add('product3', 'entity', [
                'class' => 'Acme\\DemoBundle\\Entity\\Product',
            ]);

こんな感じでformを生成するとdoctrine2.1系では3つの同一のDQLを発行し、formにバインドする。
Symfony2.1(Doctrine2.3)系ではこれが1つのDQLのみ発行し、使いまわされるように効率化されている。

同じform内で同一のEntityを利用することはそんなにあるわけではないが、Doctrine2.1系のこういう部分を見るとほんといらっときていたので、個人的にはこれはかなり嬉しい変更点だった。

SwiftMailerでRFC違反のメールアドレスでもメールを送信したい

Symfony2の標準メールライブラリはSwift_Mailerが使われている。

メール送信を試していたが、どうも送信前にメールアドレスのチェックを行うらしくRFC違反しているメールアドレスだと例外が投げられて送れない…

例外が投げられる部分は下記の箇所

vendor/swiftmailer/lib/classes/Swift/Mime/Headers/MailboxHeader.php

  private function _assertValidAddress($address)
  {
    if (!preg_match('/^' . $this->getGrammar()->getDefinition('addr-spec') . '$/D',
      $address))
    {
      throw new Swift_RfcComplianceException(
        'Address in mailbox given [' . $address .
        '] does not comply with RFC 2822, 3.6.2.'
        );
    }
  }

とりあえずこのチェックを回避するオプションはないっぽいので、throwの部分をコメントアウトして強制的に送るしかないっぽい

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系の日本語記事が全然ないので結構苦労する…