feat!: allow mapping to any type

Previously, the method `TreeMapper::map` would allow mapping only to an
object. It is now possible to map to any type handled by the library.

It is for instance possible to map to an array of objects:

```php
$objects = (new \CuyZ\Valinor\MapperBuilder())->mapper()->map(
    'array<' . SomeClass::class . '>',
    [/* … */]
);
```

For simple use-cases, an array shape can be used:

```php
$array = (new \CuyZ\Valinor\MapperBuilder())->mapper()->map(
    'array{foo: string, bar: int}',
    [/* … */]
);

echo strtolower($array['foo']);
echo $array['bar'] * 2;
```

This new feature changes the possible behaviour of the mapper, meaning
static analysis tools need help to understand the types correctly. An
extension for PHPStan and a plugin for Psalm are now provided and can be
included in a project to automatically increase the type coverage.
This commit is contained in:
Romain Canon 2021-12-29 00:09:34 +01:00
parent 33167d28d0
commit b2e810e3ce
27 changed files with 374 additions and 89 deletions

View File

@ -2,7 +2,8 @@
$finder = PhpCsFixer\Finder::create()->in([ $finder = PhpCsFixer\Finder::create()->in([
'./src', './src',
'./tests' './tests',
'./qa',
]); ]);
if (PHP_VERSION_ID < 8_00_00) { if (PHP_VERSION_ID < 8_00_00) {

125
README.md
View File

@ -125,6 +125,44 @@ public function getThread(int $id): Thread
} }
``` ```
### Mapping advanced types
Although it is recommended to map an input to a value object, in some cases
mapping to another type can be easier/more flexible.
It is for instance possible to map to an array of objects:
```php
try {
$objects = (new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(
'array<' . SomeClass::class . '>',
[/* … */]
);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
// Do something…
}
```
For simple use-cases, an array shape can be used:
```php
try {
$array = (new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(
'array{foo: string, bar: int}',
[/* … */]
);
echo $array['foo'];
echo $array['bar'] * 2;
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
// Do something…
}
```
### Validation ### Validation
The source given to a mapper can never be trusted, this is actually the very The source given to a mapper can never be trusted, this is actually the very
@ -225,7 +263,7 @@ map(new \CuyZ\Valinor\Mapper\Source\FileSource(
### Construction strategy ### Construction strategy
During the mapping, instances of the objects are created and hydrated with the During the mapping, instances of the objects are created and hydrated with the
correct values. construction strategies will determine what values are needed correct values. Construction strategies will determine what values are needed
and how an object is built. and how an object is built.
An object can provide either… An object can provide either…
@ -370,6 +408,91 @@ final class SomeClass
} }
``` ```
## Static analysis
To help static analysis of a codebase using this library, an extension for
[PHPStan] and a plugin for [Psalm] are provided. They enable these tools to
better understand the behaviour of the mapper.
Considering at least one of those tools are installed on a project, below are
examples of the kind of errors that would be reported.
**Mapping to an array of classes**
```php
final class SomeClass
{
public function __construct(
public readonly string $foo,
public readonly int $bar,
) {}
}
$objects = (new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(
'array<' . SomeClass::class . '>',
[/* … */]
);
foreach ($objects as $object) {
// ✅
echo $object->foo;
// ✅
echo $object->bar * 2;
// ❌ Cannot perform operation between `string` and `int`
echo $object->foo * $object->bar;
// ❌ Property `SomeClass::$fiz` is not defined
echo $object->fiz;
}
```
**Mapping to a shaped array**
```php
$array = (new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(
'array{foo: string, bar: int}',
[/* … */]
);
// ✅
echo $array['foo'];
// ❌ Expected `string` but got `int`
echo strtolower($array['bar']);
// ❌ Cannot perform operation between `string` and `int`
echo $array['foo'] * $array['bar'];
// ❌ Offset `fiz` does not exist on array
echo $array['fiz'];
```
---
To activate this feature, the configuration must be updated for the installed
tool(s):
**PHPStan**
```yaml
includes:
- vendor/cuyz/valinor/qa/PHPStan/valinor-phpstan-configuration.php
```
**Psalm**
```xml
<plugins>
<plugin filename="vendor/cuyz/valinor/qa/Psalm/Plugin/TreeMapperPsalmPlugin.php"/>
</plugins>
```
[PHPStan]: https://phpstan.org/ [PHPStan]: https://phpstan.org/
[Psalm]: https://psalm.dev/ [Psalm]: https://psalm.dev/

View File

@ -36,7 +36,8 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"CuyZ\\Valinor\\Tests\\": "tests" "CuyZ\\Valinor\\Tests\\": "tests",
"CuyZ\\Valinor\\QA\\": "qa"
} }
}, },
"scripts": { "scripts": {

View File

@ -1,4 +1,5 @@
includes: includes:
- qa/PHPStan/valinor-phpstan-configuration.php
- vendor/phpstan/phpstan-strict-rules/rules.neon - vendor/phpstan/phpstan-strict-rules/rules.neon
parameters: parameters:
@ -6,6 +7,7 @@ parameters:
paths: paths:
- src - src
- tests - tests
- qa/PHPStan
ignoreErrors: ignoreErrors:
# \PHPStan\Rules\BooleansInConditions # \PHPStan\Rules\BooleansInConditions
- '#Only booleans are allowed in .* given#' - '#Only booleans are allowed in .* given#'
@ -15,6 +17,10 @@ parameters:
- '#Construct empty\(\) is not allowed\. Use more strict comparison\.#' - '#Construct empty\(\) is not allowed\. Use more strict comparison\.#'
- '#Method [\w\\:]+_data_provider\(\) return type has no value type specified in iterable type#' - '#Method [\w\\:]+_data_provider\(\) return type has no value type specified in iterable type#'
- message: '#Template type T of method CuyZ\\Valinor\\Mapper\\TreeMapper::map\(\) is not referenced in a parameter#'
path: src/Mapper/TreeMapper.php
stubFiles: stubFiles:
- stubs/Psr/SimpleCache/CacheInterface.stub - qa/PHPStan/Stubs/Psr/SimpleCache/CacheInterface.stub
tmpDir: var/cache/phpstan tmpDir: var/cache/phpstan

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\QA\PHPStan\Extension;
use CuyZ\Valinor\Mapper\TreeMapper;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\TypeStringResolver;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
final class TreeMapperPHPStanExtension implements DynamicMethodReturnTypeExtension
{
private TypeStringResolver $resolver;
public function __construct(TypeStringResolver $resolver)
{
$this->resolver = $resolver;
}
public function getClass(): string
{
return TreeMapper::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'map';
}
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
{
$argument = $methodCall->getArgs()[0]->value;
$type = $scope->getType($argument);
if ($type instanceof UnionType) {
return $type->traverse(fn (Type $type) => $this->type($type));
}
return $this->type($type);
}
private function type(Type $type): Type
{
if ($type instanceof GenericClassStringType) {
return $type->getGenericType();
}
if ($type instanceof ConstantStringType) {
return $this->resolver->resolve($type->getValue());
}
return new MixedType();
}
}

View File

@ -0,0 +1,14 @@
<?php
use CuyZ\Valinor\QA\PHPStan\Extension\TreeMapperPHPStanExtension;
require_once 'Extension/TreeMapperPHPStanExtension.php';
return [
'services' => [
[
'class' => TreeMapperPHPStanExtension::class,
'tags' => ['phpstan.broker.dynamicMethodReturnTypeExtension']
]
],
];

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\QA\Psalm\Plugin;
use CuyZ\Valinor\Mapper\TreeMapper;
use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TDependentGetClass;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Union;
final class TreeMapperPsalmPlugin implements MethodReturnTypeProviderInterface
{
public static function getClassLikeNames(): array
{
return [TreeMapper::class];
}
public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Union
{
if ($event->getMethodNameLowercase() !== 'map') {
return null;
}
$type = $event->getSource()->getNodeTypeProvider()->getType($event->getCallArgs()[0]->value);
if (! $type) {
return null;
}
$types = [];
foreach ($type->getChildNodes() as $node) {
$inferred = self::type($node);
if ($inferred === null) {
return null;
}
$types[] = $inferred;
}
if (count($types) === 0) {
return null;
}
return Type::combineUnionTypeArray($types, $event->getSource()->getCodebase());
}
private static function type(Atomic $node): ?Union
{
switch (true) {
case $node instanceof TLiteralString:
return Type::parseString($node->value);
case $node instanceof TDependentGetClass:
return $node->as_type;
case $node instanceof TClassString && $node->as_type:
return new Union([$node->as_type]);
default:
return null;
}
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Exception;
use CuyZ\Valinor\Type\Type;
use RuntimeException;
final class InvalidMappingType extends RuntimeException
{
public function __construct(Type $type)
{
parent::__construct(
"Can not map type `$type`, it must be an object type.",
1630959731
);
}
}

View File

@ -16,7 +16,7 @@ final class MappingError extends RuntimeException
$this->node = $node; $this->node = $node;
parent::__construct( parent::__construct(
"Could not map an object of type `{$node->type()}` with the given source.", "Could not map type `{$node->type()}` with the given source.",
1617193185 1617193185
); );
} }

View File

@ -9,11 +9,11 @@ interface TreeMapper
/** /**
* @template T of object * @template T of object
* *
* @param class-string<T> $signature * @param string|class-string<T> $signature
* @param mixed $source * @param mixed $source
* @return T * @return T|mixed
* *
* @throws MappingError * @throws MappingError
*/ */
public function map(string $signature, $source): object; public function map(string $signature, $source);
} }

View File

@ -4,12 +4,10 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Mapper; namespace CuyZ\Valinor\Mapper;
use CuyZ\Valinor\Mapper\Exception\InvalidMappingType;
use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature;
use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\Mapper\Tree\Node;
use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Mapper\Tree\Shell;
use CuyZ\Valinor\Type\ObjectType;
use CuyZ\Valinor\Type\Parser\Exception\InvalidType; use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Parser\TypeParser; use CuyZ\Valinor\Type\Parser\TypeParser;
@ -25,7 +23,7 @@ final class TreeMapperContainer implements TreeMapper
$this->nodeBuilder = $nodeBuilder; $this->nodeBuilder = $nodeBuilder;
} }
public function map(string $signature, $source): object public function map(string $signature, $source)
{ {
$node = $this->node($signature, $source); $node = $this->node($signature, $source);
@ -33,7 +31,7 @@ final class TreeMapperContainer implements TreeMapper
throw new MappingError($node); throw new MappingError($node);
} }
return $node->value(); // @phpstan-ignore-line return $node->value();
} }
/** /**
@ -47,10 +45,6 @@ final class TreeMapperContainer implements TreeMapper
throw new InvalidMappingTypeSignature($signature, $exception); throw new InvalidMappingTypeSignature($signature, $exception);
} }
if (! $type instanceof ObjectType) {
throw new InvalidMappingType($type);
}
$shell = Shell::root($type, $source); $shell = Shell::root($type, $source);
return $this->nodeBuilder->build($shell); return $this->nodeBuilder->build($shell);

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Fake\Mapper;
use CuyZ\Valinor\Mapper\TreeMapper;
use stdClass;
final class FakeTreeMapper implements TreeMapper
{
/** @var array<string, object> */
private array $objects = [];
/**
* @param mixed $source
* @phpstan-return object
*/
public function map(string $signature, $source): object
{
return $this->objects[$signature] ?? new stdClass();
}
/**
* @param class-string $signature
*/
public function willReturn(string $signature, object $object): void
{
$this->objects[$signature] = $object;
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue; use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Object\DateTimeObjectBuilder; use CuyZ\Valinor\Mapper\Object\DateTimeObjectBuilder;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Fixture\Enum\BackedIntegerEnum; use CuyZ\Valinor\Tests\Fixture\Enum\BackedIntegerEnum;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue; use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue;
@ -10,7 +10,6 @@ use CuyZ\Valinor\Mapper\Tree\Exception\InvalidNodeValue;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;
use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject;
use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject as SimpleObjectAlias; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject as SimpleObjectAlias;
use Throwable; use Throwable;
use function array_values; use function array_values;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue; use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue; use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\IntegrationTest;

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Other;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
final class ArrayOfScalarMappingTest extends IntegrationTest
{
public function test_values_are_mapped_properly(): void
{
$source = [
'foo',
42,
1337.404,
];
try {
$result = $this->mapperBuilder->mapper()->map('string[]', $source);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame(['foo', '42', '1337.404'], $result);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Other;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
final class ShapedArrayMappingTest extends IntegrationTest
{
public function test_values_are_mapped_properly(): void
{
$source = [
'foo' => 'foo',
'bar' => '42',
'fiz' => '1337.404',
];
try {
$result = $this->mapperBuilder->mapper()->map('array{foo: string, bar: int, fiz: float}', $source);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result['foo']);
self::assertSame(42, $result['bar']);
self::assertSame(1337.404, $result['fiz']);
}
public function test_shared_values_are_mapped_properly(): void
{
$source = [
'foo' => 'foo',
'bar' => '42',
'fiz' => '1337.404',
];
foreach (['array{foo: string, bar: int}', 'array{bar: int, fiz:float}'] as $signature) {
try {
$result = $this->mapperBuilder->mapper()->map($signature, $source);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame(42, $result['bar']);
}
}
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper; namespace CuyZ\Valinor\Tests\Unit\Mapper;
use CuyZ\Valinor\Mapper\Exception\InvalidMappingType;
use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature;
use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder;
use CuyZ\Valinor\Mapper\TreeMapperContainer; use CuyZ\Valinor\Mapper\TreeMapperContainer;
@ -32,15 +31,6 @@ final class TreeMapperContainerTest extends TestCase
$this->expectExceptionCode(1630959692); $this->expectExceptionCode(1630959692);
$this->expectExceptionMessageMatches('/^Could not parse the type `foo` that should be mapped: .*/'); $this->expectExceptionMessageMatches('/^Could not parse the type `foo` that should be mapped: .*/');
$this->mapper->map('foo', []); // @phpstan-ignore-line $this->mapper->map('foo', []);
}
public function test_invalid_mapping_type_throws_exception(): void
{
$this->expectException(InvalidMappingType::class);
$this->expectExceptionCode(1630959731);
$this->expectExceptionMessage("Can not map type `string`, it must be an object type.");
$this->mapper->map('string', []); // @phpstan-ignore-line
} }
} }