В Symfony, на мой взгляд, очень удобный функционал валидации сущностей. Меня в частности очень подкупает использование формата аннотаций для конфигурирования правил валидации. В подавляющем большинстве задач готовые решения покрывают стандартные случаи. Но, как известно, валидация — дело тонкое, и никогда не знаешь, какие ограничения придется накладывать в этот раз. С другой стороны, более гибкая и продуманная валидация всегда поможет избежать ошибок пользователя.


Под катом приглашаю посмотреть, как легко писать свои ограничения и расширять имеющиеся на примере базовой валидации и валидации сравнения двух полей. Статья может быть интересна тем, кто еще мало знаком с валидацией в Symfony или пока обходил стороной возможности написания своих валидаторов.


В этой статье будем иметь в виду классический контекст валидации. Есть некая сущность, данные по которой заполняются из соответствующией формы, валидация же накладывает ограничения на любой из аспектов вводимых данных. Стоит отметить, что валидаторы можно без ограничений использовать и для контроля внутренней логики, валидации API и т.д.


Сразу обусловим, что предложенные примеры не являются единственно возможными решениями и не претендуют на максимальную оптимальность или обширность использования, а предложены в демонстрационных целях. С другой стороны, части примеров взяты и упрощены с боевых проектов, и надеюсь, что могут быть полезными в иллюстрации простоты написания кастомных валидаций без отрыва от реальности.


Базовая валидация


Для того чтобы сделать полнофункциональную валидацию необходимо создать всего два класса — наследников Constraint и ConstraintValidator. Constraint, как понятно из названия, определяет и описывает ограничения, а ConstraintValidator их валидирует. В качестве примера будем писать валидатор для времни в формате "hh:mm", хранящегося в текстовом формате. В официальной документации предлагается описывать в Constraint публичные свойства ограничения. Так и сделаем.


namespace App\Custom\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 * @Target({"PROPERTY"})
 */
class TextTime extends Constraint
{
    public $message = 'Неверный формат времени';
}

Здесь аннотация Target определяет для чего будет использована валидация: для свойства или для класса. Этот параметр также можно задать, переопределив функцию


public function getTargets()
{
    // или можно self::CLASS_CONSTRAINT
    return self::PROPERTY_CONSTRAINT;
}

Свойство message как несложно догадаться используется для вывода информации об ошибке валидации.


После того как мы определили ограничение, можно перейти к реализации самого вадидатора. Из ограничения он вызывается весьма тривиально


public function validatedBy()
{
    return \get_class($this).'Validator';
}

Значит, нужно в том же пространстве имен создать класс с функцией, наследуемый от абстрактного класса ConstraintValidator. Наследники ConstraintValidator должны переопределить функцию validate. Напишем простой валидатор времени


namespace App\Custom\Constraints;

use Symfony\Component\Validator\ConstraintValidator;

class TextTimeValidator extends ConstraintValidator
{
    /**
     * Функция проверки валидности значения
     *
     * @param mixed $value Проверяемое значение
     * @param Constraint $constraint Ограничение для валидации
     */
    public function validate($value, Constraint $constraint)
    {
        $time = explode(':', $value);

        $hours = (int) $time[0];
        $minutes = (int) $time[1];

        if ($hours >= 24 || $hours < 0)
            $this->fail($constraint);
        else if ((int) $time[1] > 60 || $minutes < 0)
            $this->fail($constraint);
    }

    private function fail(Constraint $constraint) 
    {
        $this->context->buildViolation($constraint->message)
            ->addViolation();
    }
}

Чтобы валидация заработала, достаточно сконфигурировать её в классе сущности, например, с помощью аннотаций.


...
    /**
     * @Assert\NotBlank()
     * @Assert\Regex(
     *     pattern="/\d{2}:\d{2}/",
     *     message="Недопустимый символ"
     * )
     * @CustomAssert\TextTime()
     * @ORM\Column(type="string", length=5)
     */
    private $timeFrom;
...

Первое правило проверяет на непустое значение, второе позволяет проверить формат вводимых данных, а наш валидатор примитивно проверяет соответствие логики времени. Кстати, как раз здесь, прямо в аннотации, мы можем переопределить message или любой другой ранее заданный параметр.


Вот и готов первый простой валидатор. Стоит отметить, что есть и стандартный Symfony валидатор Time. Он немногим сложнее: в него уже включена проверка на формат ввода и проверка входящего значения на возможность преобразования в строку. Его нельзя напрямую применить к этому кейсу, так как он заточен под формат "hh:mm:ss". Естественно, такую проверку можно было записать и через регулярные выражения.


Валидация со сравнением полей


Помимо простых проверок и условий, часто встречаются случаи, когда нужно сравнить поля сущности между собой или с чем-то еще. Практическую задачу, развивая наш пример, можно описать таким образом: есть два временных поля формата "hh:mm", эти поля представляют временной интервал формата "hh:mm"-"hh:mm". В предметной области такая связка описывает отвязанный от конкретной даты промежуток времени в сутках, например, режим работы или период обеденного перерыва.


Класс ограничения в данном случае не сильно отличается от предыдущего, только наследуется теперь от AbstractComparsion. AbstractComparsion в свою очередь наследуется от Constraint и только добавляет требования к необходимым параметрам ограничения — указания поля с чем сравнивать.


namespace App\Custom\Constraints;

use Symfony\Component\Validator\Constraints\AbstractComparison;

/**
 * @Annotation
 * @Target({"PROPERTY"})
 */
class TextTimeInterval extends AbstractComparison
{   
    public $message = 'Данное значение значение не может быть позднее {{ compared_value }}.';
}

Класс валидатор выглядит следующим образом


namespace App\Custom\Constraints;

use Symfony\Component\Validator\Constraints\AbstractComparisonValidator;

class TextTimeIntervalValidator extends AbstractComparisonValidator
{
    /**
     * Сравнивает два значения на их совместную валидность
     *
     * @param mixed $timeFrom Первое значение времени
     * @param mixed $timeTo Второе значение времени
     *
     * @return возвращает true если значение валидно, false иначе
     */
    protected function compareValues($timeFrom, $timeTo)
    {
        $compareResult = true;

        $from = explode(':', $timeFrom);
        $to = explode(':', $timeTo);

        try {
            if ((int) $from[0] > (int) $to[0])
                $compareResult = false;
            else if (((int) $from[0] == (int) $to[0]) && ((int) $from[1] > (int) $to[1])) {
                $compareResult = false;
            }
        } catch (\Exception $exception) {
            $compareResult = false;
        }

        return $compareResult;
    }
}

Только в данном случае (одной из возможных реализаций) мы переопределяем функцию compareValues, которая возвращает true/false об успехе валидации и обрабатывается в AbstractComparisonValidator функцией validate().


В сущности это описывается следующим образом


...
 /**
     * @Assert\NotBlank()
     * @Assert\Regex(
     *     pattern="/\d{2}:\d{2}/",
     *     message="Недопустимый символ"
     * )
     * @CustomAssert\TextTime()
     * @CustomAssert\TextTimeInterval(
     *     propertyPath="timeTo",
     *     message="Начало рабочего дня не может быть позднее окончания"
     * )
     * @ORM\Column(type="string", length=5)
     */
    private $timeFrom;

    /**
     * @Assert\NotBlank()
     * @Assert\Regex(
     *     pattern="/\d{2}:\d{2}/",
     *     message="Недопустимый символ"
     * )
     * @CustomAssert\TextTime()
     * @ORM\Column(type="string", length=5)
     */
    private $timeTo;
...

Надеюсь, этот небольшой разбор и примеры показали, как легко можно использовать кастомные валидаторы. Поскольку для начала рассматривали самые простые валидаторы, то, конечно, чтобы не перегружать изложение предметными описаниями, привести исключительные примеры не удалось. В дальнейших статьях планирую рассмотреть более сложные валидаторы и специфичные кейсы.