[Symfony] コレクションプロパティのバリデーションにおいて子オブジェクト側のNotBlank制約が無視されるケースについて
はじめに
Symfony Advent Calendar 2020 の5日目の記事です!🎄🌙
昨日は @77web さんの Symfonyにコントリビュートしよう〜SymfonyWorld Hackday〜 でした✨
この記事では、エンティティのプロパティがオブジェクトのコレクションをとる場合に、その子オブジェクト側に @Assert\NotBlank()
が使われていると意図したとおりにバリデーションされないケースがあるよ( @Assert\Valid()
を付けていても意図しない挙動をすることがあるよ)という話をします。
ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲
@Assert\NotBlank()
がない
問題が起こらない例:子オブジェクトに まずは、特に問題が起こらない普通のケースをおさらいとして見てみましょう。
以下のコードは、
-
Person#profiles
がProfile
のコレクションになっている -
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#profiles
がProfile
のコレクションになっている -
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#name
もProfile#email
も空のまま送信した場合のみ、Profile#name
の@Assert\NotBlank()
バリデーションが働かず、普通に送信成功してしまう-
Profile#email
に何か入力されている場合は、Profile#name
もちゃんとバリデーションされる
-
という挙動になります😓
まじで?って感じですよね…
実験的に知っただけで、ドキュメントは見当たらずSymfonyのソースを確認したわけでもありません。なので、厳密には仕様なのかバグなのかも不明です。詳しい人いたら教えてください🙇
Person#profiles
を必須にする
解決策1(一番簡単だけど仕上がりは微妙): 実は、この不思議な振る舞いは 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つもなくてもいいのにラベルには 必須
と表示されちゃう という嫌な仕上がりになってしまいます。
これはちょっと微妙ですね。
Person#profiles
に @Assert\All({@Assert\NotBlank()})
を付ける
解決策2(少しコードが散らかるけど簡単): 一番のポイントは一つひとつのプロフィールに対して @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
属性を追加- 親の
required
がfalse
だと、子のrequired
も強制的にfalse
になってしまうので
- 親の
- フォームのラベルにも
required
クラスを追加-
Profile#name
のフォームフィールドのrequired
は強制的にfalse
になっている状態なので、そのままだとラベルに必須
バッジが表示されないので
-
という対応をします。これでおおよそ期待どおりに動作します。
ただし、名前もメールアドレスも空欄の状態でプロフィールを送信すると、送信されるデータは {name: null, email: null}
ではなく null
になるため、エラーパスが person.profiles[0].name
と person.profiles[0].email
の2つではなく person.profiles[0]
1つとなります。
なので、一般的なフォームテーマを使ってフォームをレンダリングしている場合、 名前
と メールアドレス
それぞれに 空であってはなりません。
が表示されるのではなく、フォームの最上部に 空であってはなりません。
がドンと表示される感じになると思います。
これだとユーザー的には「どの項目に対して 空であってはなりません。
と怒られているのか分からない」ので、プロフィール欄の付近に form_errors(form.profiles)
を書くなどビュー側で多少の工夫が必要になるかと思います。この点だけ要注意です。
ProfileType
側でフォームコントロールの required
属性の付加も @Assert\NotBlank()
バリデーションも行う
解決策3(さらにコードが散らかるけど一番柔軟): より理想的な挙動をさせたい場合は、コードは多少散らかってしまいますが、以下のような実装にすればよいです。(僕はいつもこちらの方法をとっています)
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
属性を追加- 親の
required
がfalse
だと、子の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].name
と person.profiles[0].email
になってくれるので、ビュー側でも特に何もする必要はありません。
バリデーションの定義がエンティティとFormTypeに分散してしまうことが残念ですが、挙動として理想的なものを実現できます。
おわりに
というわけで、エンティティのプロパティがオブジェクトのコレクションをとる場合に、その子オブジェクト側に @Assert\NotBlank()
が使われていると意図したとおりにバリデーションされないケースがあるよ( @Assert\Valid()
を付けていても意図しない挙動をすることがあるよ)という話をしてきました。
結構ハマりやすいポイントかつ原因の究明が難しい問題だと思うので、どこかで困っている人にこの記事が届くといいなと願っています。
以上です!
Symfony Advent Calendar 2020、明日はまた僕です!笑 お楽しみに!
Discussion