From f2ab556eb3fee3efd942665866bc05fe9ceb07ca Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Thu, 25 Jun 2026 17:00:37 +0200 Subject: [PATCH 1/2] Extract PropertyResolver strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the resolution of validators from property types and explicit new resolution strategies required editing the class, and a class-typed property annotated with #[Attributes] was validated twice — once by the explicit-attribute branch and again by the declared-type branch — which tripped the circular-reference guard on the second pass. This commit moves that responsibility to a dedicated PropertyResolver interface with composable implementations, so each strategy can be developed and composed independently. The composite collapses duplicate Attributes entries, ensuring each property is validated exactly once even when multiple resolution paths produce the same instance. The Attributes class is reduced to its single responsibility, and resolver chains become pluggable through the constructor. --- docs/validators/Attributes.md | 1 + src-dev/Commands/LintMixinCommand.php | 3 +- src/Validators/Attributes.php | 61 +++------- .../Attributes/CompositePropertyResolver.php | 58 +++++++++ .../DeclaredTypePropertyResolver.php | 58 +++++++++ .../ExplicitAttributePropertyResolver.php | 31 +++++ .../Attributes/PropertyResolver.php | 24 ++++ tests/src/Stubs/StubPropertyResolver.php | 35 ++++++ tests/src/Stubs/WithClassTypedProperty.php | 19 +++ ...ithExplicitAttributesAttributeProperty.php | 22 ++++ .../Stubs/WithExplicitStringTypeProperty.php | 22 ++++ tests/src/Stubs/WithMixedProperty.php | 16 +++ tests/src/Stubs/WithNoValidatorAttributes.php | 19 +++ tests/src/Stubs/WithUntypedProperty.php | 17 +++ .../CompositePropertyResolverTest.php | 111 ++++++++++++++++++ .../DeclaredTypePropertyResolverTest.php | 97 +++++++++++++++ .../ExplicitAttributePropertyResolverTest.php | 73 ++++++++++++ 17 files changed, 621 insertions(+), 46 deletions(-) create mode 100644 src/Validators/Attributes/CompositePropertyResolver.php create mode 100644 src/Validators/Attributes/DeclaredTypePropertyResolver.php create mode 100644 src/Validators/Attributes/ExplicitAttributePropertyResolver.php create mode 100644 src/Validators/Attributes/PropertyResolver.php create mode 100644 tests/src/Stubs/StubPropertyResolver.php create mode 100644 tests/src/Stubs/WithClassTypedProperty.php create mode 100644 tests/src/Stubs/WithExplicitAttributesAttributeProperty.php create mode 100644 tests/src/Stubs/WithExplicitStringTypeProperty.php create mode 100644 tests/src/Stubs/WithMixedProperty.php create mode 100644 tests/src/Stubs/WithNoValidatorAttributes.php create mode 100644 tests/src/Stubs/WithUntypedProperty.php create mode 100644 tests/unit/Validators/Attributes/CompositePropertyResolverTest.php create mode 100644 tests/unit/Validators/Attributes/DeclaredTypePropertyResolverTest.php create mode 100644 tests/unit/Validators/Attributes/ExplicitAttributePropertyResolverTest.php diff --git a/docs/validators/Attributes.md b/docs/validators/Attributes.md index f518198ca..fd56b005b 100644 --- a/docs/validators/Attributes.md +++ b/docs/validators/Attributes.md @@ -8,6 +8,7 @@ SPDX-FileContributor: Henrique Moody # Attributes - `Attributes()` +- `Attributes(PropertyResolver $propertyResolver)` Validates the PHP attributes defined in the properties of the input. diff --git a/src-dev/Commands/LintMixinCommand.php b/src-dev/Commands/LintMixinCommand.php index c9fb1db2d..e7f84fb5d 100644 --- a/src-dev/Commands/LintMixinCommand.php +++ b/src-dev/Commands/LintMixinCommand.php @@ -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; @@ -75,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int scanner: $scanner, methodBuilder: new MethodBuilder( excludedTypePrefixes: ['Sokil', 'Egulias'], - excludedTypeNames: ['finfo'], + excludedTypeNames: ['finfo', PropertyResolver::class], ), interfaces: [ new InterfaceConfig( diff --git a/src/Validators/Attributes.php b/src/Validators/Attributes.php index d0fd303f8..ecbe0e70a 100644 --- a/src/Validators/Attributes.php +++ b/src/Validators/Attributes.php @@ -15,16 +15,17 @@ use Attribute; use ReflectionAttribute; use ReflectionClass; -use ReflectionIntersectionType; -use ReflectionNamedType; use ReflectionObject; use ReflectionProperty; -use ReflectionUnionType; use Respect\Fluent\Attributes\Composable; use Respect\Validation\Id; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; +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; @@ -43,6 +44,17 @@ final class Attributes implements Validator /** @var array */ private array $visited = []; + private readonly PropertyResolver $propertyResolver; + + public function __construct( + PropertyResolver|null $propertyResolver = null, + ) { + $this->propertyResolver = $propertyResolver ?? new CompositePropertyResolver( + new ExplicitAttributePropertyResolver(), + new DeclaredTypePropertyResolver(), + ); + } + public function evaluate(mixed $input): Result { $id = new Id('attributes'); @@ -87,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; } @@ -101,47 +113,6 @@ private function getPropertyValidators(ReflectionObject $reflection): array return $validators; } - /** @return array */ - private function getPropertyInnerValidators(ReflectionProperty $property): array - { - $propertyValidators = []; - $hasExplicitAttributes = false; - foreach ($property->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $propertyValidator = $attribute->getName() === self::class ? $this : $attribute->newInstance(); - $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 */ private function getProperties(ReflectionObject $reflection): array { diff --git a/src/Validators/Attributes/CompositePropertyResolver.php b/src/Validators/Attributes/CompositePropertyResolver.php new file mode 100644 index 000000000..03d730f86 --- /dev/null +++ b/src/Validators/Attributes/CompositePropertyResolver.php @@ -0,0 +1,58 @@ + + */ + +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 */ + private readonly array $resolvers; + + public function __construct(PropertyResolver ...$resolvers) + { + $this->resolvers = array_values($resolvers); + } + + /** @return array */ + 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; + } +} diff --git a/src/Validators/Attributes/DeclaredTypePropertyResolver.php b/src/Validators/Attributes/DeclaredTypePropertyResolver.php new file mode 100644 index 000000000..6186db1fa --- /dev/null +++ b/src/Validators/Attributes/DeclaredTypePropertyResolver.php @@ -0,0 +1,58 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +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 */ + 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 []; + } +} diff --git a/src/Validators/Attributes/ExplicitAttributePropertyResolver.php b/src/Validators/Attributes/ExplicitAttributePropertyResolver.php new file mode 100644 index 000000000..de42b5473 --- /dev/null +++ b/src/Validators/Attributes/ExplicitAttributePropertyResolver.php @@ -0,0 +1,31 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +use ReflectionAttribute; +use ReflectionProperty; +use Respect\Validation\Validator; +use Respect\Validation\Validators\Attributes; + +final class ExplicitAttributePropertyResolver implements PropertyResolver +{ + /** @return array */ + public function resolve(ReflectionProperty $property, Attributes $attributes): array + { + $validators = []; + foreach ($property->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $propertyValidator = $attribute->getName() === Attributes::class ? $attributes : $attribute->newInstance(); + $validators[] = $propertyValidator; + } + + return $validators; + } +} diff --git a/src/Validators/Attributes/PropertyResolver.php b/src/Validators/Attributes/PropertyResolver.php new file mode 100644 index 000000000..2d51741f7 --- /dev/null +++ b/src/Validators/Attributes/PropertyResolver.php @@ -0,0 +1,24 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +use ReflectionProperty; +use Respect\Validation\Validator; +use Respect\Validation\Validators\Attributes; + +/** + * Resolves validators from properties. + */ +interface PropertyResolver +{ + /** @return array */ + public function resolve(ReflectionProperty $property, Attributes $attributes): array; +} diff --git a/tests/src/Stubs/StubPropertyResolver.php b/tests/src/Stubs/StubPropertyResolver.php new file mode 100644 index 000000000..c0cf2475d --- /dev/null +++ b/tests/src/Stubs/StubPropertyResolver.php @@ -0,0 +1,35 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +use ReflectionProperty; +use Respect\Validation\Validator; +use Respect\Validation\Validators\Attributes; +use Respect\Validation\Validators\Attributes\PropertyResolver; + +use function array_values; + +final class StubPropertyResolver implements PropertyResolver +{ + /** @var list */ + private readonly array $validators; + + public function __construct(Validator ...$validators) + { + $this->validators = array_values($validators); + } + + /** @return list */ + public function resolve(ReflectionProperty $property, Attributes $attributes): array + { + return $this->validators; + } +} diff --git a/tests/src/Stubs/WithClassTypedProperty.php b/tests/src/Stubs/WithClassTypedProperty.php new file mode 100644 index 000000000..e2805b0a5 --- /dev/null +++ b/tests/src/Stubs/WithClassTypedProperty.php @@ -0,0 +1,19 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +final class WithClassTypedProperty +{ + public function __construct( + public NestedAddress $value, + ) { + } +} diff --git a/tests/src/Stubs/WithExplicitAttributesAttributeProperty.php b/tests/src/Stubs/WithExplicitAttributesAttributeProperty.php new file mode 100644 index 000000000..046b3037e --- /dev/null +++ b/tests/src/Stubs/WithExplicitAttributesAttributeProperty.php @@ -0,0 +1,22 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +use Respect\Validation\Validators as Rule; + +final class WithExplicitAttributesAttributeProperty +{ + public function __construct( + #[Rule\Attributes] + public NestedAddress $address, + ) { + } +} diff --git a/tests/src/Stubs/WithExplicitStringTypeProperty.php b/tests/src/Stubs/WithExplicitStringTypeProperty.php new file mode 100644 index 000000000..8cd32b42d --- /dev/null +++ b/tests/src/Stubs/WithExplicitStringTypeProperty.php @@ -0,0 +1,22 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +use Respect\Validation\Validators as Rule; + +final class WithExplicitStringTypeProperty +{ + public function __construct( + #[Rule\StringType] + public string $name, + ) { + } +} diff --git a/tests/src/Stubs/WithMixedProperty.php b/tests/src/Stubs/WithMixedProperty.php new file mode 100644 index 000000000..137ba7737 --- /dev/null +++ b/tests/src/Stubs/WithMixedProperty.php @@ -0,0 +1,16 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +final class WithMixedProperty +{ + public mixed $value; +} diff --git a/tests/src/Stubs/WithNoValidatorAttributes.php b/tests/src/Stubs/WithNoValidatorAttributes.php new file mode 100644 index 000000000..157c5e6d1 --- /dev/null +++ b/tests/src/Stubs/WithNoValidatorAttributes.php @@ -0,0 +1,19 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +final class WithNoValidatorAttributes +{ + public function __construct( + public mixed $value, + ) { + } +} diff --git a/tests/src/Stubs/WithUntypedProperty.php b/tests/src/Stubs/WithUntypedProperty.php new file mode 100644 index 000000000..6f579f775 --- /dev/null +++ b/tests/src/Stubs/WithUntypedProperty.php @@ -0,0 +1,17 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +final class WithUntypedProperty +{ + /** @phpstan-var array */ + public $value; +} diff --git a/tests/unit/Validators/Attributes/CompositePropertyResolverTest.php b/tests/unit/Validators/Attributes/CompositePropertyResolverTest.php new file mode 100644 index 000000000..1ca07bf1d --- /dev/null +++ b/tests/unit/Validators/Attributes/CompositePropertyResolverTest.php @@ -0,0 +1,111 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use ReflectionProperty; +use Respect\Validation\Test\Stubs\StubPropertyResolver; +use Respect\Validation\Test\Stubs\WithMixedProperty; +use Respect\Validation\Test\TestCase; +use Respect\Validation\Validators\Attributes; +use Respect\Validation\Validators\IntType; +use Respect\Validation\Validators\StringType; + +#[Group('rule')] +#[CoversClass(CompositePropertyResolver::class)] +final class CompositePropertyResolverTest extends TestCase +{ + private ReflectionProperty $property; + + private Attributes $attributes; + + protected function setUp(): void + { + $object = new WithMixedProperty(); + $this->property = new ReflectionProperty($object::class, 'value'); + $this->attributes = new Attributes(); + } + + #[Test] + public function shouldReturnEmptyWhenNoResolversProvided(): void + { + $resolver = new CompositePropertyResolver(); + + $result = $resolver->resolve($this->property, $this->attributes); + + self::assertSame([], $result); + } + + #[Test] + public function shouldReturnEmptyWhenAllResolversReturnEmpty(): void + { + $empty = new StubPropertyResolver(); + + $resolver = new CompositePropertyResolver($empty, $empty); + + $result = $resolver->resolve($this->property, $this->attributes); + + self::assertSame([], $result); + } + + #[Test] + public function shouldConcatenateResultsFromAllResolvers(): void + { + $first = new StubPropertyResolver(new StringType()); + $second = new StubPropertyResolver(new IntType()); + + $resolver = new CompositePropertyResolver($first, $second); + + $result = $resolver->resolve($this->property, $this->attributes); + + self::assertCount(2, $result); + self::assertInstanceOf(StringType::class, $result[0]); + self::assertInstanceOf(IntType::class, $result[1]); + } + + #[Test] + public function shouldConcatenateAttributesAlongsideOtherValidators(): void + { + $attributes = $this->attributes; + + $terminal = new StubPropertyResolver($attributes); + $other = new StubPropertyResolver(new StringType()); + + $resolver = new CompositePropertyResolver($terminal, $other); + + $result = $resolver->resolve($this->property, $attributes); + + self::assertCount(2, $result); + self::assertSame($attributes, $result[0]); + self::assertInstanceOf(StringType::class, $result[1]); + } + + #[Test] + public function shouldCollapseDuplicateAttributesAcrossResolvers(): void + { + $attributes = $this->attributes; + + // Both resolvers emit the same Attributes instance, as happens for a + // class-typed property annotated with #[Attributes] (DeclaredType and + // ExplicitAttribute each return [$attributes]). The composite must + // collapse them so the nested Attributes validator runs only once. + $first = new StubPropertyResolver($attributes); + $second = new StubPropertyResolver($attributes); + + $resolver = new CompositePropertyResolver($first, $second); + + $result = $resolver->resolve($this->property, $attributes); + + self::assertSame([$attributes], $result); + } +} diff --git a/tests/unit/Validators/Attributes/DeclaredTypePropertyResolverTest.php b/tests/unit/Validators/Attributes/DeclaredTypePropertyResolverTest.php new file mode 100644 index 000000000..40ed603e9 --- /dev/null +++ b/tests/unit/Validators/Attributes/DeclaredTypePropertyResolverTest.php @@ -0,0 +1,97 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use ReflectionProperty; +use Respect\Validation\Test\Stubs\NestedAddress; +use Respect\Validation\Test\Stubs\WithClassTypedProperty; +use Respect\Validation\Test\Stubs\WithIntersectionTypeNested; +use Respect\Validation\Test\Stubs\WithUnionTypeNested; +use Respect\Validation\Test\Stubs\WithUntypedProperty; +use Respect\Validation\Test\TestCase; +use Respect\Validation\Validators\Attributes; +use Respect\Validation\Validators\Given; + +use function in_array; + +#[Group('rule')] +#[CoversClass(DeclaredTypePropertyResolver::class)] +final class DeclaredTypePropertyResolverTest extends TestCase +{ + private DeclaredTypePropertyResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new DeclaredTypePropertyResolver(); + } + + #[Test] + public function shouldReturnAttributesForNonBuiltinNamedType(): void + { + $property = new ReflectionProperty(WithClassTypedProperty::class, 'value'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertCount(1, $result); + self::assertSame($attributes, $result[0]); + } + + #[Test] + public function shouldReturnAttributesForIntersectionType(): void + { + $property = new ReflectionProperty(WithIntersectionTypeNested::class, 'address'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertCount(1, $result); + self::assertSame($attributes, $result[0]); + } + + #[Test] + public function shouldReturnGivenValidatorsForUnionType(): void + { + $property = new ReflectionProperty(WithUnionTypeNested::class, 'address'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertCount(1, $result); + self::assertInstanceOf(Given::class, $result[0]); + self::assertFalse(in_array($attributes, $result, true)); + } + + #[Test] + public function shouldReturnEmptyForBuiltinNamedType(): void + { + $property = new ReflectionProperty(NestedAddress::class, 'street'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertSame([], $result); + } + + #[Test] + public function shouldReturnEmptyWhenPropertyHasNoDeclaredType(): void + { + $property = new ReflectionProperty(WithUntypedProperty::class, 'value'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertSame([], $result); + } +} diff --git a/tests/unit/Validators/Attributes/ExplicitAttributePropertyResolverTest.php b/tests/unit/Validators/Attributes/ExplicitAttributePropertyResolverTest.php new file mode 100644 index 000000000..899d66ca8 --- /dev/null +++ b/tests/unit/Validators/Attributes/ExplicitAttributePropertyResolverTest.php @@ -0,0 +1,73 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Attributes; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use ReflectionProperty; +use Respect\Validation\Test\Stubs\WithExplicitAttributesAttributeProperty; +use Respect\Validation\Test\Stubs\WithExplicitStringTypeProperty; +use Respect\Validation\Test\Stubs\WithNoValidatorAttributes; +use Respect\Validation\Test\TestCase; +use Respect\Validation\Validators\Attributes; +use Respect\Validation\Validators\StringType; + +use function in_array; + +#[Group('rule')] +#[CoversClass(ExplicitAttributePropertyResolver::class)] +final class ExplicitAttributePropertyResolverTest extends TestCase +{ + private ExplicitAttributePropertyResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new ExplicitAttributePropertyResolver(); + } + + #[Test] + public function shouldReturnEmptyWhenPropertyHasNoValidatorAttributes(): void + { + $property = new ReflectionProperty(WithNoValidatorAttributes::class, 'value'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertSame([], $result); + } + + #[Test] + public function shouldReturnValidatorWhenPropertyHasSingleValidatorAttribute(): void + { + $property = new ReflectionProperty(WithExplicitStringTypeProperty::class, 'name'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertCount(1, $result); + self::assertInstanceOf(StringType::class, $result[0]); + self::assertFalse(in_array($attributes, $result, true)); + } + + #[Test] + public function shouldReturnAttributesWhenPropertyHasAttributesAttribute(): void + { + $property = new ReflectionProperty(WithExplicitAttributesAttributeProperty::class, 'address'); + $attributes = new Attributes(); + + $result = $this->resolver->resolve($property, $attributes); + + self::assertCount(1, $result); + self::assertSame($attributes, $result[0]); + self::assertTrue(in_array($attributes, $result, true)); + } +} From b2f7e6fb0ccd0d81af5c71086f5f708947c8f7f2 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Thu, 25 Jun 2026 18:06:32 +0200 Subject: [PATCH 2/2] Replace Attributes::$visited array with WeakMap The circular-reference guard keyed visited objects by spl_object_id(), which is recycled once an object is garbage-collected. On a reused validator instance this caused two problems: unbounded growth of the visited map across calls, and false circular-reference failures when a fresh object inherited a freed ID. WeakMap is keyed by object identity, so distinct objects can never collide, and entries are reclaimed automatically when the key object is garbage-collected, no manual reset is required. Since WeakMap is not serializable, __sleep excludes the field and __wakeup reinitializes it, which also avoids carrying stale IDs across unserialize. --- src/Validators/Attributes.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Validators/Attributes.php b/src/Validators/Attributes.php index ecbe0e70a..207ce0646 100644 --- a/src/Validators/Attributes.php +++ b/src/Validators/Attributes.php @@ -27,8 +27,7 @@ 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)] @@ -41,8 +40,8 @@ final class Attributes implements Validator { public const string TEMPLATE_CIRCULAR_REFERENCE = '__circular_reference__'; - /** @var array */ - private array $visited = []; + /** @var WeakMap */ + private WeakMap $visited; private readonly PropertyResolver $propertyResolver; @@ -53,6 +52,7 @@ public function __construct( new ExplicitAttributePropertyResolver(), new DeclaredTypePropertyResolver(), ); + $this->visited = new WeakMap(); } public function evaluate(mixed $input): Result @@ -63,12 +63,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)]; @@ -127,4 +126,15 @@ private function getProperties(ReflectionObject $reflection): array return $properties; } + + /** @return list */ + public function __sleep(): array + { + return ['propertyResolver']; + } + + public function __wakeup(): void + { + $this->visited = new WeakMap(); + } }