phphd / pipeline-bundle

A streamlined implementation of Chain of Responsibility pattern on top of Symfony Messenger component
MIT License
0 stars 0 forks source link

Showing all validation errors instead of just first #2

Open rela589n opened 6 months ago

rela589n commented 6 months ago

In most applications it makes sense to show full list of validation errors, not just the first one (as with the case of exception).

Technically it seems to be possible to implement this feature, though with somewhat oddish syntax.

The converter should throw as much details regarding what is wrong with given command as possible.

When combined with https://github.com/phphd/exceptional-validation-bundle/ this could give a strong foundation for enterprise applications.

The key idea is to use one-level indirection during command conversion:

    private function createBluePrint(
        UpdateFamilyProfileCommandDto $commandDto,
    ): Closure {
        $id = $this->getId($commandDto);
        $family = $this->getFamily($commandDto);

        return static fn () => new UpdateFamilyProfileCommand($id(), $family());
    }

Here function is returned, and both $id and $family variables are functions.

This way it would be possible to change the execution flow from DFS into BFS, and therefore catch all the exceptions from given closures.

As a simple convention to introduce - when closure uses closure variables with no parameters, - then we could call it separately.

The rest of thoughs are in the code:

<?php

declare(strict_types=1);

namespace App;

require "/home/rela589n/Projects/opensource/phphd/exceptional-validation-bundle/vendor/autoload.php";

use Closure;
use DateTimeImmutable;
use PhPhD\ExceptionalValidation;
use ReflectionFunction;
use RuntimeException;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints\Valid;

#[ExceptionalValidation]
final class UpdateFamilyProfileCommandDto
{
    public function __construct(
        private string $id,
        #[Valid]
        private readonly FamilyDto $family,
    ) {
    }

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

    public function getFamily(): FamilyDto
    {
        return $this->family;
    }
}

final class FamilyDto
{
    public function __construct(
        /** @var FamilyMemberDto[] */
        #[Valid]
        private readonly array $members,
    ) {
    }

    public function getMembers(): array
    {
        return $this->members;
    }
}

final class FamilyMemberDto
{
    public function __construct(
        #[ExceptionalValidation\Capture(BirthDateInFutureException::class, when: [$this, 'matchesException'], message: 'errors.birth_date.in_future')]
        private string $birthDate,
    ) {
    }

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

    public function matchesException(BirthDateInFutureException $exception): bool
    {
        return $exception->getBirthDate()->format('Y-m-d') === $this->birthDate;
    }
}

final class ConversionBlueprintHandler
{
    public function __invoke(UpdateFamilyProfileCommandDto $commandDto)
    {
        return new ConversionBluePrint($this->createBluePrint($commandDto));
    }

    private function createBluePrint(
        UpdateFamilyProfileCommandDto $commandDto,
    ): Closure {
        $id = $this->getId($commandDto);
        $family = $this->getFamily($commandDto);

        return static fn () => new UpdateFamilyProfileCommand($id(), $family());
    }

    private function getId(UpdateFamilyProfileCommandDto $commandDto): Closure
    {
        return static fn () => Uuid::fromString($commandDto->getId());
    }

    private function getFamily(UpdateFamilyProfileCommandDto $commandDto): Closure
    {
        $familyMembers = $this->getFamilyMembers($commandDto->getFamily());

        return static fn () => new Family($familyMembers());
    }

    private function getFamilyMembers(FamilyDto $familyDto): Closure
    {
        $members = [];

        foreach ($familyDto->getMembers() as $member) {
            $members[] = $this->getMember($member);
        }

        return static fn (): array => array_reduce($members, static fn (Closure $closure) => $closure());
    }

    private function getMember(FamilyMemberDto $familyMemberDto): Closure
    {
        $birthDate = $this->getBirthDate($familyMemberDto->getBirthDate());

        return static fn () => new FamilyMember($birthDate());
    }

    private function getBirthDate(string $birthDate): Closure
    {
        return static fn () => new BirthDate(new DateTimeImmutable($birthDate));
    }
}

$blueprint = new ConversionBlueprintHandler();
$closure = $blueprint->__invoke(new UpdateFamilyProfileCommandDto('c4feb193-f5fb-40b0-9abf-98fc683c5e96', new FamilyDto([
    new FamilyMemberDto('2024-01-01')
])));

$reflectionFunction = new ReflectionFunction($closure);
$rootUsedVariables = $reflectionFunction->getClosureUsedVariables();

$familyReflection = new ReflectionFunction($rootUsedVariables['family']);

$familyUsedVariables = $familyReflection->getClosureUsedVariables();

$membersReflection = new ReflectionFunction($familyUsedVariables['members']);

$membersVariables = $membersReflection->getClosureUsedVariables();

$firstMemberReflection = new ReflectionFunction($membersVariables['members'][0]);

$firstMemberVariables = $firstMemberReflection->getClosureUsedVariables();
$firstMemberBirthDateReflection = new ReflectionFunction($firstMemberVariables['birthDate']);

dd($firstMemberBirthDateReflection->getClosure()());
var_dump($closure);

die;

#[NextForwarded]
final class UpdateFamilyProfileCommand
{
    public function __construct(
        private Uuid $id,
        private Family $family,
    ) {
    }

    public function getId(): Uuid
    {
        return $this->id;
    }

    public function getFamily(): Family
    {
        return $this->family;
    }
}

final class Family
{
    public function __construct(
        /** @var FamilyMember[] */
        private array $familyMembers,
    ) {
    }

    public function getFamilyMembers(): array
    {
        return $this->familyMembers;
    }
}

final class FamilyMember
{
    public function __construct(
        private BirthDate $birthDate,
    ) {
    }

    public function getBirthDate(): BirthDate
    {
        return $this->birthDate;
    }
}

final class BirthDate
{
    public function __construct(
        private DateTimeImmutable $birthDate,
    ) {
        if ($this->birthDate > (new DateTimeImmutable())) {
            throw new BirthDateInFutureException($this->birthDate);
        }
    }
}

final class BirthDateInFutureException extends RuntimeException
{
    public function __construct(
        private DateTimeImmutable $birthDate,
    ) {
        parent::__construct();
    }

    public function getBirthDate(): DateTimeImmutable
    {
        return $this->birthDate;
    }
}

/** @var \Symfony\Component\Messenger\MessageBusInterface $commandBus */
$commandBus->dispatch(new UpdateFamilyProfileCommandDto());
rela589n commented 6 months ago

In some distant future, if PHP has single-parameter Closure unpacking, it would be possible to make following calls:

private function getCommand(
    UpdateFamilyProfileCommandDto $commandDto,
): UpdateFamilyProfileCommand {
    /** @var Closure(): Uuid $id */
    $id = $this->getId($commandDto);
    /** @var Closure(): Family $family */
    $family = $this->getFamily($commandDto);

    return new UpdateFamilyProfileCommand($id, $family);
}

final class UpdateFamilyProfileCommand
{
    public function __construct(
        private Uuid $id,
        private Family $family,
    ) {
    }
}

Where single-parameter Closure is reduced to the actual value behind the scenes during the method call.

rela589n commented 4 months ago

Most likely the problem is more generic than closures - it is all about code evaluation.

If PHP had lazy evaluation, I guess there would be no need to pack everything up with closures.

Also, interaction combinators evaluation is much more powerful than sequential code evaluation

rela589n commented 2 months ago

It seems that in latest version of PHP lazy objects are going to be introduced.

I think it's a good thing to consider.

One could use amphp/amp to create a future for the needed value and then use it in place of lazy object.

TODO: check this point in the PHP email list