[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のソースを確認したわけでもありません。なので、厳密には仕様なのかバグなのかも不明です。詳しい人いたら教えてください🙇
解決策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属性を追加- 親の
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) を書くなどビュー側で多少の工夫が必要になるかと思います。この点だけ要注意です。
解決策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属性を追加- 親の
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