Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/validators/Attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
# Attributes

- `Attributes()`
<<<<<<< HEAD
- `Attributes(Resolver $resolver)`
=======
- `Attributes(PropertyResolver $propertyResolver)`
>>>>>>> f2ab556e (Extract PropertyResolver strategy)

Validates the PHP attributes defined in the properties of the input.

Expand Down
3 changes: 2 additions & 1 deletion src-dev/Commands/LintMixinCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Respect\Validation\Mixins\Chain;
use Respect\Validation\Validator;
use Respect\Validation\ValidatorBuilder;
use Respect\Validation\Validators\Attributes\PropertyResolver;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -75,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
scanner: $scanner,
methodBuilder: new MethodBuilder(
excludedTypePrefixes: ['Sokil', 'Egulias', 'Ramsey', 'libphonenumber'],
excludedTypeNames: ['Respect\\Parameter\\Resolver'],
excludedTypeNames: ['finfo', PropertyResolver::class],
),
interfaces: [
new InterfaceConfig(
Expand Down
102 changes: 29 additions & 73 deletions src/Validators/Attributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@
use Attribute;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionObject;
use ReflectionProperty;
use ReflectionUnionType;
use Respect\Fluent\Attributes\Composable;
use Respect\Parameter\Resolver;
use Respect\Validation\Id;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validator;
use Respect\Validation\Validators\Attributes\BypassResolver;
use Respect\Validation\Validators\Attributes\CompositePropertyResolver;
use Respect\Validation\Validators\Attributes\DeclaredTypePropertyResolver;
use Respect\Validation\Validators\Attributes\ExplicitAttributePropertyResolver;
use Respect\Validation\Validators\Attributes\PropertyResolver;
use Respect\Validation\Validators\Core\Reducer;

use function spl_object_id;
use WeakMap;

#[Composable(without: [All::class, Key::class, Property::class, Not::class, UndefOr::class])]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
Expand All @@ -41,11 +41,19 @@ final class Attributes implements Validator
{
public const string TEMPLATE_CIRCULAR_REFERENCE = '__circular_reference__';

/** @var array<int, true> */
private array $visited = [];
/** @var WeakMap<object, true> */
private WeakMap $visited;

public function __construct(private readonly Resolver|null $resolver = null)
{
private readonly PropertyResolver $propertyResolver;

public function __construct(
PropertyResolver|null $propertyResolver = null,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@alganet because of this new injection, I would like to only release version 3.2 after this has been merged. However, there are a few things that are not optimal with this.

For starters, I think we should have a resolver that works on the class level rather than the property level because that will allow us to also use doc blocks in the future. It would then make sense that once of those class resolver take care of the properties by itself, so we don't need to call any PropertyResolver here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you worried about breaking BC on the parameter signature?

For me, Attributes is parameter-less. We could document that the parameter is internal and subject to change, and only say it's public when it's done.

We need to do more of that (what is internal vs what is public) so we can have more freedom. Not all mechanisms in validation are public API surface.


The resolver you mention is a subsystem of Validation that is starting to couple cache ideas, docblock scanning and so on. It has now become more than a validator.

I think this subsystem deserves a careful design, and it's likely to take a lot of time, so I'm concerned about blocking release of other features while we do it. Perhaps there's a way we can deliver recursive attributes (lower tier improvement) before going full-feature.

) {
$this->propertyResolver = $propertyResolver ?? new CompositePropertyResolver(
new ExplicitAttributePropertyResolver(new BypassResolver()),
new DeclaredTypePropertyResolver(),
);
$this->visited = new WeakMap();
}

public function evaluate(mixed $input): Result
Expand All @@ -56,12 +64,11 @@ public function evaluate(mixed $input): Result
return $objectType->withId($id);
}

$objectId = spl_object_id($input);
if (isset($this->visited[$objectId])) {
if ($this->visited->offsetExists($input)) {
return Result::failed($input, $this, [], self::TEMPLATE_CIRCULAR_REFERENCE)->withId($id);
}

$this->visited[$objectId] = true;
$this->visited[$input] = true;

$reflection = new ReflectionObject($input);
$validators = [...$this->getClassValidators($reflection), ...$this->getPropertyValidators($reflection)];
Expand All @@ -78,7 +85,7 @@ private function getClassValidators(ReflectionObject $reflection): array
$validators = [];
while ($reflection instanceof ReflectionClass) {
foreach ($reflection->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$validators[] = $this->instantiateAttribute($attribute);
$validators[] = $attribute->newInstance();
}

$reflection = $reflection->getParentClass();
Expand All @@ -92,7 +99,7 @@ private function getPropertyValidators(ReflectionObject $reflection): array
{
$validators = [];
foreach ($this->getProperties($reflection) as $propertyName => $property) {
$propertyValidators = $this->getPropertyInnerValidators($property);
$propertyValidators = $this->propertyResolver->resolve($property, $this);
if ($propertyValidators === []) {
continue;
}
Expand All @@ -106,52 +113,6 @@ private function getPropertyValidators(ReflectionObject $reflection): array
return $validators;
}

/** @return array<Validator> */
private function getPropertyInnerValidators(ReflectionProperty $property): array
{
$propertyValidators = [];
$hasExplicitAttributes = false;
foreach ($property->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
if ($attribute->getName() === self::class) {
$propertyValidator = $this;
} else {
$propertyValidator = $this->instantiateAttribute($attribute);
}

$hasExplicitAttributes = $hasExplicitAttributes || $propertyValidator === $this;
$propertyValidators[] = $propertyValidator;
}

if ($hasExplicitAttributes) {
return $propertyValidators;
}

$type = $property->getType();
if ($type instanceof ReflectionNamedType) {
if (!$type->isBuiltin()) {
$propertyValidators[] = $this;
}
}

if ($type instanceof ReflectionIntersectionType) {
$propertyValidators[] = $this;
}

if ($type instanceof ReflectionUnionType) {
foreach ($type->getTypes() as $innerType) {
if (!$innerType instanceof ReflectionNamedType || $innerType->isBuiltin()) {
continue;
}

/** @var class-string $class */
$class = $innerType->getName();
$propertyValidators[] = new Given(new Instance($class), $this);
}
}

return $propertyValidators;
}

/** @return array<ReflectionProperty> */
private function getProperties(ReflectionObject $reflection): array
{
Expand All @@ -167,19 +128,14 @@ private function getProperties(ReflectionObject $reflection): array
return $properties;
}

/** @param ReflectionAttribute<Validator> $attribute */
private function instantiateAttribute(ReflectionAttribute $attribute): Validator
/** @return list<string> */
public function __sleep(): array
{
if ($this->resolver === null) {
return $attribute->newInstance();
}

$reflection = new ReflectionClass($attribute->getName());
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return $attribute->newInstance();
}
return ['propertyResolver'];
}

return $reflection->newInstanceArgs($this->resolver->resolve($constructor, $attribute->getArguments()));
public function __wakeup(): void
{
$this->visited = new WeakMap();
}
}
14 changes: 14 additions & 0 deletions src/Validators/Attributes/BypassResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Respect\Validation\Validators\Attributes;

use ReflectionFunctionAbstract;
use Respect\Parameter\Resolver;

final class BypassResolver implements Resolver

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I created this one here quickly, just to signal the use-case, but this should be in Respect\Parameter

{
public function resolve(ReflectionFunctionAbstract $reflection, array $arguments): array
{
return $arguments;
}
}
58 changes: 58 additions & 0 deletions src/Validators/Attributes/CompositePropertyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators\Attributes;

use ReflectionProperty;
use Respect\Validation\Validator;
use Respect\Validation\Validators\Attributes;

use function array_values;
use function in_array;

final class CompositePropertyResolver implements PropertyResolver
{
/** @var list<PropertyResolver> */
private readonly array $resolvers;

public function __construct(PropertyResolver ...$resolvers)
{
$this->resolvers = array_values($resolvers);
}

/** @return array<Validator> */
public function resolve(ReflectionProperty $property, Attributes $attributes): array
{
$accumulated = [];

foreach ($this->resolvers as $resolver) {
$validators = $resolver->resolve($property, $attributes);
if ($validators === []) {
continue;
}

// When more than one resolver recognizes the same property (e.g. a
// class-typed property annotated with #[Attributes], which both
// DeclaredTypePropertyResolver and ExplicitAttributePropertyResolver
// emit `$attributes` for), collapse duplicate `$attributes` entries
// so the nested Attributes validator runs once instead of tripping
// its circular-reference guard on the second pass.
foreach ($validators as $validator) {
if ($validator === $attributes && in_array($validator, $accumulated, true)) {
continue;
}

$accumulated[] = $validator;
}
}

return $accumulated;
}
}
58 changes: 58 additions & 0 deletions src/Validators/Attributes/DeclaredTypePropertyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators\Attributes;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure about the namespacing.

Until now, we kept the Validators namespace containing exclusively Validator instances.

The Fluent family even builds on this convention, scanning folders for classes that it assumes are all the same type within a namespace (it has an interface check, but still).

The way I see, the resolvers are not validators and should instead be somewhere else.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm fine to move it somewhere else, but they're not generic. They're very Attributes specific.

I would have been happier if that wasn't the case, but dealing with the cycle here was the path I found. Maybe we could pass the WeakMap and a $depth here, but I struggle to visualize this working now (without testing it first)

@alganet alganet Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Am I missing something? Does the namespace declaration has any role in the recursion detection?

I'm discussing that namespace Respect\Validation\Validators\Attributes; is a resolve-mechanism inside the bucket that previously only held Validators. Maybe namespace Respect\Validation\Attributes; would fit better.


use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionUnionType;
use Respect\Validation\Validator;
use Respect\Validation\Validators\Attributes;
use Respect\Validation\Validators\Given;
use Respect\Validation\Validators\Instance;

final class DeclaredTypePropertyResolver implements PropertyResolver
{
/** @return array<Validator> */
public function resolve(ReflectionProperty $property, Attributes $attributes): array
{
$type = $property->getType();

if ($type instanceof ReflectionNamedType) {
if ($type->isBuiltin()) {
return [];
}

return [$attributes];
}

if ($type instanceof ReflectionIntersectionType) {
return [$attributes];
}

if ($type instanceof ReflectionUnionType) {
$validators = [];
foreach ($type->getTypes() as $innerType) {
if (!$innerType instanceof ReflectionNamedType || $innerType->isBuiltin()) {
continue;
}

/** @var class-string $class */
$class = $innerType->getName();
$validators[] = new Given(new Instance($class), $attributes);
}

return $validators;
}

return [];
}
}
55 changes: 55 additions & 0 deletions src/Validators/Attributes/ExplicitAttributePropertyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Validation\Validators\Attributes;

use ReflectionAttribute;
use ReflectionClass;
use ReflectionProperty;
use Respect\Parameter\Resolver;
use Respect\Validation\Validator;
use Respect\Validation\Validators\Attributes;

final class ExplicitAttributePropertyResolver implements PropertyResolver
{
public function __construct(
private readonly Resolver $resolver,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@alganet I'm wondering if we should pass the factory here instead of the resolver.

Part of me thinks it would be a big overhead, but if we want to do something like I mentioned in Parameter we will need somewhere to mediate between the Resolver and the creation of each validator.

Another part of me want to keep only the Resolver here, to make things more simpler at the cost of making things inflexible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I still don't understand the real use case for that. I mean, I get the Animal -> Goat, Cat analogy but I don't get why we need that in Validation.

) {
}

/** @return array<Validator> */
public function resolve(ReflectionProperty $property, Attributes $attributes): array
{
$validators = [];
foreach ($property->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$propertyValidator = $this->toValidator($attribute, $attributes);
$validators[] = $propertyValidator;
}

return $validators;
}

private function toValidator(ReflectionAttribute $attribute, Attributes $attributes): Validator
{
/** @var class-string<Validator> $name */
$name = $attribute->getName();
if ($name === Attributes::class) {
return $attributes;
}

$reflection = new ReflectionClass($name);
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return $attribute->newInstance();
}

return $reflection->newInstanceArgs($this->resolver->resolve($constructor, $attribute->getArguments()));
}
}
Loading
Loading