Symfony2でTwigを文字列から読み込みたい

昼間にTwigローダーを処理中に変更し、文字列をTwigに渡してリソースを生成する方法を書いたけど、
単純なテンプレート機能として使うのであれば全然問題ないが、form_widgetとか使う場合だと昼間の方法だと無理だった。

とりあえず文字列ベースのTwigLoaderでform_widgetとかも使えるバンドルを作ったので置いておきます。
Twigの内部処理の勉強にかなりなったなー

ryster/HaouTwigBundle · GitHub

利用方法は下記の通り。

// ソース
$source = '{% form_theme form "AcmeDemoBundle:Form:form_layout.html.twig" %}  {{ form_rest(form) }}';

$render = $this->get('haou_twig')->render($source, $context);
return new Response($render);

Symfony2のコントローラ内でTwigローダーを変更する

コントローラでの処理中にファイルからではなく文字列からTwigに渡してレスポンスを生成したかった。
普通にTwig_Environmentを生成してやれば簡単に出来たんだけど、assetsとか使いたくてExtensionも引き継ぎたかったので途中で変更する方法を取った。

こんな感じ。

$this->get('twig')->setLoader(new \Twig_Loader_String());
$resource = $this->get('twig')->render("{{ asset('hogehoge') }}");

return new Response($resource);

Symfony2のエラーページをカスタマイズしてみる

Symfony2でエラーページをカスタマイズしたかったのでやり方を調べてみた。

エラーページのカスタマイズ方法 | Symfony2日本語ドキュメント

エラーページをカスタマイズするには下記の2種類の方法があるみたい。

  1. エラーテンプレートのカスタマイズ
  2. 例外処理のカスタマイズ

1の方法は上記のドキュメントにも載っている通り非常に簡単にできるけど自由度が効かないので、今回は2の方法を試してみた。

例外処理をカスタマイズするには、まず初めに例外処理をするクラスをサービス登録します。
この時、kernel.exceptionイベント発生時に動くようにeventにはkernel.exceptionを設定します。

src/Acme/DemoBundle/Resources/config/services.yml

custom.exception.listener:
    class: Acme\DemoBundle\Listener\CustomExceptionListener
    arguments: ['@templating']
    tags:
        - { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

ここまで設定したら次は実際に例外処理を行うクラスを作成します。
自分は管理画面の時だけこの処理を実行したかったので、URLが/adminの時だけ処理されるように設定しています。
また、Securityコンポーネントでユーザーと権限管理を行なっているので、権限が足りなかった際のページも用意しています。

src/Acme/DemoBundle/Listener/CustomExceptionListener.php

<?php
namespace Acme\DemoBundle\Listener;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Bundle\TwigBundle\TwigEngine;

class CustomExceptionListener
{
    /**
     * @var TwigEngine
     */
    protected $templating;


    /**
     * __construct
     *
     * @param \Symfony\Bundle\TwigBundle\TwigEngine $templating
     */
    public function __construct(TwigEngine $templating)
    {
        $this->templating = $templating;
    }

    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        static $handling;

        $exception = $event->getException();

        if (true === $handling) {
            return;
        }

        if (method_exists($exception, 'getStatusCode')) {
            $code = $exception->getStatusCode();
        }

        // URLが/adminの時だけ実行
        if (0 === strpos($event->getRequest()->getPathInfo(), '/admin')) {
            $handling = true;

            // Securityコンポーネントで権限が足りないときの処理
            if ($exception instanceof AccessDeniedHttpException) {
                $message = $this->templating->render('AcmeDemoBundle:Exception:access_denied.html.twig');
            }

            // HTTP 404 Status
            if (isset($code) && 404 == $code) {
                $message = $this->templating->render('AcmeDemoBundle:404.html.twig', array());
                $response = new Response($message, $code);
                $event->setResponse($response);
            }

            if (isset($message)) {
                $response = new Response($message, $code);
                $event->setResponse($response);
            }
        }

        $handling = false;
    }
}

ここまで出来たら後はテンプレートを用意してあげれば完了。
もっと色々エラー内容を拾って処理したいって人は元の例外処理クラスを参考にしてみるといいかもしれません。
元々の例外処理をしているクラスは下記のファイルです。

src/Symfony/Component/HttpKernel/EventListener/ExceptionListener.php

Symfony2でform名を指定する方法

Symfony2でEntityに依存しない同一のformを複数発行したくて、form名を動的に変更する方法を調べてみたので忘れないうちのメモっとく。
結果的にはcreateNameBuilderを通してFormBuilderを発行してあげればいけた。

for ($i = 1;$i <= 3;$i++) {
    $builder = $this->get('form.factory')->createNamedBuilder('form', 'sampleForm' . $i);
}

createNamedBuilderの引数はこんな感じ。

    /**
     * Returns a form builder.
     *
     * @param string|FormTypeInterface  $type       The type of the form
     * @param string                    $name       The name of the form
     * @param mixed                     $data       The initial data
     * @param array                     $options    The options
     *
     * @return FormBuilder The form builder
     *
     * @throws FormException if any given option is not applicable to the given type
     */
    public function createNamedBuilder($type, $name, $data = null, array $options = array())
    {
        // ...
    }

Symfony2のDateTypeのバグ対策

※追記 (2012/02/29 20:40)
というかそもそも、下記を実装した後に気づいたんですが、date_patternをオーバーライドするフォームテーマなんて作成しないでも直接オーバーライド出来るんじゃないか?と思って試してみたら出来ました。

特にテンプレートとか作らずに下記のように指定すれば出来ます。

{{ form_widget(form.sample_date, { 'date_pattern': '{{ year }}年{{ month }}月{{ day }}日' }) }}


id:shimookaさんのブログでDateTypeにバグがあるという記事があがっていたので試してみました。

実際にテストコードを書いて確認してみると確かにformatにyyyy年MM月dd日を指定しているのに無視される。

id:shimookaさんの記事には対応策が載っているが、Symfony2のコアコードを変更するのはあまりスマートではないので、フォームテーマを作成して対策してみました。

デフォルトのフォームテーマを確認すると、下記のようになってます。

vendor/symfony/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig

{% block date_widget %}
{% spaceless %}
    {% if widget == 'single_text' %}
        {{ block('field_widget') }}
    {% else %}
        <div {{ block('widget_container_attributes') }}>
            {{ date_pattern|replace({
                '{{ year }}':  form_widget(form.year),
                '{{ month }}': form_widget(form.month),
                '{{ day }}':   form_widget(form.day),
            })|raw }}
        </div>
    {% endif %}
{% endspaceless %}
{% endblock date_widget %}

date_patternという変数には{{ year }}-{{ month }}-{{ day }}という文字列が入っています。
なので、ここを独自のフォームテーマを作成して、オーバーライドしてあげればSymfony2のコアコードを変えずに実装できます。

まずは下記のようにデフォルトのフォームテーマを元にdate_widgetのみ拡張するフォームテーマを作成します。

src/My/Bundle/DemoBundle/Resources/views/Form/form_layout.html.twig

{% extends "form_div_layout.html.twig" %}

{% block date_widget %}
{% spaceless %}
    {# _date_patternが定義されていれば、date_patternをオーバーライド #}
    {% if _date_pattern is defined %}
        {% set date_pattern = _date_pattern %}
    {% endif %}

    {% if widget == 'single_text' %}
        {{ block('field_widget') }}
    {% else %}
        <div {{ block('widget_container_attributes') }}>
            {{ date_pattern|replace({
                '{{ year }}':  form_widget(form.year),
                '{{ month }}': form_widget(form.month),
                '{{ day }}':   form_widget(form.day),
            })|raw }}
        </div>
    {% endif %}
{% endspaceless %}
{% endblock date_widget %}

_date_patternが定義されていればdate_patternをオーバーライドするという処理をdate_widgetブロックの直下に追加しました。


後は実際のフォームを利用するテンプレートで下記のようにしてフォームを呼び出すと独自の形式でフォームが呼び出せるようになります。

{% form_theme form "MyDemoBundle:Form:form_layout.html.twig" %}

{{ form_widget(form.sample_date, { '_date_pattern': '{{ year }}年{{ month }}月{{ day }}日' }) }}

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