diff --git a/composer.json b/composer.json index d1f7404..94ce902 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,9 @@ "type": "library", "require": { "php": ">=8.2", - "ramsey/collection": "^2.1.1" + "ramsey/collection": "^2.1.1", + "thecodingmachine/safe": "^2 || ^3.0", + "nette/utils": "^4.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.74", diff --git a/src/Attribute/ObjectType/Slots.php b/src/Attribute/ObjectType/Slots.php index 2d31b0b..83d074d 100644 --- a/src/Attribute/ObjectType/Slots.php +++ b/src/Attribute/ObjectType/Slots.php @@ -4,6 +4,7 @@ namespace Pinto\Attribute\ObjectType; +use Nette\Utils\Type; use Pinto\Exception\PintoThemeDefinition; use Pinto\Exception\Slots\BuildValidation; use Pinto\List\ObjectListInterface; @@ -17,6 +18,7 @@ use Pinto\Slots\RenameSlots; use Pinto\Slots\Slot; use Pinto\Slots\SlotList; +use Pinto\Slots\Validation; /** * An attribute representing an object with slots. @@ -122,22 +124,37 @@ public static function lateBindObjectToBuild(mixed $build, mixed $definition, ob public static function validateBuild(mixed $build, mixed $definition, string $objectClassName): void { - // @todo validate typing? assert($build instanceof Build); assert($definition instanceof Definition); $missingSlots = []; + /** @var array $validationFailures */ + $validationFailures = []; foreach ($definition->slots as $slot) { // When there is no default, the slot must be defined: if ($slot->defaultValue instanceof NoDefaultValue && false === $build->pintoHas($slot->name)) { $missingSlots[] = $slot->name instanceof \UnitEnum ? $slot->name->name : $slot->name; } + // + // if (!$slot->validation instanceof Validation\NoValidation && true === $build->pintoHas($slot->name)) { + // // @todo this might need to be optional... + // $v = $build->pintoGet($slot->name); + // $expectedType = Type::fromString($slot->validation->type); + // $actualType = \is_object($v) ? $v::class : \gettype($v); + // if (!$expectedType->allows($actualType)) { + // $validationFailures[] = [$slot->name instanceof \UnitEnum ? $slot->name->name : $slot->name, $slot->validation->type, $actualType]; + // } + // } } if ([] !== $missingSlots) { throw BuildValidation::missingSlots($objectClassName, $missingSlots); } + + if ([] !== $validationFailures) { + throw BuildValidation::validation($objectClassName, $validationFailures); + } } public function getDefinition(ObjectListInterface $case, \Reflector $r): mixed @@ -162,8 +179,7 @@ public function getDefinition(ObjectListInterface $case, \Reflector $r): mixed $parametersFrom = false === $this->bindPromotedProperties ? $reflectionMethod : ($reflectionMethod->getDeclaringClass()->getConstructor() ?? throw new \LogicException('A constructor must be defined to use `bindPromotedProperties`')); foreach ($parametersFrom->getParameters() as $rParam) { $paramType = $rParam->getType(); - if ($paramType instanceof \ReflectionNamedType) { - // @todo use the type @ $paramType->getName() + if ($paramType instanceof \ReflectionNamedType || $paramType instanceof \ReflectionUnionType) { $args = ['name' => $rParam->getName()]; // Default should only be set if there is a default. if ($rParam->isDefaultValueAvailable()) { @@ -174,6 +190,8 @@ public function getDefinition(ObjectListInterface $case, \Reflector $r): mixed $args['fillValueFromThemeObjectClassPropertyWhenEmpty'] = $rParam->name; } + $args['validation'] = Validation\PhpType::fromReflection($rParam); + $slots[] = new Slot(...$args); } } diff --git a/src/Exception/Slots/BuildValidation.php b/src/Exception/Slots/BuildValidation.php index aa964ca..4cace82 100644 --- a/src/Exception/Slots/BuildValidation.php +++ b/src/Exception/Slots/BuildValidation.php @@ -14,4 +14,18 @@ public static function missingSlots(string $objectClassName, array $missingSlots { return new static(sprintf('Build for %s missing values for %s: `%s`', $objectClassName, 1 === count($missingSlots) ? 'slot' : 'slots', \implode('`, `', $missingSlots))); } + + /** + * @param class-string $objectClassName + * @param array $validationFailures + */ + public static function validation(string $objectClassName, array $validationFailures): static + { + $messages = []; + foreach ($validationFailures as [$slotName, $expectedType, $actualType]) { + $messages[] = sprintf('`%s` expects `%s`, but got `%s`', $slotName, $expectedType, $actualType); + } + + return new static(sprintf('Build for %s failed validation: %s', $objectClassName, \implode(', ', $messages))); + } } diff --git a/src/PintoMapping.php b/src/PintoMapping.php index e20e2ba..ffae0be 100644 --- a/src/PintoMapping.php +++ b/src/PintoMapping.php @@ -16,7 +16,7 @@ * @param array> $enumClasses * @param array< * class-string, - * array{class-string<\Pinto\List\ObjectListInterface>, string} + * array{class-string, string} * > $enums * @param array $definitions * @param array $buildInvokers diff --git a/src/Slots/Slot.php b/src/Slots/Slot.php index d866caa..589f3df 100644 --- a/src/Slots/Slot.php +++ b/src/Slots/Slot.php @@ -13,6 +13,7 @@ public function __construct( string $useNamedParameters = self::useNamedParameters, public readonly mixed $defaultValue = new NoDefaultValue(), public readonly ?string $fillValueFromThemeObjectClassPropertyWhenEmpty = null, + public readonly Validation\NoValidation|Validation\PhpType $validation = new Validation\NoValidation(), ) { if (self::useNamedParameters !== $useNamedParameters) { throw new \LogicException(self::useNamedParameters); diff --git a/src/Slots/Validation/NoValidation.php b/src/Slots/Validation/NoValidation.php new file mode 100644 index 0000000..469ac30 --- /dev/null +++ b/src/Slots/Validation/NoValidation.php @@ -0,0 +1,14 @@ +cases expansion.. - [1 => $slotsDefinition] = Pinto\ObjectType\ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsExplicitEnumClass::class, PintoListSlots::PintoObjectSlotsExplicitEnumClass, definitionDiscovery: new Pinto\DefinitionDiscovery()); + [1 => $slotsDefinition] = ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsExplicitEnumClass::class, PintoListSlots::PintoObjectSlotsExplicitEnumClass, definitionDiscovery: new DefinitionDiscovery()); static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ @@ -94,13 +97,13 @@ public function testSlotsExplicitEnumClass(): void */ public function testPintoObjectSlotsBindPromotedPublic(): void { - [1 => $slotsDefinition] = Pinto\ObjectType\ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsBindPromotedPublic::class, PintoListSlots::PintoObjectSlotsBindPromotedPublic, definitionDiscovery: new Pinto\DefinitionDiscovery()); + [1 => $slotsDefinition] = ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsBindPromotedPublic::class, PintoListSlots::PintoObjectSlotsBindPromotedPublic, definitionDiscovery: new DefinitionDiscovery()); static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'aPublic', fillValueFromThemeObjectClassPropertyWhenEmpty: 'aPublic'), - new Slots\Slot(name: 'aPublicAndSetInInvoker', fillValueFromThemeObjectClassPropertyWhenEmpty: 'aPublicAndSetInInvoker'), - new Slots\Slot(name: 'aPrivate'), + new Slots\Slot(name: 'aPublic', fillValueFromThemeObjectClassPropertyWhenEmpty: 'aPublic', validation: Slots\Validation\PhpType::fromString('string')), + new Slots\Slot(name: 'aPublicAndSetInInvoker', fillValueFromThemeObjectClassPropertyWhenEmpty: 'aPublicAndSetInInvoker', validation: Slots\Validation\PhpType::fromString('string')), + new Slots\Slot(name: 'aPrivate', validation: Slots\Validation\PhpType::fromString('string')), ]), $slotsDefinition->slots); $object = new PintoObjectSlotsBindPromotedPublic('the public', 'public but also overridden in invoker', 'the private'); @@ -116,7 +119,7 @@ public function testPintoObjectSlotsBindPromotedPublic(): void */ public function PintoObjectSlotsBindPromotedPublicNonConstructor(): void { - [1 => $slotsDefinition] = Pinto\ObjectType\ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsBindPromotedPublicNonConstructor::class, PintoListSlots::PintoObjectSlotsBindPromotedPublicNonConstructor, definitionDiscovery: new Pinto\DefinitionDiscovery()); + [1 => $slotsDefinition] = ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsBindPromotedPublicNonConstructor::class, PintoListSlots::PintoObjectSlotsBindPromotedPublicNonConstructor, definitionDiscovery: new DefinitionDiscovery()); static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ @@ -135,7 +138,7 @@ public function testPintoObjectSlotsBindPromotedPublicWithDefinedSlots(): void { static::expectException(Pinto\Exception\PintoThemeDefinition::class); static::expectExceptionMessage('Slots must use reflection (no explicitly defined `$slots`) when promoted properties bind is on.'); - Pinto\ObjectType\ObjectTypeDiscovery::definitionForThemeObject(Pinto\tests\fixtures\Objects\Faulty\PintoObjectSlotsBindPromotedPublicWithDefinedSlots::class, Lists\PintoFaultyList::PintoObjectSlotsBindPromotedPublicWithDefinedSlots, definitionDiscovery: new Pinto\DefinitionDiscovery()); + ObjectTypeDiscovery::definitionForThemeObject(Pinto\tests\fixtures\Objects\Faulty\PintoObjectSlotsBindPromotedPublicWithDefinedSlots::class, Lists\PintoFaultyList::PintoObjectSlotsBindPromotedPublicWithDefinedSlots, definitionDiscovery: new DefinitionDiscovery()); } public function testSlotsExplicitIgnoresReflection(): void @@ -155,6 +158,25 @@ public function testSlotsBuildMissingValue(): void $object(); } + // /** + // * @covers \Pinto\Attribute\ObjectType\Slots::validateBuild + // * @covers \Pinto\Exception\Slots\BuildValidation::validation + // */ + // public function testSlotsBuildMissingValueValidationFailurePhpType(): void + // { + // [1 => $slotsDefinition] = ObjectTypeDiscovery::definitionForThemeObject(PintoObjectSlotsValidationFailurePhpType::class, PintoListSlots::PintoObjectSlotsValidationFailurePhpType, new DefinitionDiscovery()); + // + // static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); + // static::assertEquals(new SlotList([ + // new Slots\Slot(name: 'number', validation: Slots\Validation\PhpType::fromString('int')), + // ]), $slotsDefinition->slots); + // + // $object = new PintoObjectSlotsValidationFailurePhpType(123); + // static::expectException(Pinto\Exception\Slots\BuildValidation::class); + // static::expectExceptionMessage(sprintf('Build for %s failed validation: `number` expects `int`, but got `string`', PintoObjectSlotsValidationFailurePhpType::class)); + // $object(); + // } + /** * Tests no constructor is required when #[Slots(slots)] is provided. * @@ -164,7 +186,7 @@ public function testSlotsBuildMissingValue(): void */ public function testSlotsNoConstructor(): void { - $definitions = PintoListSlots::definitions(new Pinto\DefinitionDiscovery()); + $definitions = PintoListSlots::definitions(new DefinitionDiscovery()); // Assert anything (no exception thrown): static::assertGreaterThan(0, count($definitions)); } @@ -181,52 +203,52 @@ public function testSlotsBuildMissingValueWithDefault(): void public function testDefinitionsSlotsAttrOnObject(): void { - $themeDefinitions = PintoListSlots::definitions(new Pinto\DefinitionDiscovery()); - static::assertCount(8, $themeDefinitions); + $themeDefinitions = PintoListSlots::definitions(new DefinitionDiscovery()); + static::assertCount(9, $themeDefinitions); $slotsDefinition = $themeDefinitions[PintoListSlots::Slots]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'text'), - new Slots\Slot(name: 'number', defaultValue: 3), + new Slots\Slot(name: 'text', validation: Slots\Validation\PhpType::fromString('string')), + new Slots\Slot(name: 'number', defaultValue: 3, validation: Slots\Validation\PhpType::fromString('int')), ]), $slotsDefinition->slots); $slotsDefinition = $themeDefinitions[PintoListSlots::SlotsAttributeOnMethod]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'foo', defaultValue: null), - new Slots\Slot(name: 'arr', defaultValue: []), + new Slots\Slot(name: 'foo', defaultValue: null, validation: Slots\Validation\PhpType::fromString('?string')), + new Slots\Slot(name: 'arr', defaultValue: [], validation: Slots\Validation\PhpType::fromString('?array')), ]), $slotsDefinition->slots); } public function testDefinitionsSlotsAttrOnList(): void { - $themeDefinitions = Lists\PintoListSlotsOnEnum::definitions(new Pinto\DefinitionDiscovery()); + $themeDefinitions = Lists\PintoListSlotsOnEnum::definitions(new DefinitionDiscovery()); static::assertCount(1, $themeDefinitions); $slotsDefinition = $themeDefinitions[Lists\PintoListSlotsOnEnum::SlotsOnEnum]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'fooFromList'), - new Slots\Slot(name: 'number', defaultValue: 4), + new Slots\Slot(name: 'fooFromList', validation: Slots\Validation\PhpType::fromString('string')), + new Slots\Slot(name: 'number', defaultValue: 4, validation: Slots\Validation\PhpType::fromString('int')), ]), $slotsDefinition->slots); } public function testDefinitionsSlotsAttrOnListCase(): void { - $themeDefinitions = Lists\PintoListSlotsOnEnumCase::definitions(new Pinto\DefinitionDiscovery()); + $themeDefinitions = Lists\PintoListSlotsOnEnumCase::definitions(new DefinitionDiscovery()); static::assertCount(1, $themeDefinitions); $slotsDefinition = $themeDefinitions[Lists\PintoListSlotsOnEnumCase::SlotsOnEnumCase]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'fooFromListCase'), + new Slots\Slot(name: 'fooFromListCase', validation: Slots\Validation\PhpType::fromString('string')), ]), $slotsDefinition->slots); } public function testDefinitionsSlotsAttrByInheritance(): void { - $definitionDiscovery = new Pinto\DefinitionDiscovery(); + $definitionDiscovery = new DefinitionDiscovery(); $definitionDiscovery[PintoObjectSlotsByInheritanceChild::class] = Lists\PintoListSlotsByInheritance::SlotsByInheritanceChild; $definitionDiscovery[PintoObjectSlotsByInheritanceGrandParent::class] = Lists\PintoListSlotsByInheritance::SlotsByInheritanceGrandParent; $themeDefinitions = Lists\PintoListSlotsByInheritance::definitions($definitionDiscovery); @@ -235,7 +257,7 @@ public function testDefinitionsSlotsAttrByInheritance(): void $slotsDefinition = $themeDefinitions[Lists\PintoListSlotsByInheritance::SlotsByInheritanceChild]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'fooFromGrandParent'), + new Slots\Slot(name: 'fooFromGrandParent', validation: Slots\Validation\PhpType::fromString('string')), ]), $slotsDefinition->slots); } @@ -261,7 +283,7 @@ public function testModifySlotsAttributeAddMissingSlots(): void public function testDefinitionsSlotsAttrByInheritanceModifiedSlots(): void { - $definitionDiscovery = new Pinto\DefinitionDiscovery(); + $definitionDiscovery = new DefinitionDiscovery(); $definitionDiscovery[PintoObjectSlotsByInheritanceChild::class] = Lists\PintoListSlotsByInheritance::SlotsByInheritanceChild; $definitionDiscovery[PintoObjectSlotsByInheritanceChildModifySlots::class] = Lists\PintoListSlotsByInheritance::PintoObjectSlotsByInheritanceChildModifySlots; $definitionDiscovery[PintoObjectSlotsByInheritanceGrandParent::class] = Lists\PintoListSlotsByInheritance::SlotsByInheritanceGrandParent; @@ -271,7 +293,7 @@ public function testDefinitionsSlotsAttrByInheritanceModifiedSlots(): void $slotsDefinition = $themeDefinitions[Lists\PintoListSlotsByInheritance::PintoObjectSlotsByInheritanceChildModifySlots]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'fooFromGrandParent'), + new Slots\Slot(name: 'fooFromGrandParent', validation: Slots\Validation\PhpType::fromString('string')), new Slots\Slot(name: 'new_slot'), ]), $slotsDefinition->slots); } @@ -279,7 +301,7 @@ public function testDefinitionsSlotsAttrByInheritanceModifiedSlots(): void public function testDefinitionsSlotsAttrByInheritanceGrandParentUnregistered(): void { // It the parent isn't registered to an enum, no object type is determined. - $definitionDiscovery = new Pinto\DefinitionDiscovery(); + $definitionDiscovery = new DefinitionDiscovery(); // Normally parent is set here. $definitionDiscovery[PintoObjectSlotsByInheritanceChild::class] = Lists\PintoListSlotsByInheritance::SlotsByInheritanceChild; @@ -289,13 +311,13 @@ public function testDefinitionsSlotsAttrByInheritanceGrandParentUnregistered(): public function testDefinitionsSlotsAttrOnListMethodSpecified(): void { - $themeDefinitions = Lists\PintoListSlotsOnEnumMethodSpecified::definitions(new Pinto\DefinitionDiscovery()); + $themeDefinitions = Lists\PintoListSlotsOnEnumMethodSpecified::definitions(new DefinitionDiscovery()); static::assertCount(1, $themeDefinitions); $slotsDefinition = $themeDefinitions[Lists\PintoListSlotsOnEnumMethodSpecified::SlotsOnEnumMethodSpecified]; static::assertInstanceOf(Slots\Definition::class, $slotsDefinition); static::assertEquals(new SlotList([ - new Slots\Slot(name: 'create', defaultValue: 'from method specified on enum #[Slots]'), + new Slots\Slot(name: 'create', defaultValue: 'from method specified on enum #[Slots]', validation: Slots\Validation\PhpType::fromString('?string')), ]), $slotsDefinition->slots); } @@ -332,7 +354,7 @@ public function testSlotAttributeNamedParameters(): void */ public function testRenameSlots(): void { - $definitionDiscovery = new Pinto\DefinitionDiscovery(); + $definitionDiscovery = new DefinitionDiscovery(); $definitionDiscovery[PintoObjectSlotsRenameParent::class] = Lists\PintoListSlotsRename::SlotsRenameParent; $definitionDiscovery[PintoObjectSlotsRenameChild::class] = Lists\PintoListSlotsRename::SlotsRenameChild; $themeDefinitions = Lists\PintoListSlotsRename::definitions($definitionDiscovery); diff --git a/tests/fixtures/Lists/PintoListSlots.php b/tests/fixtures/Lists/PintoListSlots.php index 2e609b8..0d5556b 100644 --- a/tests/fixtures/Lists/PintoListSlots.php +++ b/tests/fixtures/Lists/PintoListSlots.php @@ -22,6 +22,9 @@ enum PintoListSlots implements ObjectListInterface #[Definition(Slots\PintoObjectSlotsMissingSlotValue::class)] case SlotMissingValue; + #[Definition(Slots\PintoObjectSlotsValidationFailurePhpType::class)] + case PintoObjectSlotsValidationFailurePhpType; + #[Definition(Slots\PintoObjectSlotsMissingSlotValueWithDefault::class)] case PintoObjectSlotsMissingSlotValueWithDefault; diff --git a/tests/fixtures/Objects/Slots/PintoObjectSlotsValidationFailurePhpType.php b/tests/fixtures/Objects/Slots/PintoObjectSlotsValidationFailurePhpType.php new file mode 100644 index 0000000..3407f9a --- /dev/null +++ b/tests/fixtures/Objects/Slots/PintoObjectSlotsValidationFailurePhpType.php @@ -0,0 +1,55 @@ +pintoBuild(function (Build $build): Build { + return $build + ->set('number', 'Foo') + ; + }); + } + + private function pintoMapping(): PintoMapping + { + return new PintoMapping( + enumClasses: [], + enums: [ + static::class => [PintoListSlots::class, PintoListSlots::PintoObjectSlotsValidationFailurePhpType->name], + ], + definitions: [ + static::class => new Slots\Definition(new Slots\SlotList([ + new Slots\Slot(name: 'number', defaultValue: null, validation: Slots\Validation\PhpType::fromString('int')), + ])), + ], + buildInvokers: [ + static::class => '__invoke', + ], + types: [static::class => ObjectType\Slots::class], + lsbFactoryCanonicalObjectClasses: [], + ); + } +}