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..207ce0646 100644 --- a/src/Validators/Attributes.php +++ b/src/Validators/Attributes.php @@ -15,19 +15,19 @@ 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; +use WeakMap; #[Composable(without: [All::class, Key::class, Property::class, Not::class, UndefOr::class])] #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] @@ -40,8 +40,20 @@ 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; + + public function __construct( + PropertyResolver|null $propertyResolver = null, + ) { + $this->propertyResolver = $propertyResolver ?? new CompositePropertyResolver( + new ExplicitAttributePropertyResolver(), + new DeclaredTypePropertyResolver(), + ); + $this->visited = new WeakMap(); + } public function evaluate(mixed $input): Result { @@ -51,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)]; @@ -87,7 +98,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 +112,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 { @@ -156,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(); + } } 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)); + } +}