Skip to content

Commit

Permalink
feat!: allow mapping to any type
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
romm committed Jan 1, 2022
1 parent 33167d2 commit b2e810e
Show file tree
Hide file tree
Showing 27 changed files with 374 additions and 89 deletions.
3 changes: 2 additions & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

$finder = PhpCsFixer\Finder::create()->in([
'./src',
'./tests'
'./tests',
'./qa',
]);

if (PHP_VERSION_ID < 8_00_00) {
Expand Down
125 changes: 124 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

The source given to a mapper can never be trusted, this is actually the very
Expand Down Expand Up @@ -225,7 +263,7 @@ map(new \CuyZ\Valinor\Mapper\Source\FileSource(
### Construction strategy

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.

An object can provide either…
Expand Down Expand Up @@ -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/

[Psalm]: https://psalm.dev/
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
},
"autoload-dev": {
"psr-4": {
"CuyZ\\Valinor\\Tests\\": "tests"
"CuyZ\\Valinor\\Tests\\": "tests",
"CuyZ\\Valinor\\QA\\": "qa"
}
},
"scripts": {
Expand Down
8 changes: 7 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
includes:
- qa/PHPStan/valinor-phpstan-configuration.php
- vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
level: max
paths:
- src
- tests
- qa/PHPStan
ignoreErrors:
# \PHPStan\Rules\BooleansInConditions
- '#Only booleans are allowed in .* given#'
Expand All @@ -15,6 +17,10 @@ parameters:
- '#Construct empty\(\) is not allowed\. Use more strict comparison\.#'

- '#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:
- stubs/Psr/SimpleCache/CacheInterface.stub
- qa/PHPStan/Stubs/Psr/SimpleCache/CacheInterface.stub
tmpDir: var/cache/phpstan
62 changes: 62 additions & 0 deletions qa/PHPStan/Extension/TreeMapperPHPStanExtension.php
Original file line number Diff line number Diff line change
@@ -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();
}
}
File renamed without changes.
14 changes: 14 additions & 0 deletions qa/PHPStan/valinor-phpstan-configuration.php
Original file line number Diff line number Diff line change
@@ -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']
]
],
];
68 changes: 68 additions & 0 deletions qa/Psalm/Plugin/TreeMapperPsalmPlugin.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
19 changes: 0 additions & 19 deletions src/Mapper/Exception/InvalidMappingType.php

This file was deleted.

2 changes: 1 addition & 1 deletion src/Mapper/MappingError.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function __construct(Node $node)
$this->node = $node;

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
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/Mapper/TreeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ interface TreeMapper
/**
* @template T of object
*
* @param class-string<T> $signature
* @param string|class-string<T> $signature
* @param mixed $source
* @return T
* @return T|mixed
*
* @throws MappingError
*/
public function map(string $signature, $source): object;
public function map(string $signature, $source);
}
Loading

0 comments on commit b2e810e

Please sign in to comment.