🎻

[Symfony/Validation] バリデーションから他の制約クラスを呼び出して使う方法

2020/07/27に公開

やりたいこと

あるバリデーションロジックの中から、別の既存のバリデーション制約クラスを呼び出して使いたいという話です。

実際に例を見てみましょう。

Before

[Symfony/Validation] Callback制約の使い方

こちらの過去記事 のシチュエーションを例にとります。

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    /**
     * @Assert\NotBlank()
     */
    private $name;

    /**
     * @Assert\NotBlank()
     * @Assert\Email()
     */
    private $email;
    
    /**
     * @Assert\NotBlank()
     */
    private $password;
    
    /**
     * @Assert\Callback()
     */
    public function validatePassword(ExecutionContextInterface $context)
    {
        if (strpos($this->name, $this->password) !== false || strpos($this->email, $this->password) !== false) {
            $context->buildViolation('名前やメールアドレスに含まれる文字列はパスワードに設定できません')->atPath('password')->addViolation();
        }
    }
}

こんな感じでバリデーションが設定されたエンティティがあるとします。

今回は例として、 validatePassword() メソッドを validate() メソッドに改名し、プロパティのアノテーションで実施しているバリデーションも含めてすべての検証を validate() メソッド内で行うようにしてみたいと思います。

普通わざわざそんな実装にはしないと思います😅ここではあくまで例だと思ってお付き合いください🙏

After(微妙パターン)

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    private $name;
    private $email;
    private $password;
    
    /**
     * @Assert\Callback()
     */
    public function validate(ExecutionContextInterface $context)
    {
        if (empty($this->name)) {
            $context->buildViolation('この項目は必須です')->atPath('name')->addViolation();
        }

        if (empty($this->email)) {
            $context->buildViolation('この項目は必須です')->atPath('email')->addViolation();
        }

        if (empty($this->password)) {
            $context->buildViolation('この項目は必須です')->atPath('password')->addViolation();
        }

        if (!preg_match(Assert\EmailValidator::PATTERN_HTML5, $this->email)) {
            $context->buildViolation('メールアドレスが正しくありません')->atPath('email')->addViolation();
        }

        if (strpos($this->name, $this->password) !== false || strpos($this->email, $this->password) !== false) {
            $context->buildViolation('名前やメールアドレスに含まれる文字列はパスワードに設定できません')->atPath('password')->addViolation();
        }
    }
}

すべてのバリデーションを自力でやるとこんな感じになってしまうでしょう。

しかしこれだと完全に既存の NotBlankEmail の再実装ですし、エラーメッセージもハードコードしないといけないのも微妙です。

After(いい感じパターン)

というわけで、 validate() メソッドの中から NotBlankEmail といった既存のバリデーション制約クラスを呼び出して使いたくなります。

結論としては、以下のような方法で実現できます👍

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    private $name;
    private $email;
    private $password;
    
    /**
     * @Assert\Callback()
     */
    public function validate(ExecutionContextInterface $context)
    {
        $notBlankConstraint = new Assert\NotBlank();
        $emailConstraint = new Assert\Email();
        
        $validator = $context->getValidator()->inContext($context);

        $validator
            ->atPath('name')
            ->validate($this->name, $notBlankConstraint)
        ;

        $validator
            ->atPath('email')
            ->validate($this->email, $notBlankConstraint)
            ->validate($this->email, $emailConstraint)
        ;

        $validator
            ->atPath('password')
            ->validate($this->password, $notBlankConstraint)
        ;

        if (strpos($this->name, $this->password) !== false || strpos($this->email, $this->password) !== false) {
            $context->buildViolation('名前やメールアドレスに含まれる文字列はパスワードに設定できません')->atPath('password')->addViolation();
        }
    }
}

カスタムバリデーションなどの中から既存のバリデーション制約クラスを使いたいなーと思ったときに、思い出してください😇

参考:How to use Dynamic Constraints with Symfony/Validator | Děláš v PHP? Jsi jedním z nás

引数ありの制約クラスの場合

ちなみに、制約クラスの中にはコンストラクタが引数をとるものもあるので、その例も一応書いておきます✋

例えば Choice 制約に callback 引数をつけて使う場合のBefore/Afterはこんな感じです。(これも、Afterのほうが良い実装だとかそういう話ではありません。あくまで例です🙏)

参考:[Symfony] エンティティのプロパティが定数をとるときに僕がよくやる実装

Before

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    const DIVISION_SALES = '営業部';
    const DIVISION_DEVELOPMENT = '開発部':
    const DIVISION_GENERAL = '総務部';
    
    public static function getValidDivisions(): array
    {
        return [
            self::DIVISION_SALES,
            self::DIVISION_DEVELOPMENT,
            self::DIVISION_GENERAL,
        ];
    }

    /**
     * Assert\Choice(callback="getValidDivisions")
     */
    private $division;
}

After

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    const DIVISION_SALES = '営業部';
    const DIVISION_DEVELOPMENT = '開発部':
    const DIVISION_GENERAL = '総務部';
    
    public static function getValidDivisions(): array
    {
        return [
            self::DIVISION_SALES,
            self::DIVISION_DEVELOPMENT,
            self::DIVISION_GENERAL,
        ];
    }

    private $division;

    /**
     * @Assert\Callback()
     */
    public function validate(ExecutionContextInterface $context)
    {
        $choiceConstraint = new Assert\Choice([
            'callback' => [$this, 'getValidDivisions'],
        ]);
        
        $context
            ->getValidator()
            ->inContext($context)
            ->atPath('division')
            ->validate($this->division, $choiceConstraint)
        ;
    }
}

応用すると「他のプロパティの値に応じて選択肢を変える」といった実装も簡単にできそうですね👍

switch ($this->type) {
    case self::TYPE_A:
        $callback = [$this, 'getValidChoicesForTypeA'];
        break;
    case self::TYPE_B:
        $callback = [$this, 'getValidChoicesForTypeB'];
        break;
    case self::TYPE_C:
    default:
        $callback = [$this, 'getValidChoicesForTypeC'];
        break;
}

$choiceConstraint = new Assert\Choice([
    'callback' => $callback,
]);
GitHubで編集を提案

Discussion