diff --git a/docs/pages/how-to/infer-interfaces.md b/docs/pages/how-to/infer-interfaces.md index b8886c18c..c557428a1 100644 --- a/docs/pages/how-to/infer-interfaces.md +++ b/docs/pages/how-to/infer-interfaces.md @@ -128,3 +128,70 @@ assert($result->baz === 'baz'); ``` [registering a constructor for the interface]: use-custom-object-constructors.md#interface-implementation-constructor + +## Inferring generic classes + +It can sometimes be useful for an inferred interface implementation to be a +generic class. To do so, it is not possible to use the `class-string` return +type, because by nature a class-string cannot be generic. + +To do so, a workaround is implemented: instead of returning a `class-string`, +the callback can return a string value containing the signature of the generic +class. + +```php +namespace My\App; + +interface ApiResponse {} + +/** + * @template T + */ +final readonly class SuccessResponse implements ApiResponse +{ + /** @var T */ + public mixed $data; +} + +final readonly class User +{ + public string $name; + public string $email; +} + +final readonly class Product +{ + public string $name; + public float $price; +} + +$mapper = (new \CuyZ\Valinor\MapperBuilder()) + ->infer( + ApiResponse::class, + /** @return '\My\App\SuccessResponse<\My\App\User>'|'\My\App\SuccessResponse<\My\App\Product>' */ + static fn (string $type): string => match($type) { + 'user' => SuccessResponse::class . '<' . User::class . '>', + 'product' => SuccessResponse::class . '<' . Product::class . '>', + default => throw new \DomainException("Unhandled type `$type`."), + } + ) + ->mapper(); + +$userResponse = $mapper->map(ApiResponse::class, [ + 'type' => 'user', // Will return a `SuccessResponse` + 'name' => 'John Doe', + 'email' => 'john@example.com', +]); + +assert($userResponse instanceof SuccessResponse); +assert($userResponse->data instanceof User); + +$productResponse = $mapper->map(ApiResponse::class, [ + 'type' => 'product', // Will return a `SuccessResponse` + 'name' => 'Laptop', + 'price' => 1337.42, +]); + +assert($productResponse instanceof SuccessResponse); +assert($productResponse->data instanceof Product); +``` diff --git a/src/Mapper/Tree/Builder/InterfaceInferringContainer.php b/src/Mapper/Tree/Builder/InterfaceInferringContainer.php index 1aa718541..2b3122c14 100644 --- a/src/Mapper/Tree/Builder/InterfaceInferringContainer.php +++ b/src/Mapper/Tree/Builder/InterfaceInferringContainer.php @@ -16,6 +16,7 @@ use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\ClassStringType; use CuyZ\Valinor\Type\Types\InterfaceType; +use CuyZ\Valinor\Type\Types\StringValueType; use CuyZ\Valinor\Type\Types\UnionType; use Exception; @@ -120,38 +121,34 @@ private function call(string $name, array $arguments): string */ private function implementationsByReturnSignature(string $name, FunctionDefinition $function): array { - $returnType = $function->returnType; - - if (! $returnType instanceof ClassStringType && ! $returnType instanceof UnionType) { - if (count($function->parameters) > 0) { - return []; - } - - $class = $this->call($name, []); - $classType = $this->typeParser->parse($class); - - return [$classType->toString() => $classType]; - } - - $types = $returnType instanceof UnionType - ? $returnType->types() - : [$returnType]; - $classes = []; - foreach ($types as $type) { - if (! $type instanceof ClassStringType) { - return []; - } + $types = $function->returnType instanceof UnionType + ? $function->returnType->types() + : [$function->returnType]; - $subTypes = $type->subTypes(); + foreach ($types as $type) { + if ($type instanceof ClassStringType) { + foreach ($type->subTypes() as $classType) { + $classes[$classType->toString()] = $classType; + } + } elseif ($type instanceof StringValueType) { + $classType = $this->typeParser->parse($type->value()); - if ($subTypes === []) { - return []; - } + if (! $classType instanceof ClassType) { + return []; + } - foreach ($subTypes as $classType) { $classes[$classType->toString()] = $classType; + } else { + if (count($function->parameters) > 0) { + return []; + } + + $class = $this->call($name, []); + $classType = $this->typeParser->parse($class); + + return [$classType->toString() => $classType]; } } diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php index 5e18ac55d..18f226fb9 100644 --- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php +++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php @@ -123,6 +123,44 @@ public function test_infer_interface_with_class_string_with_union_of_class_names self::assertInstanceOf(SomeClassThatInheritsInterfaceA::class, $result); } + public function test_infer_interface_withclass_name_works_properly(): void + { + try { + $result = $this->mapperBuilder() + ->infer( + SomeInterface::class, + /** @return '\CuyZ\Valinor\Tests\Integration\Mapping\SomeClassWithGenericA' */ + fn (): string => SomeClassWithGenericA::class . '' + ) + ->mapper() + ->map(SomeInterface::class, 'foo'); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertInstanceOf(SomeClassWithGenericA::class, $result); + self::assertSame('foo', $result->value); + } + + public function test_infer_interface_with_union_of_class_names_works_properly(): void + { + try { + $result = $this->mapperBuilder() + ->infer( + SomeInterface::class, + /** @return '\CuyZ\Valinor\Tests\Integration\Mapping\SomeClassWithGenericA'|'\CuyZ\Valinor\Tests\Integration\Mapping\SomeClassWithGenericB' */ + fn (): string => SomeClassWithGenericA::class . '' + ) + ->mapper() + ->map(SomeInterface::class, 'foo'); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertInstanceOf(SomeClassWithGenericA::class, $result); + self::assertSame('foo', $result->value); + } + public function test_infer_interface_with_single_argument_works_properly(): void { try { @@ -334,6 +372,21 @@ public function test_invalid_class_string_object_implementation_registration_thr ->map(SomeInterface::class, []); } + public function test_invalid_string_type_object_implementation_registration_throws_exception(): void + { + $this->expectException(MissingObjectImplementationRegistration::class); + $this->expectExceptionMessage('No implementation of `' . SomeInterface::class . "` found with return type `'invalid-string-type'` of"); + + $this->mapperBuilder() + ->infer( + SomeInterface::class, + /** @return 'invalid-string-type' */ + fn () => SomeClassThatInheritsInterfaceA::class + ) + ->mapper() + ->map(SomeInterface::class, []); + } + public function test_object_implementation_not_registered_throws_exception(): void { $this->expectException(ObjectImplementationNotRegistered::class); @@ -439,3 +492,17 @@ final class SomeClassThatInheritsInterfaceB implements SomeInterface } final class SomeClassThatInheritsInterfaceC implements SomeInterface {} + +/** + * @template T + */ +final class SomeClassWithGenericA implements SomeInterface +{ + /** @var T */ + public mixed $value; +} + +/** + * @template T + */ +final class SomeClassWithGenericB implements SomeInterface {}