Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add constructor decorator #79

Open
wants to merge 4 commits into
base: 4.4.x
Choose a base branch
from

Conversation

R4c00n
Copy link
Contributor

@R4c00n R4c00n commented Jan 26, 2022

Signed-off-by: Marcel Kempf [email protected]

Q A
Documentation no
Bugfix no
BC Break no
New Feature yes
RFC no
QA no

Description

Hi, I wanted to get rid of setters just for the sake of hydrators without using reflection to write directly to properties, so I wrote this little decorator that uses the constructor to hydrate an object. Works really well in our projects, maybe this could also be interesting to other people.

Small simplified example:

<?php

class MyObject {
  private int $foo;
  public function __construct(int $foo) {
    $this->foo = $foo;
  }

  public function getFoo(): int
  {
    return $this->foo;
  }
}

$hydrator = new ClassMethodsHydrator(false);
$hydrator->addStrategy('foo', ScalarTypeStrategy::createToInt());
$hydrator = new ConstructorParametersHydratorDecorator($hydrator);

$myObject = $hydrator->hydrate(['foo' => '42'], new ProxyObject(MyObject::class));
echo $myObject->getFoo(); // 42

Open question on my end:
For now I added new boolean flags to CollectionStrategy and HydratorStrategy in order to use the new ProxyObject instead of a reflection. Another way could be two new classes, e.g. ProxyObjectCollectionStrategy and ProxyObjectHydratorStrategy. What do you think about this?
Will adjust/add tests for those when that's decided.

Copy link
Member

@Ocramius Ocramius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire proxy logic leads to really non-intuitive semantics: the actual use-case needs a better example

@froschdesign
Copy link
Member

Basically I like the idea and the use of the constructor is definitely missing in this component.

But why do I have to add strategies for the individual types myself if they are already defined in the constructor of my object and the ReflectionClass is already being used in the decorator?

@R4c00n
Copy link
Contributor Author

R4c00n commented Feb 16, 2022

@froschdesign True, the decorator could easily detect scalar types and cast the values accordingly. Didn't think of that and will add it to the code :)
@Ocramius I agree, the ProxyObject is not the most beautiful thing to use. But I don't really see another way here. Any ideas?

@froschdesign
Copy link
Member

The way via the constructor would be great, because then all (ugly) workarounds can finally be removed. In userland code and the documentation:

$this->setObject(new Post('', ''));

https://docs.laminas.dev/tutorials/in-depth-guide/laminas-form-laminas-form-fieldset/#using-laminas-hydrator-with-laminas-form

    public function __construct(
        ?string $title = null,
        ?string $artist = null,
        array $tracks = []
    ) {
        $this->title  = $title;
        $this->artist = $artist;
        $this->tracks = $tracks;
    }

https://docs.laminas.dev/laminas-hydrator/v4/strategies/serializable/#example

The main problem here is: We have designed our objects for a framework / library.

@R4c00n
Copy link
Contributor Author

R4c00n commented Feb 16, 2022

@froschdesign With the latest commit the decorator now casts values for scalar parameters into their proper type.

The example from the PR description can now also look like this:

<?php

class MyObject {
  private int $foo;
  public function __construct(int $foo) {
    $this->foo = $foo;
  }

  public function getFoo(): int
  {
    return $this->foo;
  }
}

$hydrator = new ConstructorParametersHydratorDecorator(new ClassMethodsHydrator(false));

$myObject = $hydrator->hydrate(['foo' => '42'], new ProxyObject(MyObject::class));
echo $myObject->getFoo(); // 42

Signed-off-by: Marcel Kempf <[email protected]>
Signed-off-by: Marcel Kempf <[email protected]>
@R4c00n
Copy link
Contributor Author

R4c00n commented Mar 14, 2022

Hi @froschdesign, what do you think about the automatic type casting I added?

@R4c00n R4c00n marked this pull request as ready for review March 17, 2022 13:35
@vaclavvanik
Copy link
Contributor

Hello,
I like objects with constructor ;-).

class EmailAddress
{
    private string $emailAddress;
    
    public function __construct(string $emailAddress)
    {
        if ($emailAddress === '') {
           throw new ValueError('Email address cannot be empty');
        }
    
        $this->emailAddress = $emailAddress;
    }
    
    public function getEmailAddress(): string
    {
        return $this->emailAddress;
    }
}

With this kind of object the HydrationInterace::hydrate(array $data, object $object) does not make a sense.

It should be IMHO

interface HydrationInterface
{
    public function hydrate(array $data, ?object $object = null): object;
}

because my HydrationInterface implementation could be simplified to:

final class EmailAddressHydrator implements HydrationInterface
{
    public function hydrate(array $data, ?object $object = null): object
    {
        return new EmailAddress($data['email_address'] ?? '');
    }
}

@ezkimo
Copy link

ezkimo commented Jun 29, 2023

Can we discuss the non intuitive logic of this implementation?

When having a look at all other hydrator classes we see that they have all in common: they 're easy to use. Personally, I don't understand why this proxy class is needed. A possible constructor property promotion hydrator can work pretty much like the ClassMethodsHydrator class. Here 's a short, easy to understand example ...

A quick draft

<?php

declare(strict_types=1);

namespace Marcel\Test\Hydrator;

use Laminas\Hydrator;

class CustructorPropertyPromotionHydrator extends Hydrator\AbstractHydrator
{
    protected array $parametersCache = [];

    protected $underscoreSeparatedKeys = true;
    
    public function __construct(bool $underscoreSeparatedKeys = true)
    {
        $this->setUnderscoreSeparatedKeys($underscoreSeparatedKeys);
    }

    public function extract(object $extract): array
    {
        ...
    }

    public function hydrate(array $data, object $object)
    {
        if (! $object instanceof \ReflectionClass) {
            $object = new \ReflectionClass($object);
        }

        $objectName = $object->getName();

        if (! isset($this->parametersCache[$objectName])) {
            $constructor = $object->getConstructor();

            if ($constructor === null) {
                throw new Hydrator\Exception\InvalidArgumentException('Object needs a constructor.');
            }

            $this->parametersCache[$objectName] = $constructor->getParameters();
        }

        $values = [];

        foreach ($data as $key => $value) {
            $name = $this->hydrateName($key, $data);

            $parameter = array_filter(
                $this->parametersCache[$objectName], 
                function($element) use ($name): bool {
                    return $element->getName() === $name;
                }
            );

            if (empty($parameter)) {
                continue;
            }

            $values[$name] = $this->hydrateValue($name, $value, $data);
        }

        $orderedParameters = [];
        foreach ($this->parametersCache[$objectName] as $parameter) {
            if (isset($values[$parameter->getName()])) {
                $orderedParameters[] = $values[$parameter->getName()];
            }
        }

        $object = $object->newInstanceArgs($orderedParameters);

        return $object;
    }

    public function setUnderscoreSeparatedKeys(bool $underscoreSeparatedKeys): void
    {
        $this->underscoreSeparatedKeys = $underscoreSeparatedKeys;

        if ($this->underscoreSeparatedKeys) {
            $this->setNamingStrategy(new UnderscoreNamingStrategy());
            return;
        }

        if ($this->hasNamingStrategy()) {
            $this->removeNamingStrategy();
            return;
        }
    }
}

Bear with me. This is just a raw, quick draft, meant only as an illustration. So, what does this hydrator exactly do? It pretty much works like the ClassMethodsHydrator class. It takes naming strategies into account, because form data like 'registration_date' has to be converted into camel cased $registrationDate. It does not care about types or if the data matches all parameters or default values, which will be set on construction any way. It just orders the given data to the parameter order.

Why no type validation? You have to do that before. That 's the reason input filters where made for. You have to validate your incoming data before validation. That 's not a task of a hydrator.

Why no default values? Default values are set anyway when initializing an object and no other data was given. That 's what default values are made for. They are default.

Why no validation if the given data matches all non-default parameters? Well, that 's also the job of input filters and validation. In every conceivable scenario, the data must already be filtered and validated as soon as it is transferred to a hydrator. It is not the task of a hydrator to filter or validate the transferred data. If the data does not match the required constructor parameters, it throws an exception anyway.

Example

<?php

readonly class Album
{
    public function __construct(
        protected string $name,
        protected Artist $artist,
        protected DateTimeImmutable $releaseDate
        protected ?string $label =null
    ) {}

    public function getName(): string
    {
        return $this->name;
    }
    
    public function getArtist(): Artist
    {
        return $this->artist;
    }

    public function getReleaseDate(): DateTimeImmutable
    {
        return $this->releaseDate;
    }

    public function getLabel(): ?string
    {
        return $this->label
    }
}

readonly class Artist
{
    public function __construct(
        protected string $name 
    ) {}

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

$data = [
    'name' => 'You left a hole',
    'artist' => [
        'name' => 'Embrionyc',
    ],
    'label' => 'The Third Movement',
    'release_date' => '2023-06-30',
];

// Normally done in a factory ...
$hydratorr = new ConstructorPropertyPromotionHydrator();
$hydrator->addStrategy('artist', new HydratorStrategy(
    new ConstructorPropertyPromotionHydrator(),
    Artist::class
));
$hydrator->addStrategy('releaseDate', new DateTimeImmutableFormatterStrategy(new DateTimeFormatterStrategy(format: 'Y-m-d')));

$object->hydrate($data, new ReflectionClass(Album::class));

The above shown code results into the expected object structure.

Conclusion

Hydration should be simple and intutive. Sure, there are use cases, that need complex hydrators and delegation. But this should be an exceptional case. Just keep it simple.

@froschdesign
Copy link
Member

@ezkimo

Why no type validation? You have to do that beforder.

I think that is the reason why this proposal is not moving forward, because there are already libraries that solve the issue of types in a much better way. Example: https://github.com/EventSaucePHP/ObjectHydrator

Well, that 's also the job of input filters and validation.

If the hydrator can use the typed parameters from the constructor, why should I still create an input filter by hand? If we had a CLI command that automatically created an input filter based on an object or builders like in laminas-form, then I would go along with your argument.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants