🎻

[Symfony] コレクションプロパティのバリデーションにおいて子オブジェクト側のNotBlank制約が無視されるケースについて

2020/12/05に公開

はじめに

Symfony Advent Calendar 2020 の5日目の記事です!🎄🌙

昨日は @77web さんの Symfonyにコントリビュートしよう〜SymfonyWorld Hackday〜 でした✨

この記事では、エンティティのプロパティがオブジェクトのコレクションをとる場合に、その子オブジェクト側に @Assert\NotBlank() が使われていると意図したとおりにバリデーションされないケースがあるよ( @Assert\Valid() を付けていても意図しない挙動をすることがあるよ)という話をします。

ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲

問題が起こらない例:子オブジェクトに @Assert\NotBlank() がない

まずは、特に問題が起こらない普通のケースをおさらいとして見てみましょう。

以下のコードは、

  • Person#profilesProfile のコレクションになっている
  • Profile#email には @Assert\Email() 制約が付いている
  • Person#profiles は必須ではない(1つもプロフィールがないデータも許容される)

という例です。

class Person
{
    /**
     * @Assert\Valid()
     */
    public Collection $profiles;
    
    public function __construct()
    {
        $this->profiles = new ArrayCollection();
    }
}
class Profile
{
    public ?string $name = null;
    
    /**
     * @Assert\Email()
     */
    public ?string $email = null;
}
class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('profiles', CollectionType::class, [
                'required' => false,
                'label' => 'プロフィール',
                'entry_type' => ProfileType::class,
                'prototype' => true, // プロトタイプを使ったフロント側の処理についての説明はここでは割愛します
                'allow_add' => true,
                'allow_delete' => true,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Person::class,
        ]);
    }
}
class ProfileType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'required' => false,
                'label' => '名前',
            ])
            ->add('email', EmailType::class, [
                'required' => false,
                'label' => 'メールアドレス',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Profile::class,
        ]);
    }
}

このように、 Person#profiles@Assert\Valid() をアノテートしておけば、

  • プロフィールが複数送信されてきても、その一つひとつの email 項目に対してちゃんと @Assert\Email() が効いてバリデーションが行われる
  • もちろんプロフィールが1つもないデータも許容される

という意図したとおりの動作になります👍

問題が起こる例:子オブジェクトに @Assert\NotBlank() がある

では次に、 Profile#name必須項目 にしてみましょう。

実現したい要件は、

  • Person#profilesProfile のコレクションになっている
  • Profile#name には @Assert\NotBlank() 制約が付いていて必須項目となっている
  • Profile#email には @Assert\Email() 制約が付いている
  • Person#profiles は必須ではない(1つもプロフィールがないデータも許容される)

です。

普通に考えると以下のようなコードを書きたくなると思います。

class Person
{
    /**
     * @Assert\Valid()
     */
    public Collection $profiles;
    
    public function __construct()
    {
        $this->profiles = new ArrayCollection();
    }
}
class Profile
{
    /**
     * @Assert\NotBlank()
     */
    public ?string $name = null;
    
    /**
     * @Assert\Email()
     */
    public ?string $email = null;
}
class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('profiles', CollectionType::class, [
                'required' => false,
                'label' => 'プロフィール',
                'entry_type' => ProfileType::class,
                'prototype' => true,
                'allow_add' => true,
                'allow_delete' => true,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Person::class,
        ]);
    }
}
class ProfileType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'label' => '名前',
            ])
            ->add('email', EmailType::class, [
                'required' => false,
                'label' => 'メールアドレス',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Profile::class,
        ]);
    }
}
  • Person#profiles には @Assert\NotBlank() は付けない
  • Person#profiles のフォームフィールドには 'required' => false を付ける
  • Profile#name には @Assert\NotBlank() を付ける
  • Profile#name のフォームフィールドには 'required' => false は付けない

という実装をしました。

これで、意図したとおりに動作するでしょうか?

実は、答えはNOです 😱

これだと、

  • Profile#name のフォームフィールドに required 属性が付かず、空のプロフィールデータを送信できてしまう
  • そして、 Profile#nameProfile#email も空のまま送信した場合のみ、Profile#name@Assert\NotBlank() バリデーションが働かず、普通に送信成功してしまう
    • Profile#email に何か入力されている場合は、 Profile#name もちゃんとバリデーションされる

という挙動になります😓

まじで?って感じですよね…

実験的に知っただけで、ドキュメントは見当たらずSymfonyのソースを確認したわけでもありません。なので、厳密には仕様なのかバグなのかも不明です。詳しい人いたら教えてください🙇

解決策1(一番簡単だけど仕上がりは微妙): Person#profiles を必須にする

実は、この不思議な振る舞いは Person#profiles のフォームフィールドの required 属性の値によって決定されています。

  class PersonType extends AbstractType
  {
      public function buildForm(FormBuilderInterface $builder, array $options)
      {
          $builder
              ->add('profiles', CollectionType::class, [
-                 'required' => false,
                  'label' => 'プロフィール',
                  'entry_type' => ProfileType::class,
                  'prototype' => true,
                  'allow_add' => true,
                  'allow_delete' => true,
              ])
          ;
      }
  
      // ...

このように、Person#profiles のフォームフィールドを required にするだけで、

  • Profile#name の フォームフィールドの required 属性がちゃんと有効になって、空のまま送信できなくなる
  • <form> タグに novalidate 属性を付けて無理やり送信してみても、ちゃんとPHP側で @Assert\NotBlank() バリデーションが動いてエラーになってくれる

という挙動に早変わりします。

個人的に、FormTypeの required 属性は単にHTML側のフォームコントロールに required をレンダリングしてくれるだけの作用しかないと思っていたので、FormTypeの required 属性によってバリデーションの動作に影響があるというのはとても意外でなかなか気づけませんでした😓

しかし、 Person#profiles のフォームフィールドを required にするという対応は、

  • Person#profiles は必須ではない(1つもプロフィールがないデータも許容される)

という要件から考えるとちょっと不自然で気持ち悪いですよね…

それに、例えば symfony/twig-bridge のBootstrap4フォームテーマなんかだと、フォームコントロールが required なときに、ラベルに required というクラスを自動で付加してくれる 機能があり、こういう実装を使って必須項目のラベル部分に 必須 といったバッジをCSSで自動で表示させている場合だと、プロフィールは1つもなくてもいいのにラベルには 必須 と表示されちゃう という嫌な仕上がりになってしまいます。

これはちょっと微妙ですね。

解決策2(少しコードが散らかるけど簡単): Person#profiles@Assert\All({@Assert\NotBlank()}) を付ける

一番のポイントは一つひとつのプロフィールに対して @Assert\NotBlank() バリデーションが働いてほしいという部分なので、@Assert\All() バリデーションを使えばある程度期待通りの挙動を手に入れられます。

Person#profiles のフォームフィールドは 'required' => false に戻しておいてください。

まず、 Person#profiles に以下のようにアノテーションを追記します。

  /**
   * @Assert\Valid()
+  * @Assert\All({
+  *     @Assert\NotBlank(),
+  * })
   */
  public Collection $profiles;

これだけだと相変わらずフォームフィールドは required にならないので、さらに ProfileType のほうに

  class ProfileType extends AbstractType
  {
      public function buildForm(FormBuilderInterface $builder, array $options)
      {
          $builder
              ->add('name', TextType::class, [
                  'label' => '名前',
+                 'attr' => [
+                     'required' => true,
+                 ],
+                 'label_attr' => [
+                     'class' => 'required',
+                 ],
              ])
              ->add('email', EmailType::class, [
                  'required' => false,
                  'label' => 'メールアドレス',
              ])
          ;
      }
  
      // ...
  }

こんな具合に

  • フォームコントロールの required 属性を追加
    • 親の requiredfalse だと、子の required も強制的に false になってしまうので
  • フォームのラベルにも required クラスを追加
    • Profile#name のフォームフィールドの required は強制的に false になっている状態なので、そのままだとラベルに 必須 バッジが表示されないので

という対応をします。これでおおよそ期待どおりに動作します。

ただし、名前もメールアドレスも空欄の状態でプロフィールを送信すると、送信されるデータは {name: null, email: null} ではなく null になるため、エラーパスが person.profiles[0].nameperson.profiles[0].email の2つではなく person.profiles[0] 1つとなります。

なので、一般的なフォームテーマを使ってフォームをレンダリングしている場合、 名前メールアドレス それぞれに 空であってはなりません。 が表示されるのではなく、フォームの最上部に 空であってはなりません。 がドンと表示される感じになると思います。

これだとユーザー的には「どの項目に対して 空であってはなりません。 と怒られているのか分からない」ので、プロフィール欄の付近に form_errors(form.profiles) を書くなどビュー側で多少の工夫が必要になるかと思います。この点だけ要注意です。

解決策3(さらにコードが散らかるけど一番柔軟): ProfileType 側でフォームコントロールの required 属性の付加も @Assert\NotBlank() バリデーションも行う

より理想的な挙動をさせたい場合は、コードは多少散らかってしまいますが、以下のような実装にすればよいです。(僕はいつもこちらの方法をとっています)

Person#profiles のフォームフィールドは 'required' => false に戻しておいてください。

Person#profiles@Assert\All({@Assert\NotBlank()}) アノテーションも不要です。

もはやエンティティのアノテーションでコントロールするのを諦めて、ProfileType

  class ProfileType extends AbstractType
  {
      public function buildForm(FormBuilderInterface $builder, array $options)
      {
          $builder
              ->add('name', TextType::class, [
                  'label' => '名前',
+                 'constraints' => [
+                     new Assert\NotBlank(),
+                 ],
+                 'attr' => [
+                     'required' => true,
+                 ],
+                 'label_attr' => [
+                     'class' => 'required',
+                 ],
              ])
              ->add('email', EmailType::class, [
                  'required' => false,
                  'label' => 'メールアドレス',
              ])
          ;
      }
  
      // ...
  }

こんなふうに

  • Asset\NotBlank() 制約を追加
  • フォームコントロールの required 属性を追加
    • 親の requiredfalse だと、子の required も強制的に false になってしまうので
  • フォームのラベルにも required クラスを追加
    • Profile#name のフォームフィールドの required は強制的に false になっている状態なので、そのままだとラベルに 必須 バッジが表示されないので

というすべての対応をします。

また、このままだと( <form> タグに novalidate 属性を追加するなどして) Profile#name を空で送信すると @Assert\NotBlank() が2回走ってエラーが2重に表示されてしまうので、エンティティのほうの @Assert\NotBlank() アノテーションは外しておきます。

  class Profile
  {
-     /**
-      * @Assert\NotBlank()
-      */
      public ?string $name = null;
      
      /**
       * @Assert\Email()
       */
      public ?string $email = null;
  }

これであれば、名前もメールアドレスも空欄の状態でフォームを送信しても、エラーパスが person.profiles[0].nameperson.profiles[0].email になってくれるので、ビュー側でも特に何もする必要はありません。

バリデーションの定義がエンティティとFormTypeに分散してしまうことが残念ですが、挙動として理想的なものを実現できます。

おわりに

というわけで、エンティティのプロパティがオブジェクトのコレクションをとる場合に、その子オブジェクト側に @Assert\NotBlank() が使われていると意図したとおりにバリデーションされないケースがあるよ( @Assert\Valid() を付けていても意図しない挙動をすることがあるよ)という話をしてきました。

結構ハマりやすいポイントかつ原因の究明が難しい問題だと思うので、どこかで困っている人にこの記事が届くといいなと願っています。

以上です!

Symfony Advent Calendar 2020、明日はまた僕です!笑 お楽しみに!

GitHubで編集を提案

Discussion