BonBonSlick
@BonBonSlick
Vanilla Web Architect

Валидация ValueObject поля?

Есть сущность Юзер и ВО Токен.
User->Token

У токена 2, 3 поля.
Token {
public string $token;
public DateTime $validTill;
public string $ip;
}


Когда прилетает токен необходимы Constraints для валидаций
1. валидный ли токен по дате. Сравнение поля valid_till с текущй датой. Если что сравнение уже есть в обьекте.
spoiler
abstract class AbstractDateValidityVO extends AbstractVO {
    use StringHelperTrait;

    protected ?DateTimeImmutable $createdAt;
    protected ?DateTimeImmutable $expireAt;

    /**
     * @throws Exception
     */
    public function __construct() {
        $this->createdAt = new DateTimeImmutable();
        // use minimal time for token among all tokens
        $defaultMinutes = 5;
        $this->expireAt = (new DateTimeImmutable())->add(new DateInterval(sprintf('PT%dM', $defaultMinutes)));
    }

    public function value(): object {
        return (object)get_object_vars($this);
    }

    final public function getCreatedAt(): ?DateTimeImmutable {
        return $this->createdAt;
    }

    final  public function isValid(): bool {
        if (null === $this->getValidTill()) {
            return true;
        }
        return new DateTimeImmutable() < $this->getValidTill();
    }

    final public function getValidTill(): ?DateTimeImmutable {
        return $this->expireAt;
    }
}

2. т.к. токен привязан к IP, то IdenticalTо IP токена к токену реквеста.
Идет вложенная валидация, скорее всего с подзапросом по типу выбрать юзера по токену и проверить поле.
Возможно у кого есть уже валидаторы для VO полей или кто видел где и может поделиться.

Проверки на на уникальность и существование уже сделано, если надо могу поделиться в ответе к вопросу.
  • Вопрос задан
  • 120 просмотров
Решения вопроса 3
maksim92
@maksim92
Нашёл решение — пометь вопрос ответом!
Если я вас правильно понял, то Это не валидация. Это бизнес правила. Бизнес правила проверяются в сервисе/handler/сущности/VO и если условие не выполнено выбрасывается исключение.

Проверка помещается там, где есть все необходимые данные. Если в VO есть все данные для проверки - можно проверку поместить туда. Вы говорите о запросе, то такую проверку посещаем в Handler.
Ответ написан
@Flying
Если, судя по тегам, речь идёт от Symfony - то зачем вам вообще базовый абстрактный класс для ValueObject, которые по-идее должны представлять значения, а не логику их валидации. Для валидации в Symfony, как вы наверняка знаете, есть соответствующий компонент, поэтому вам достаточно просто описать правила валидации через соответствующие аннотации.

Для первой валидации стоит использовать LessThan с датой. Для второй придётся писать свой валидатор, но там нет ничего сложного.

Если данные приходят в запросе - то можно воспользоваться подходом, который предложил BoShurik в своём ответе на похожий вопрос (смотрите комментарии, там больше информации и примеры), в этом случае до контроллера у вас гарантированно будет долетать только заполненный и валидный объект.
Ответ написан
BonBonSlick
@BonBonSlick Автор вопроса
Vanilla Web Architect
Моя собственная реализация, скажу сразу, завязка под конкретную структуру.
Поскольку делать по констренту будет возможно более геморно чем сделать такой мост, потратил день на это.
Это черновой вариант, тесты были только мануальные пока.

class ResetPasswordRequest extends UpdatePasswordRequest {
    private string $token;

    public function __construct(Request $request) {
        parent::__construct($request);
        $this->token = (string)($request->get('token') ?? $request->attributes->get('token'));
    }

    public static function loadValidatorMetadata(ClassMetadataInterface $classMetadata): void {
        $classMetadata->addPropertyConstraints(
            'token',
            [
                new NotBlank(['message' => 'not.blank',]),
                new Length(['min' => 8, 'max' => 128,]),
                new ValueExistsConstraint(
                    [
                        'message'           => 'not.found',
                        'entityFieldName'   => 'resetPasswordToken',
                        'entityClass'       => User::class,
                        'entityFilterClass' => UserFilter::class,
                        //                        'id' => $this->request->,
                    ]
                ),
                new ValueObjectBridgeConstraint(
                    [
                        'entityFieldName'      => 'resetPasswordToken',
                        'valueObjectFieldName' => 'expiresAt',
                        'entityClass'          => User::class,
                        'entityFilterClass'    => UserFilter::class,
                        'applyConstraint'      => new GreaterThan(
                            ['message' => 'outdated', 'value' => new DateTimeImmutable()]
                        ),
                    ]
                ),
            ]
        );
    }

    public function token(): string {
        return $this->token;
    }
}

<?php
declare(strict_types=1);


namespace App\Infrastructure\Validation\Constraints;


use App\Domain\Core\Exceptions\WrongInstanceOfClassException;
use App\Domain\Core\Interfaces\IAggregateRoot;
use App\Domain\Core\Query\AbstractQueryFilter;
use App\Infrastructure\Traits\Helper\ClassHelpersTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\AbstractComparison;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Throwable;

final class ValueObjectBridgeConstraintValidator extends ConstraintValidator {
    use ClassHelpersTrait;

    private EntityManagerInterface $entityManager;
    private ValidatorInterface     $validator;

    public function __construct(EntityManagerInterface $entityManager, ValidatorInterface $validator) {
        $this->entityManager = $entityManager;
        $this->validator     = $validator;
    }

    /**
     * @param ValueObjectBridgeConstraint $constraint
     */
    public function validate($validationValue, Constraint $constraint): void {
        try {
            $className        = $constraint->entityClass;
            $repositoryMethod = 'single';
            $repository       = $this->entityManager->getRepository($className);
            $this->isMethodExists($repository, $repositoryMethod);
            $entityParameterName = $constraint->entityFieldName;
            $this->isPropertyExists($className, $entityParameterName);
            $applyConstraint = $constraint->applyConstraint;
            $filter          = $this->buildRepositoryFilter(
                $constraint->entityFilterClass,
                $entityParameterName,
                $validationValue
            );

            if (false === $filter instanceof AbstractQueryFilter ||
                false === $applyConstraint instanceof Constraint ||
                false === is_subclass_of($className, IAggregateRoot::class)
            ) {
                throw new WrongInstanceOfClassException();
            }

            /** @var IAggregateRoot $entity */
            $entity = $repository->$repositoryMethod($filter);
            if (null === $entity || false === is_subclass_of($entity, IAggregateRoot::class)) {
                $this->context->buildViolation('not.found')->addViolation();
                throw new WrongInstanceOfClassException();
            }

            $valueObject = $entity->$entityParameterName;
            $this->isPropertyExists($applyConstraint, 'value');
            $valueObjectFieldAccessName = $constraint->valueObjectFieldName;

            if (true === $this->isPropertyExists($valueObject, $valueObjectFieldAccessName, false)) {
                $voValue = $valueObject->$valueObjectFieldAccessName;
            } else {
                $this->isMethodExists($valueObject, $valueObjectFieldAccessName);
                $voValue = $valueObject->$valueObjectFieldAccessName();
            }
            if (true === $applyConstraint instanceof AbstractComparison) {
                $validationValue = $applyConstraint->value;
            }

            $applyConstraint->value = $voValue;
            $errors                 = $this->validator->validate($validationValue, [$applyConstraint]);
            if (0 < $errors->count()) {
                /** @var ConstraintViolation $violation */
                $violation = $errors->get(0);
                $this->context->buildViolation($violation->getMessage())->addViolation();
            }
        } catch (Throwable $exception) {
            $this->context->buildViolation(
                sprintf('Throwable Exception for validation rule %s', $applyConstraint->message)
            )->addViolation()
            ;
        }
    }

    private function buildRepositoryFilter(string $filterName, string $parameterName, $filterValue) {
        $filter = new $filterName();
        if (true === $this->isPropertyExists($filter, $parameterName, false)) {
            $filter->$parameterName = $filterValue;
        } else {
            $setterPrefix    = 'set';           // common updater, setter prefix, must be used in every entity
            $parameterSetter = $setterPrefix . ucfirst($parameterName);
            $this->isMethodExists($filter, $parameterSetter);
            $filter->$parameterSetter($filterValue);
        }
        return $filter;
    }
}


ValueExistsConstraint работает по аналогичному принципу. Увы невозможно переиспользовать весь код, потом посмотрю что можно вынести в трейт или абстркт класс. Пока только buildRepositoryFilter общий для выборки с БД между констрейнтами.

Важно будет упомянуть еще один из возможных подходов new Assert\Valid() валидация VO внутри их самих. Нагружает логику класса слоем валидации, что может усложнить поддержку кода, но жиснеспособно.

Дополнение Compound
Сильно помагает структуризировать дублирование констрейнтов.

Групирирование валидаций. Это крайне важно что бы не выполнять валидации другие к примеру когда поле пустое или сущности не существует в БД, то есть какие-то валидации выдают ошибку для поля, что делает последующие бесполезными.
Sequentially
How to Sequentially Apply Validation Groups
Можно так же просто групирировать используя option groups
Ответ написан
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы