Symfony の ChoiceToValueTransformer に関して調べてみた

この記事は [Symfony Advent Calendar 2018](https://qiita.com/advent-calendar/2018/symfony) 17日目の記事です。そして去年のアドベントカレンダーに投稿して以来、約１年ぶりの Qiita の投稿です :joy_cat:

この一年は PHP を触る機会がめっきり減ってしまい、この記事を書くため６ヶ月？１０カ月？ぶりに "composer" という文字をタイピングしました。Symfony もかなり久しぶりに触ったので「あれ、どうするんだっけ」と迷う感じが新鮮でした。

アドベントカレンダーに登録したものの、Symfony ネタが全くなくて、困っていたんですが、先月、Symfony でアプリケーションを開発している友人から Form に関する相談を受けたので、その話を書きたいと思います。結論から言うと、ちょっと上手い方法が見つけられなかったので悔しい :confounded:

誰かいい方法あったら教えてください :pray: 

## 結論

- ChoiceType で予めセットしていない選択肢は `ChoiceToValueTransformer`　で例外発生してエラーになる
- そんなときは `resetViewTransformers()` で Transformer をリセットすることで回避可能
- でもちょっと微妙。もっといい方法がありそう。（WIP）

## 相談内容

相談された内容は以下でした。

- とある管理画面のフォームでセレクトボックスを作った。
- ユーザーはそのセレクトボックスの選択肢を動的に増やすことができる。
- JS で選択肢を増やすと、FormType で設定されていない選択肢はエラー発生
    -  `This value is not valid.`
    -  あるある
- 最終的には、フォームの送信後に EventListener で設定されていない値を追加する、という方法で対応。
- 社内用管理画面の機能であり、どんな値が登録できても問題ない、という前提で OK

確かに、こういうときどうすんだろうって思って調べてみることに。

## 対象のコード

今回、対象とするサンプルコードはこんな感じです。

```php
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('task')
            ->add('status', ChoiceType::class, [
                'choices'  => [
                    'backlog' => null,
                    'doing'   => false,
                    'done'    => true,
                ]])
            ->add('dueDate', null, array('widget' => 'single_text'))
            ->add('save', SubmitType::class)
        ;
    }
}
```

```php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;

use App\Entity\Task;
use App\Form\TaskType;

class TaskController extends AbstractController
{
    public function form(Request $request)
    {
        $task = new Task();
        $form = $this->createForm(TaskType::class, $task);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // SUCCESS
        }

        return $this->render('task/form.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}
```

## 方針

まず、このエラーはどのように発生しているのでしょうか。ざっくり追ってみました。

1. コントローラーで `$form->handleRequest($request)` 
2. `ChoiceToValueTransformer` の `reverseTransform()` が実行され `TransformationFailedException` が投げられる。 [ChoiceToValueTransformer.php#L48](https://github.com/symfony/form/blob/40861df3284d69b5410e02668d7b4bbfcd9afc32/Extension/Core/DataTransformer/ChoiceToValueTransformer.php#L48)
3. その例外をよしなに拾ってエラーがハンドリングされる（雑）

このあたりって Transformer が担当しているんですね。知ってみると「まぁ、そうだよね」って話なんですが、ちゃんと認識したことなかったので勉強になりました。

JS で追加された選択肢を Transformer が実行される前に追加してあげたり、Transformer 自体を変更・拡張してあげればよさそう。

## 対応例

### 1. EventListener で対応

EventListener でダイナミックにフォームを変更する方法です。こんな感じで新たに追加された選択肢（ここでは `pending => 4`）を追加してあげれば OK です。

```php
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
        ;

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) {
                $form = $event->getForm();
                $statusConfig = $form->get('status')->getConfig();
                $statusOptions = $statusConfig->getOptions();

                $form->add('status', ChoiceType::class, array_replace(
                    $statusOptions,
                    ['choices' => array_merge($statusOptions['choices'], ['pending' => 4])]
                    // pending => 4 はリクエストから取得する
                    // 時間なかったので Request の取得はちょっと省略（汗）
                ));
            }
        );
    }
```

より詳しい方法は公式ドキュメントで紹介されていますので参考にしてみてください。

- [How to Dynamically Modify Forms Using Form Events (Symfony Docs)](https://symfony.com/doc/current/form/dynamic_form_modification.html)

### 2. resetViewTransformers でアンセットする

`resetViewTransformers()` というメソッドで登録されている全ての ViewTransformer をリセットできるって！知らなかった。

```php
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
        ;

       $builder->get('status')->resetViewTransformers();
    }
```

こんな感じで ViewTransformer をアンセットしてくれるようですね。

```php
    public function resetViewTransformers()
    {
        if ($this->locked) {
            throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.');
        }

        $this->viewTransformers = array();

        return $this;
    }
```

### 3. ChoiceToValueTransformer 以外は再セットする

`resetViewTransformers()` は全ての ViewTransformer をアンセットしてしまうので、思わぬ影響がありそうでちょっと怖い。そんなときは必要な Transformer があるようなら再セットするとか。

```php
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
        ;

        $field = $builder->get('status');
        $transformers = $field->getViewTransformers();
        $field->resetViewTransformers();
        array_map(function($transformer) use ($field) {
            if (!$transformer instanceof ChoiceToValueTransformer) {
                $field->addViewTransformer($transformer);
            }
        }, $transformers);
    }
```

が、ちょっとビミョ、、、もうちょいうまいこと書けそうな気もするんですが、、、。できたら、全てをぶっ飛ばす `resetViewTransformers()` を呼ばずに処理したい。けど、登録された Transformer を削除するメソッドはこれ以外になさそう（？）。

## WIP

１〜２時間ほど調べてみてやってみたのですが、もうちょっと良い書き方がありそうなところでギブアップ！デフォルトで追加される Transformer を変更したり削除したり、その Transformer 自体を拡張や上書きするような方法がないのかな。あるいは選択肢を自由に受け付けれるオプションとか。時間を作って、もう少し調査してみます。

もっと良い方法があったら教えていただけると嬉しいです！久しぶりの　Symfony でしたが、Symfony4 になってますます便利になった Symfony をもっと触ってみようって気持ちになりました。2019年は機会を作ってもうちょっと触っていきたいです。

以上です :smile_cat: