diff --git a/src/Attribute/Builder.php b/src/Attribute/Builder.php index c7337a18..fe2aa783 100644 --- a/src/Attribute/Builder.php +++ b/src/Attribute/Builder.php @@ -10,6 +10,7 @@ use Membrane\Builder\Builder as BuilderInterface; use Membrane\Builder\Specification; use Membrane\Exception\CannotProcessProperty; +use Membrane\Filter; use Membrane\Processor; use Membrane\Processor\AfterSet; use Membrane\Processor\BeforeSet; @@ -17,9 +18,9 @@ use Membrane\Processor\Field; use Membrane\Processor\FieldSet; use Membrane\Processor\ProcessorType; +use Membrane\Validator; use ReflectionAttribute; use ReflectionClass; -use ReflectionNamedType; use ReflectionProperty; use function array_map; @@ -53,30 +54,84 @@ private function fromClass(string $class, string $processes = ''): Processor continue; } - $type = $property->getType(); + $processors[] = $this->getProcessorFromProperty($property); + } - if ($type === null) { - throw CannotProcessProperty::noTypeHint($property->getName()); - } + return new FieldSet($processes, ...$processors); + } - if (!($type instanceof ReflectionNamedType)) { - throw CannotProcessProperty::compoundPropertyType($property->getName()); - } + private function getProcessorFromProperty(ReflectionProperty $property): Processor + { + $type = $property->getType(); + + if ($type === null) { + throw CannotProcessProperty::noTypeHint($property->getName()); + } - $processorType = $this->getProcessorTypeFromPropertyType($type->getName()); - $processorTypeAttribute = current($property->getAttributes(OverrideProcessorType::class)); - if ($processorTypeAttribute !== false) { - $processorType = $processorTypeAttribute->newInstance()->type; + if ($type instanceof \ReflectionIntersectionType) { + throw CannotProcessProperty::intersectionTypeHint($property->getName()); + } + + if ($type instanceof \ReflectionUnionType) { + $processors = []; + + foreach ($type->getTypes() as $subType) { + if ($subType instanceof \ReflectionIntersectionType) { + throw CannotProcessProperty::intersectionTypeHint($property->getName()); + } + + if (!in_array($subType->getName(), ['bool', 'float', 'int', 'string', 'null', 'true', 'false'])) { + throw CannotProcessProperty::compoundPropertyType($property->getName()); + } + + $processors [] = $this->makeField($property->getName(), ...$this + ->getFiltersOrValidators($property, $subType->getName())); } - $processors[] = match ($processorType) { - ProcessorType::Field => $this->makeField($property), - ProcessorType::Fieldset => $this->fromClass($type->getName(), $property->getName()), - ProcessorType::Collection => $this->makeCollection($property), - }; + // AnyOf is faster than OneOf since it performs less checks + return new Processor\AnyOf($property->getName(), ...$processors); } - return new FieldSet($processes, ...$processors); + assert($type instanceof \ReflectionNamedType); // proof by exhaustion (all alternatives have been checked above) + + $processorType = $this->getProcessorTypeFromPropertyType($type->getName()); + $processorTypeAttribute = current($property->getAttributes(OverrideProcessorType::class)); + if ($processorTypeAttribute !== false) { + $processorType = $processorTypeAttribute->newInstance()->type; + } + + return match ($processorType) { + ProcessorType::Field => $this->makeField($property->getName(), ...$this + ->getFiltersOrValidators($property, $type->getName())), + ProcessorType::Fieldset => $this->fromClass($type->getName(), $property->getName()), + ProcessorType::Collection => $this->makeCollection($property), + }; + } + + /** @return array */ + private function getFiltersOrValidators( + ReflectionProperty $property, + string $type + ): array { + $reflectionAttributes = $property->getAttributes(); + + $result = []; + foreach ($reflectionAttributes as $reflectionAttribute) { + $attribute = $reflectionAttribute->newInstance(); + + switch (true) { + case $attribute instanceof When: + if ($attribute->typeIs === $type) { + $result [] = $attribute->filterOrValidator->class; + } + break; + case $attribute instanceof FilterOrValidator: + $result [] = $attribute->class; + break; + } + } + + return $result; } private function getProcessorTypeFromPropertyType(string $type): ProcessorType @@ -96,28 +151,20 @@ enum_exists($type) }; } - private function makeField(ReflectionProperty $property): Field - { - $attributes = $property->getAttributes( - FilterOrValidator::class, - ReflectionAttribute::IS_INSTANCEOF - ); - - return new Field( - $property->getName(), - ...array_map(fn($reflectionAttribute) => $reflectionAttribute->newInstance()->class, $attributes) - ); + private function makeField( + string $propertyName, + Filter|Validator ...$filtersOrValidators + ): Field { + return new Field($propertyName, ...$filtersOrValidators); } private function makeCollection(ReflectionProperty $property): Processor { - $subtype = (current($property->getAttributes(Subtype::class)) ?: null) - ?->newInstance() - ?->type; - - if ($subtype === null) { + $subtype = current($property->getAttributes(Subtype::class)); + if ($subtype === false) { throw CannotProcessProperty::noSubtypeHint($property->getName()); } + $subtype = $subtype->newInstance()->type; $subProcessorType = $this->getProcessorTypeFromPropertyType($subtype); @@ -130,7 +177,8 @@ private function makeCollection(ReflectionProperty $property): Processor $processors[] = match ($subProcessorType) { ProcessorType::Fieldset => $this->fromClass($subtype, $property->getName()), - ProcessorType::Field => $this->makeField($property), + ProcessorType::Field => $this->makeField($property->getName(), ...$this + ->getFiltersOrValidators($property, $subtype)), ProcessorType::Collection => throw CannotProcessProperty::nestedCollection($property->getName()) }; diff --git a/src/Attribute/When.php b/src/Attribute/When.php new file mode 100644 index 00000000..feac0a5a --- /dev/null +++ b/src/Attribute/When.php @@ -0,0 +1,15 @@ +build($specification); } - #[Test] - public function compoundPropertyThrowsException(): void - { - $specification = new ClassWithAttributes(ClassWithCompoundPropertyType::class); - $builder = new Builder(); - - self::expectException(CannotProcessProperty::class); - self::expectExceptionMessage( - 'Property compoundProperty uses a compound type hint, these are not currently supported' - ); - - $builder->build($specification); - } - #[Test] public function nestedCollectionThrowsException(): void { @@ -177,8 +169,8 @@ public function nestedCollectionThrowsException(): void public static function dataSetOfClassesToBuild(): array { return [ - EmptyClass::class => [ - new ClassWithAttributes(EmptyClass::class), + 'empty class' => [ + new ClassWithAttributes((new class() {})::class), new FieldSet(''), ], EmptyClassWithIgnoredProperty::class => [ @@ -201,7 +193,7 @@ public static function dataSetOfClassesToBuild(): array new ClassWithAttributes(ClassWithIntPropertyIsIntValidator::class), new FieldSet('', new Field('integerProperty', new IsInt())), ], - EnumPropeties::class => [ + EnumProperties::class => [ new ClassWithAttributes(EnumProperties::class), new FieldSet( '', @@ -282,13 +274,87 @@ public static function dataSetOfClassesToBuild(): array ) ), ], - + 'union type' => [ + new ClassWithAttributes((new class () {private float|int $number;})::class), + new FieldSet( + '', + new AnyOf( + 'number', + new Field('number'), + new Field('number'), + ), + ), + ], + 'union type, When float, isfloat?' => [ + new ClassWithAttributes((new class () { + #[When('float', new FilterOrValidator(new IsFloat()))] + private float|int $number; + })::class), + new FieldSet( + '', + new AnyOf( + 'number', + new Field('number'), + new Field('number', new IsFloat()), + ), + ), + ], + 'union type. When float, isfloat? When int, isInt?' => [ + new ClassWithAttributes((new class () { + #[When('float', new FilterOrValidator(new IsFloat()))] + #[When('int', new FilterOrValidator(new IsInt()))] + private float|int $number; + })::class), + new FieldSet( + '', + new AnyOf( + 'number', + new Field('number', new IsInt()), + new Field('number', new IsFloat()), + ), + ), + ], + 'union type. When string NumericString?' => [ + new ClassWithAttributes((new class () { + #[When('string', new FilterOrValidator(new NumericString()))] + #[When('string', new FilterOrValidator(new ToNumber()))] + #[FilterOrValidator(new Maximum(3.14))] + private float|int|string $number; + })::class), + new FieldSet( + '', + new AnyOf( + 'number', + new Field('number', new NumericString(), new ToNumber(), new Maximum(3.14)), + new Field('number', new Maximum(3.14)), + new Field('number', new Maximum(3.14)), + ), + ), + ], + 'union type. Whens and FilterOrValidators mixed together' => [ + new ClassWithAttributes((new class () { + #[When('string', new FilterOrValidator(new NumericString()))] + #[When('string', new FilterOrValidator(new ToNumber()))] + #[FilterOrValidator(new Maximum(3.14))] + #[When('string', new FilterOrValidator(new ToString()))] + private float|int|string $number; + })::class), + new FieldSet( + '', + new AnyOf( + 'number', + new Field('number', new NumericString(), new ToNumber(), new Maximum(3.14), new ToString()), + new Field('number', new Maximum(3.14)), + new Field('number', new Maximum(3.14)), + ), + ), + ], ]; } - #[DataProvider('dataSetOfClassesToBuild')] #[Test] - public function BuildingProcessorsTest(Specification $specification, FieldSet $expected): void + #[DataProvider('dataSetOfClassesToBuild')] + public function buildingProcessorsTest(Specification $specification, FieldSet $expected): void { $builder = new Builder(); @@ -300,10 +366,10 @@ public function BuildingProcessorsTest(Specification $specification, FieldSet $e public static function dataSetOfInputsAndOutputs(): array { return [ - EmptyClass::class => [ - new ClassWithAttributes(EmptyClass::class), + 'empty class' => [ + new ClassWithAttributes((new class() {})::class), [], - Result::noResult([]), + Result::noResult([]) ], EmptyClassWithIgnoredProperty::class => [ new ClassWithAttributes(EmptyClassWithIgnoredProperty::class), @@ -369,8 +435,8 @@ public static function dataSetOfInputsAndOutputs(): array ]; } - #[DataProvider('dataSetOfInputsAndOutputs')] #[Test] + #[DataProvider('dataSetOfInputsAndOutputs')] public function InputsAndOutputsTest(Specification $specification, mixed $input, mixed $expected): void { $builder = new Builder(); @@ -551,8 +617,8 @@ public static function dataSetsWithDocExamples(): array ]; } - #[DataProvider('dataSetsWithDocExamples')] #[Test] + #[DataProvider('dataSetsWithDocExamples')] public function docExamplesTest(Specification $specification, array $input, Result $expected): void { $builder = new Builder(); diff --git a/tests/MembraneTest.php b/tests/MembraneTest.php index f878514e..8a4031ff 100644 --- a/tests/MembraneTest.php +++ b/tests/MembraneTest.php @@ -21,7 +21,6 @@ use Membrane\Result\Message; use Membrane\Result\MessageSet; use Membrane\Result\Result; -use Membrane\Tests\Fixtures\Attribute\EmptyClass; use Membrane\Validator\FieldSet\RequiredFields; use Membrane\Validator\Type\IsList; use PHPUnit\Framework\Attributes\CoversClass; @@ -102,7 +101,7 @@ public static function provideSpecifications(): Generator yield 'Attributes' => [ new class { }, - new ClassWithAttributes(EmptyClass::class), + new ClassWithAttributes((new class () {})::class), ]; yield 'Request' => [ new \GuzzleHttp\Psr7\Request('get', ''), diff --git a/tests/fixtures/Attribute/EmptyClass.php b/tests/fixtures/Attribute/EmptyClass.php deleted file mode 100644 index e34742ab..00000000 --- a/tests/fixtures/Attribute/EmptyClass.php +++ /dev/null @@ -1,9 +0,0 @@ -