カスタムバリデーション

業務においてカスタムバリデーションを行うことになりました。カスタムバリデータの作成方法は Symfony2 カスタムバリデーションの作成 | QUARTETCOM TECH BLOGHow to Create a custom Validation Constraint (The Symfony CookBook) が参考になります。
このあたりを見ながら「/を含む場合に不正」とするファイル名バリデータを作成してみます。

カスタムバリデータを作成

<?php
namespace Acme\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

class Filename extends Constraint
{
    public $message = 'ファイル名が正しくありません。';
}
<?php
namespace Acme\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

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

        if (preg_match('/\//', $value, $matches)) {
            $this->context
                ->buildViolation($constraint->message)
                ->addViolation()
            ;
        }
    }
}

以上で完成です。

カスタムバリデータを使用

さっそくファイル名バリデータを使ってみましょう。
エントリポイントとなるmain.phpを作成して実行してみます。

<?php
use Symfony\Component\Validator\Validation;
use Acme\Validator\Constraints\Filename;

$validator = Validation::createValidator();
$violations = $validator->validate('/', new Filename());
foreach ($violations as $violation) {
    echo $violation->getMessage().PHP_EOL;
}
$ php main.php
ファイル名が正しくありません。

参考URLのとおりに行えば特に難しくありませんね。

コンテキスト

さてAcme\Validator\Constraints\FilenameValidatorにおいて$this->contextという記述があります。ここにあるcontextとは何でしょうか?疑問に思ったので今回はその役割を調べてみました。

contextSymfony\Component\Validator\Context\ExecutionContextInterfaceを実装したインスタンスです。このインターフェイスの冒頭のコメントにコンテキストの役割が記されていましたので以下に引用します。

/**
 * The context of a validation run.
 *
 * The context collects all violations generated during the validation. By
 * default, validators execute all validations in a new context:
 *
 *     $violations = $validator->validate($object);
 *
 * When you make another call to the validator, while the validation is in
 * progress, the violations will be isolated from each other:
 *
 *     public function validate($value, Constraint $constraint)
 *     {
 *         $validator = $this->context->getValidator();
 *
 *         // The violations are not added to $this->context
 *         $violations = $validator->validate($value);
 *     }
 *
 * However, if you want to add the violations to the current context, use the
 * {@link ValidatorInterface::inContext()} method:
 *
 *     public function validate($value, Constraint $constraint)
 *     {
 *         $validator = $this->context->getValidator();
 *
 *         // The violations are added to $this->context
 *         $validator
 *             ->inContext($this->context)
 *             ->validate($value)
 *         ;
 *     }
 *
 * Additionally, the context provides information about the current state of
 * the validator, such as the currently validated class, the name of the
 * currently validated property and more. These values change over time, so you
 * cannot store a context and expect that the methods still return the same
 * results later on.
 *
 * @since  2.5
 *
 * @author Bernhard Schussek <bschussek@gmail.com>
 */
interface ExecutionContextInterface extends LegacyExecutionContextInterface

バリデータがバリデーションを行うごとに独立した「コンテキスト」という状態が定義されるようです。このコンテキストがバリデータの現在の状態を保持し、バリデーション対象クラスや現在バリデーションしているプロパティの情報などを持つようです。
個人的にはわかったようなわからないような感じでした。

使い所は?

先ほど引用したコメントにある「独立した場合」と「コンテキストを共有した場合」の使いわけをする場面というのはどういう時なのでしょうか?
ひとつの案としてビルトインされているバリデータや既存のカスタムバリデータを複数使用する時にありえるのではないかというアドバイスを同僚にもらいましたので試してみます。

試してみる

仮に「一意なEメールアドレスを登録するアプリ」における「Eメールバリデータ = MyEmailValidator」を作成することとします。
以下をバリデーションの不正条件としました。

  1. メールアドレスとしてのフォーマットが正しくない
  2. 登録済である
ここではEmailおよび[Symfony2 カスタムバリデーションの作成 QUARTETCOM TECH BLOG](http://tech.quartetcom.co.jp/2014/12/24/symfony2-custom-validation/) で作成したUnusedEmailと同等のものを使用することとします。

なお、エントリポイントとなるスクリプトは以下です。

<?php
// main.php

use Symfony\Component\Validator\Validation;
use Acme\Validator\Constraints\MyEmail;

$validator = Validation::createValidator();
$violations = $validator->validate('test@example',  new MyEmail());
foreach ($violations as $violation) {
    echo $violation->getMessage().PHP_EOL;
}

実装方法として以下の3パターンを考えてみました。

  1. EmailあるいはUnusedEmailのどちらに違反しても違反の内容に変化がない
  2. EmailあるいはUnusedEmailのどれに違反したのかを明確に
  3. 1,2 のハイブリッド

1. EmailあるいはUnusedEmailのどちらに違反しても違反の内容に変化がない

Email制約に違反した時点でUnusedEmailの制約チェックは行われません。
MyEmailValidatorは「Email制約で違反しているのか」「UnusedEmail制約で違反しているのか」の情報を保持せず、あくまでMyEmailValidatorのレイヤとしての情報を保持しているのみです。
Email制約に違反している時点でUnusedEmailの制約チェックを行うまでもなくMyEmail制約としては違反です。余計なコストをかける必要もないですしMyEmail制約としては妥当な振舞に思えます。

<?php
namespace Acme\Validator\Constraints;

use Acme\Validator\Constraints\UnusedEmail;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

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

        $validator = $this->context->getValidator();
        $emailConstraint = new Email();
        $unusedEmailConstraint = new UnusedEmail();

        if (count($validator->validate($value, $emailConstraint)) > 0
            || count($validator->validate($value, $unusedEmailConstraint)) > 0
        ) {
            $this->context
                ->buildViolation($constraint->message)
                ->addViolation()
            ;
        }
    }
}

結果は以下です。カスタムバリデータMyEmailのコンテキストに独自メッセージを保持するviolationが登録されたのがわかります。

$ php main.php
Eメールアドレスが正しくありません。 # MyEmailによるメッセージ。

2. EmailあるいはUnusedEmailのどれに違反したのかを明確に

Email制約に違反してもしなくてもUnusedEmailの制約チェックが行われます。
MyEmailValidatorは「Email制約で違反しているのか」「UnusedEmail制約で違反しているのか」の情報を保持しています。MyEmailValidatorはこれらの情報の器としてしか機能していないように見受けられます。
バリデーションを実行した側としてはどういった制約で違反しうるのかを一度で情報を得たい場合もあるでしょう。そういった際にはこのようなコンテキスト運用が有用に思われます。

<?php
namespace Acme\Validator\Constraints;

use Acme\Validator\Constraints\UnusedEmail;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

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

        $validator = $this->context->getValidator();
        $emailConstraint = new Email();
        $unusedEmailConstraint = new UnusedEmail();

        $validator
            ->inContext($this->context)
            ->validate($value, $emailConstraint)
            ->validate($value, $unusedEmailConstraint)
        ;
    }
}

結果は以下です。カスタムバリデータMyEmailのコンテキストにEmailおよびUnusedEmailで発生したviolationが登録されたのがわかります。

$ php main.php
This value is not a valid email address. # Emailによるメッセージ
他のサービスで使用されています。         # UnusedEmailによるメッセージ

3. 1,2 のハイブリッド

参考までに。

<?php
namespace Acme\Validator\Constraints;

use Acme\Validator\Constraints\UnusedEmail;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

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

        $validator = $this->context->getValidator();
        $emailConstraint = new Email();
        $unusedEmailConstraint = new UnusedEmail();

        // 2. のロジック
        $violations = $validator
            ->inContext($this->context)
            ->validate($value, $emailConstraint)
            ->validate($value, $unusedEmailConstraint)
            ->getViolations()
        ;

        // 1. のロジック
        if (count($violations) > 0) {
            $this->context
                ->buildViolation($constraint->message)
                ->addViolation()
            ;
        }
    }
}

結果は以下です。カスタムバリデータMyEmailのコンテキストに独自メッセージを保持するviolation、EmailおよびUnusedEmailで発生したviolationが並行して登録されたのがわかります。

$ php main.php
This value is not a valid email address. # Emailによるメッセージ
他のサービスで使用されています。         # UnusedEmailによるメッセージ
Eメールアドレスが正しくありません。      # MyEmailによるメッセージ。

まとめ

以上より、バリデーションの制約結果情報をひとまとまりに保持するのがコンテキストの役割のようです。的確なコンテキスト運用をすることでバリデーションの結果情報を有用に使用できることができるようになるかと思います。

今回は以下のコードを重点的に探っていきました。

まだまだ奥は深そうですが、疑問ドリブンでコードの海に飛び込んで一度さらりとでも読んでおいたという事実がいつか役に立つんじゃないかなと期待しています。いつまでも覚えていられる自信はまったくありませんが…
あとコードリーディングにもPhpStorm便利。

補足

なお、カスタムバリデータ内でないときは以下のように$validator->startContext()することで新たなコンテキストを作成し、複数のバリデーションで共有することができます。ご参考まで。

<?php
// main.php

use Acme\Validator\Constraints\UnusedEmail;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Validation;

$value = 'test@example';

$validator = Validation::createValidator();
$violations = $validator
    ->startContext()
    ->validate($value, new Email())
    ->validate($value, new UnusedEmail())
    ->getViolations()
;
foreach ($violations as $violation) {
    echo $violation->getMessage().PHP_EOL;
}
$ php main.php
This value is not a valid email address.
他のサービスで使用されています。