Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/pages/how-to/infer-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>`
'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<Product>`
'name' => 'Laptop',
'price' => 1337.42,
]);

assert($productResponse instanceof SuccessResponse);
assert($productResponse->data instanceof Product);
```
49 changes: 23 additions & 26 deletions src/Mapper/Tree/Builder/InterfaceInferringContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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];
}
}

Expand Down
67 changes: 67 additions & 0 deletions tests/Integration/Mapping/InterfaceInferringMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>' */
fn (): string => SomeClassWithGenericA::class . '<string>'
)
->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<string>'|'\CuyZ\Valinor\Tests\Integration\Mapping\SomeClassWithGenericB<int>' */
fn (): string => SomeClassWithGenericA::class . '<string>'
)
->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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {}