🎻

symfony/formで「どちらか片方の入力は必須、かつ両方入力はNG」をバリデーションする

2020/04/29に公開

以前、symfony/formで「どちらか片方の入力は必須」をシュッと作る という記事を書きました。

今回はこれの発展系というか、もう少し複雑な要件を考えてみます。

要件

例えば以下のような要件を考えます。

  • 「ほしい物リスト」と「商品」というエンティティがある
  • ほしい物リストには「商品」と「商品名」という2つのプロパティがある
  • システムに登録されている商品をほしい物リストに追加したい場合は「商品」プロパティに商品エンティティを保存する
  • システムに登録されていない商品をほしい物リストに追加したい場合は「商品名」プロパティに文字列で商品の情報を保存する
  • 「商品」と「商品名」はどちらか片方の入力は必須だが、両方とも入力することは許可されない

一見ややこしいですが、まあありそうな要件です。

まず、バリデーションのことを考えずにエンティティを作ってみると、以下のようなイメージになるでしょう。

class WishListEntry
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="wishListEntries")
     */
    private $product;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $productName;

    // ... setters and getters
}

カスタムバリデーションを作る

今回は symfony/formで「どちらか片方の入力は必須」をシュッと作る のときよりも要件が少し複雑ですし、FormTypeで対応するのではなくカスタムバリデーションを書きたいと思います。

カスタムバリデーションの作り方は こちらの公式ドキュメント にすべて書いてあります😇

流れとしては、

  1. @Annotation とアノテーションした Constraint の派生クラスを作る
  2. そのクラスと同じnamespaceに、 ConstraintValidator の派生クラスを作り、クラス名は↑で作ったクラスの末尾に Validator を加えただけの名前にする
  3. Constraint クラスには違反時のエラーメッセージをセットするのみ
  4. Validator クラスで実際のバリデーション処理を行う

という感じです。

実際に作ってみましょう。

<?php
namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class ProductXorProductName extends Constraint
{
    public $messageBlank = '商品か商品名のどちらか一方は入力してください';
    public $messageDuplicated = '商品と商品名はどちらか一方しか入力してはいけません';
}
<?php
namespace App\Validator\Constraints;

use App\Entity\WishListEntry;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class ProductXorProductNameValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof ProductXorProductName) {
            throw new UnexpectedTypeException($constraint, ProductXorProductName::class);
        }

        $wishListEntry = $this->context->getObject();

        if (!$wishListEntry instanceof WishListEntry) {
            throw new UnexpectedValueException($wishListEntry, WishListEntry::class);
        }

        $blank = !$wishListEntry->getProduct() && !$wishListEntry->getProductName();
        $duplicated = $wishListEntry->getProduct() && $wishListEntry->getProductName();

        if ($blank) {
            $this->context->buildViolation($constraint->messageBlank)->addViolation();
        } elseif ($duplicated) {
            $this->context->buildViolation($constraint->messageDuplicated)->addViolation();
        }
    }
}

こんな感じです。ほぼ 公式ドキュメント を参考にしただけで難しいことはしていないのですが、唯一注意が必要なところといえば

$wishListEntry = $this->context->getObject();

if (!$wishListEntry instanceof WishListEntry) {
    throw new UnexpectedValueException($wishListEntry, WishListEntry::class);
}

ここでしょうか。

公式ドキュメントのコード例 を参考にすると、 $value の型をチェックして想定外の型だったら例外を投げる、という処理にしたくなりますが、今回作っているのは「商品」と「商品名」という2つの異なる型の値をセットにしてバリデーションする機能なので、フィールドの値である $value ではなく context から取得した WishListEntry のオブジェクトを対象に型チェックをしています。

エンティティのプロパティにカスタムバリデーションをセットする

あとは、作ったカスタムバリデーションをエンティティのプロパティにセットしてあげれば完成です。

use App\Validator\Constraints as AppAssert;

class WishListEntry
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="wishListEntries")
+    * 
+    * @AppAssert\ProductXorProductName()
     */
    private $product;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
+    * 
+    * @AppAssert\ProductXorProductName()
     */
    private $productName;

    // ... setters and getters
}

動かしてみる

実際に動かしてみると…

バッチリですね!🙌

まとめ

  • 複雑なバリデーションが必要なときはカスタムバリデーションを書けばなんでもできる👍
GitHubで編集を提案

Discussion