Как создать пользовательское ограничение валидации

Дата обновления перевода 2024-07-29

Как создать пользовательское ограничение валидации

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

Создание класса ограничения

Для начала вам нужно создать класс ограничения и расширить Constraint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
    public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';
    public string $mode = 'strict';

    // все конфигурируемые опции должны быть переданы в конструктор
    public function __construct(string $mode = null, string $message = null, array $groups = null, $payload = null)
    {
        parent::__construct([], $groups, $payload);

        $this->mode = $mode ?? $this->mode;
        $this->message = $message ?? $this->message;
    }
}

Добавьте #[\Attribute] к классу ограничения, если вы хотите использовать его как атрибут в других классах.

Вы можете использовать #[HasNamedArguments], чтобы сделать какие-то опции ограничения обязательными:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;

use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
    public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';

    #[HasNamedArguments]
    public function __construct(
        public string $mode,
        array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct([], $groups, $payload);
    }
}

Создание самого валидатора

Как вы видите, класс ограничения достаточно минимален. Сама валидация выполняется другим классом "валидатором ограничения". Класс валидатора ограничения указывается методом ограничения validatedBy(), который включает некоторую простую логику по умолчанию:

1
2
3
4
5
// in the base Symfony\Component\Validator\Constraint class
public function validatedBy(): string
{
    return static::class.'Validator';
}

Другими словами, если вы создадите пользовательское Constraint (например, MyConstraint), Symfony автоматически будет искать другой класс, MyConstraintValidator при проведении самой валидации.

Класс валидатора так же прост, и имеет только один обязательный метод validate():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/Validator/ContainsAlphanumericValidator.php
namespace App\Validator;

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

class ContainsAlphanumericValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof ContainsAlphanumeric) {
            throw new UnexpectedTypeException($constraint, ContainsAlphanumeric::class);
        }

        // пользовательские ограничения должны игнорировать пустые значения и null, чтобы
        // позволить другим ограничениям (NotBlank, NotNull, и др.) позаботиться об этом
        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            // вызовите это исключение, если ваш валидатор не может обработать переданный тип, чтобы он мог быть отмечен как невалидный
            throw new UnexpectedValueException($value, 'string');

            // разделите множество типов, используя вертикальные черты
            // вызовите новое UnexpectedValueException($value, 'string|int');
        }

        // получите доступ к вашим опциям конфигурации таким образом:
        if ('strict' === $constraint->mode) {
            // ...
        }

        if (preg_match('/^[a-zA-Z0-9]+$/', $value, $matches)) {
            return;
        }

        // аргумент должен быть строкой или объектом, реализующим implementing __toString()
        $this->context->buildViolation($constraint->message)
            ->setParameter('{{ string }}', $value)
            ->addViolation();
    }
}

Внутри validate вам не нужно возвращать значение. Вместо этого, вы добавляете нарушения к свойству валидатора context и значение будет принято как валидное, если оно не вызывает никаких нарушений. Метод buildViolation() берёт сообщение об ошибке в качестве своего аргумента и возвращает экземпляр ConstraintViolationBuilderInterface. Метод вызова addViolation() в конце-концов добавляет наружение в контекст.

Использование нового валидатора

Использовать пользовательские валидаторы как и те, что предоставляются самой Symfony:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;

class AcmeEntity
{
    // ...

    #[Assert\NotBlank]
    #[AcmeAssert\ContainsAlphanumeric(mode: 'loose')]
    protected string $name;

    // ...
}

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

Валидаторы ограничений с зависимостями

Если вы используете конфигурацию services.yml по умолчанию , то ваш валидатор уже зарегистрирован в качестве сервиса и тегирован необходимым validator.constraint_validator. Это означает, что вы можете внедрять сервисы или конфигурацию , как любой другой сервис.

Валидаторы ограничений с пользовательскими опциями

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/Validator/Foo.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class Foo extends Constraint
{
    public $mandatoryFooOption;
    public $message = 'This value is invalid';
    public $optionalBarOption = false;

    public function __construct(
        $mandatoryFooOption,
        string $message = null,
        bool $optionalBarOption = null,
        array $groups = null,
        $payload = null,
        array $options = []
    ) {
        if (\is_array($mandatoryFooOption)) {
            $options = array_merge($mandatoryFooOption, $options);
        } elseif (null !== $mandatoryFooOption) {
            $options['value'] = $mandatoryFooOption;
        }

        parent::__construct($options, $groups, $payload);

        $this->message = $message ?? $this->message;
        $this->optionalBarOption = $optionalBarOption ?? $this->optionalBarOption;
    }

    public function getDefaultOption()
    {
        return 'mandatoryFooOption';
    }

    public function getRequiredOptions()
    {
        return ['mandatoryFooOption'];
    }
}

Затем внутри класса валидатора вы можете получить доступ к этим опциям напрямую через класс ограничения, которые передаются методу validate():

1
2
3
4
5
6
7
8
9
10
11
12
class FooValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        // получить доступ к любой опции ограничения
        if ($constraint->optionalBarOption) {
            // ...
        }

        // ...
    }
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;

class AcmeEntity
{
    // ...

    #[Assert\NotBlank]
    #[AcmeAssert\Foo(
        mandatoryFooOption: 'bar',
        optionalBarOption: true
    )]
    protected $name;

    // ...
}

Создайте повторно используемый набор ограничений

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

Валидатор класса ограничений

Кроме валидации свойства класса, ограничение может иметь область действия
всего класса.

Например, представьте, что у вас также есть сущность PaymentReceipt, и вам нужно убедиться в том, что электронная почта в содержимом квитанции совпадает с электронной почтой пользователя. Для начала, создайте ограничение и переопределите метод getTargets():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Validator/ConfirmedPaymentReceipt.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ConfirmedPaymentReceipt extends Constraint
{
    public string $userDoesNotMatchMessage = 'User\'s e-mail address does not match that of the receipt';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

Тепеь, валидатор ограничения получит объект в качестве первого аргумента validate():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/Validator/ConfirmedPaymentReceiptValidator.php
namespace App\Validator;

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

class ConfirmedPaymentReceiptValidator extends ConstraintValidator
{
    /**
     * @param PaymentReceipt $receipt
     */
    public function validate($receipt, Constraint $constraint): void
    {
        if (!$receipt instanceof PaymentReceipt) {
            throw new UnexpectedValueException($receipt, PaymentReceipt::class);
        }

        if (!$constraint instanceof ConfirmedPaymentReceipt) {
            throw new UnexpectedValueException($constraint, ConfirmedPaymentReceipt::class);
        }

        $receiptEmail = $receipt->getPayload()['email'] ?? null;
        $userEmail = $receipt->getUser()->getEmail();

        if ($userEmail !== $receiptEmail) {
            $this->context
                ->buildViolation($constraint->userDoesNotMatchMessage)
                ->atPath('user.email')
                ->addViolation();
        }
    }
}

Tip

Метод atPath() определяет свойство, с которым ассоцииуется ошибка валидации. Используйте любой валидный синтаксис PropertyAccess, чтобы определить это свойство.

Валидатор ограничения класса должен быть применен к самому классу:

1
2
3
4
5
6
7
8
9
10
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;

#[AcmeAssert\ProtocolClass]
class AcmeEntity
{
    // ...
}

Тестиование пользовательских ограничений

Используйте класс ConstraintValidatorTestCase`, чтобы упростить написание модульных тестов для ваших пользовательских ограничений:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// tests/Validator/ContainsAlphanumericValidatorTest.php
namespace App\Tests\Validator;

use App\Validator\ContainsAlphanumeric;
use App\Validator\ContainsAlphanumericValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

class ContainsAlphanumericValidatorTest extends ConstraintValidatorTestCase
{
    protected function createValidator(): ConstraintValidatorInterface
    {
        return new ContainsAlphanumericValidator();
    }

    public function testNullIsValid(): void
    {
        $this->validator->validate(null, new ContainsAlphanumeric());

        $this->assertNoViolation();
    }

    /**
     * @dataProvider provideInvalidConstraints
     */
    public function testTrueIsInvalid(ContainsAlphanumeric $constraint): void
    {
        $this->validator->validate('...', $constraint);

        $this->buildViolation('myMessage')
            ->setParameter('{{ string }}', '...')
            ->assertRaised();
    }

    public function provideInvalidConstraints(): \Generator
    {
        yield [new ContainsAlphanumeric(message: 'myMessage')];
        // ...
    }
}