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

Дата обновления перевода 2023-01-09

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

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

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

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

  • Attributes
1
2
3
4
5
6
7
8
9
10
11
12
// 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';
}

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

6.1

Атрибут #[HasNamedArguments] был представлен в Symfony 6.1.

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

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

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

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

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

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

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

1
2
3
4
5
// в базовом классе Symfony\Component\Validator\Constraint
public function validatedBy()
{
    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
// 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($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)) {
            // аргумент должен быть строкой или объектом, реализующим implementing __toString()
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

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

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

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

  • Attributes
  • YAML
  • XML
  • PHP
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. Это означает, что вы можете внедрять сервисы или конфигурацию , как любой другой сервис.

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

Если вам часто нужно применять общий набор ограничений в разных местах по всему вашему приложению, вы можете расширить ограничение 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, чтобы определить это свойство.

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

  • Attributes
  • YAML
  • XML
  • PHP
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
// tests/Validator/ContainsAlphanumericValidatorTest.php
namespace App\Tests\Validator;

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

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

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

        $this->assertNoViolation();
    }

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

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

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