Skip to content

Commit

Permalink
feat: introduce mapper to map arguments of a callable
Browse files Browse the repository at this point in the history
This new mapper can be used to ensure a source has the right shape
before calling a function/method.

The mapper builder can be configured the same way it would be with a
tree mapper, for instance to customize the type strictness.

```php
$someFunction = function(string $foo, int $bar): string {
    return "$foo / $bar";
}

try {
    $arguments = (new \CuyZ\Valinor\MapperBuilder())
        ->argumentsMapper()
        ->mapArguments($someFunction, [
            'foo' => 'some value',
            'bar' => 42,
        ]);

    // some value / 42
    echo $someFunction(...$arguments);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    // Do something…
}
```

Any callable can be given to the arguments mapper:

```php
final class SomeController
{
    public static function someAction(string $foo, int $bar): string
    {
        return "$foo / $bar";
    }
}

try {
    $arguments = (new \CuyZ\Valinor\MapperBuilder())
        ->argumentsMapper()
        ->mapArguments(SomeController::someAction(...), [
            'foo' => 'some value',
            'bar' => 42,
        ]);

    // some value / 42
    echo SomeController::someAction(...$arguments);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    // Do something…
}
```
  • Loading branch information
romm committed Nov 21, 2022
1 parent b425af7 commit 9c7e884
Show file tree
Hide file tree
Showing 19 changed files with 591 additions and 43 deletions.
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Expand Up @@ -83,6 +83,7 @@ nav:
- Type strictness: mapping/type-strictness.md
- Construction strategy: mapping/construction-strategy.md
- Inferring interfaces: mapping/inferring-interfaces.md
- Arguments mapping: mapping/arguments-mapping.md
- Handled types: mapping/handled-types.md
- Other:
- Performance & caching: other/performance-and-cache.md
Expand Down
53 changes: 53 additions & 0 deletions docs/pages/mapping/arguments-mapping.md
@@ -0,0 +1,53 @@
# Mapping arguments of a callable

This library can map the arguments of a callable; it can be used to ensure a
source has the right shape before calling a function/method.

The mapper builder can be configured the same way it would be with a tree
mapper, for instance to customize the [type strictness](type-strictness.md).

```php
$someFunction = function(string $foo, int $bar): string {
return "$foo / $bar";
}

try {
$arguments = (new \CuyZ\Valinor\MapperBuilder())
->argumentsMapper()
->mapArguments($someFunction, [
'foo' => 'some value',
'bar' => 42,
]);

// some value / 42
echo $someFunction(...$arguments);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
// Do something…
}
```

Any callable can be given to the arguments mapper:

```php
final class SomeController
{
public static function someAction(string $foo, int $bar): string
{
return "$foo / $bar";
}
}

try {
$arguments = (new \CuyZ\Valinor\MapperBuilder())
->argumentsMapper()
->mapArguments(SomeController::someAction(...), [
'foo' => 'some value',
'bar' => 42,
]);

// some value / 42
echo SomeController::someAction(...$arguments);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
// Do something…
}
```
18 changes: 18 additions & 0 deletions docs/pages/other/static-analysis.md
Expand Up @@ -63,6 +63,24 @@ echo $array['foo'] * $array['bar'];
echo $array['fiz'];
```

**Mapping arguments of a callable**

```php
$someFunction = function(string $foo, int $bar): string {
return "$foo / $bar";
};

$arguments = (new \CuyZ\Valinor\MapperBuilder())
->argumentsMapper()
->mapArguments($someFunction, [
'foo' => 'some value',
'bar' => 42,
]);

// ✅ Arguments have a correct shape, no error reported
echo $someFunction(...$arguments);
```

---

To activate this feature, the configuration must be updated for the installed
Expand Down
65 changes: 65 additions & 0 deletions qa/PHPStan/Extension/ArgumentsMapperPHPStanExtension.php
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\QA\PHPStan\Extension;

use CuyZ\Valinor\Mapper\ArgumentsMapper;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;

use function count;

final class ArgumentsMapperPHPStanExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return ArgumentsMapper::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'mapArguments';
}

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
{
$arguments = $methodCall->getArgs();

if (count($arguments) === 0) {
return new MixedType();
}

$type = $scope->getType($arguments[0]->value);

if ($type instanceof ClosureType) {
$parameters = $type->getParameters();
} elseif ($type instanceof ConstantArrayType) {
$acceptors = $type->getCallableParametersAcceptors($scope);

if (count($acceptors) !== 1) {
return new MixedType();
}

$parameters = $acceptors[0]->getParameters();
} else {
return new MixedType();
}

$builder = ConstantArrayTypeBuilder::createEmpty();

foreach ($parameters as $parameter) {
$builder->setOffsetValueType(new ConstantStringType($parameter->getName()), $parameter->getType(), $parameter->isOptional());
}

return $builder->getArray();
}
}
6 changes: 5 additions & 1 deletion qa/PHPStan/valinor-phpstan-configuration.php
@@ -1,5 +1,6 @@
<?php

use CuyZ\Valinor\QA\PHPStan\Extension\ArgumentsMapperPHPStanExtension;
use CuyZ\Valinor\QA\PHPStan\Extension\TreeMapperPHPStanExtension;

require_once 'Extension/TreeMapperPHPStanExtension.php';
Expand All @@ -9,6 +10,9 @@
[
'class' => TreeMapperPHPStanExtension::class,
'tags' => ['phpstan.broker.dynamicMethodReturnTypeExtension']
]
], [
'class' => ArgumentsMapperPHPStanExtension::class,
'tags' => ['phpstan.broker.dynamicMethodReturnTypeExtension']
],
],
];
73 changes: 73 additions & 0 deletions qa/Psalm/Plugin/ArgumentsMapperPsalmPlugin.php
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\QA\Psalm\Plugin;

use CuyZ\Valinor\Mapper\ArgumentsMapper;
use Psalm\Internal\Type\Comparator\CallableTypeComparator;
use Psalm\Plugin\EventHandler\Event\MethodReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\MethodReturnTypeProviderInterface;
use Psalm\Type\Atomic\TClosure;
use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Union;

use function count;
use function reset;

final class ArgumentsMapperPsalmPlugin implements MethodReturnTypeProviderInterface
{
public static function getClassLikeNames(): array
{
return [ArgumentsMapper::class];
}

public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Union
{
if ($event->getMethodNameLowercase() !== 'maparguments') {
return null;
}

$arguments = $event->getCallArgs();

if (count($arguments) === 0) {
return null;
}

$types = $event->getSource()->getNodeTypeProvider()->getType($arguments[0]->value);

if ($types === null) {
return null;
}

$types = $types->getAtomicTypes();

if (count($types) !== 1) {
return null;
}

$type = reset($types);

if ($type instanceof TKeyedArray) {
// Internal class usage, see https://github.com/vimeo/psalm/issues/8726
$type = CallableTypeComparator::getCallableFromAtomic($event->getSource()->getCodebase(), $type);
}

if (! $type instanceof TClosure) {
return null;
}

if (empty($type->params ?? [])) {
return null;
}

$params = [];

foreach ($type->params as $param) {
$params[$param->name] = $param->type ?? new Union([new TMixed()]);
}

return new Union([new TKeyedArray($params)]);
}
}
3 changes: 3 additions & 0 deletions qa/Psalm/ValinorPsalmPlugin.php
Expand Up @@ -2,6 +2,7 @@

namespace CuyZ\Valinor\QA\Psalm;

use CuyZ\Valinor\QA\Psalm\Plugin\ArgumentsMapperPsalmPlugin;
use CuyZ\Valinor\QA\Psalm\Plugin\TreeMapperPsalmPlugin;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface;
Expand All @@ -12,7 +13,9 @@ class ValinorPsalmPlugin implements PluginEntryPointInterface
public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null): void
{
require_once __DIR__ . '/Plugin/TreeMapperPsalmPlugin.php';
require_once __DIR__ . '/Plugin/ArgumentsMapperPsalmPlugin.php';

$api->registerHooksFromClass(TreeMapperPsalmPlugin::class);
$api->registerHooksFromClass(ArgumentsMapperPsalmPlugin::class);
}
}
16 changes: 14 additions & 2 deletions src/Library/Container.php
Expand Up @@ -19,6 +19,7 @@
use CuyZ\Valinor\Definition\Repository\Reflection\NativeAttributesRepository;
use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionClassDefinitionRepository;
use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionFunctionDefinitionRepository;
use CuyZ\Valinor\Mapper\ArgumentsMapper;
use CuyZ\Valinor\Mapper\Object\Factory\AttributeObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Object\Factory\CacheObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Object\Factory\CollisionObjectBuilderFactory;
Expand Down Expand Up @@ -49,7 +50,8 @@
use CuyZ\Valinor\Mapper\Tree\Visitor\AttributeShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\ShellVisitor;
use CuyZ\Valinor\Mapper\TreeMapper;
use CuyZ\Valinor\Mapper\TreeMapperContainer;
use CuyZ\Valinor\Mapper\TypeArgumentsMapper;
use CuyZ\Valinor\Mapper\TypeTreeMapper;
use CuyZ\Valinor\Type\Parser\CachedParser;
use CuyZ\Valinor\Type\Parser\Factory\LexingTypeParserFactory;
use CuyZ\Valinor\Type\Parser\Factory\Specifications\HandleClassGenericSpecification;
Expand Down Expand Up @@ -81,11 +83,16 @@ final class Container
public function __construct(Settings $settings)
{
$this->factories = [
TreeMapper::class => fn () => new TreeMapperContainer(
TreeMapper::class => fn () => new TypeTreeMapper(
$this->get(TypeParser::class),
new RootNodeBuilder($this->get(NodeBuilder::class))
),

ArgumentsMapper::class => fn () => new TypeArgumentsMapper(
$this->get(TreeMapper::class),
$this->get(FunctionDefinitionRepository::class)
),

ShellVisitor::class => fn () => new AttributeShellVisitor(),

NodeBuilder::class => function () use ($settings) {
Expand Down Expand Up @@ -238,6 +245,11 @@ public function treeMapper(): TreeMapper
return $this->get(TreeMapper::class);
}

public function argumentsMapper(): ArgumentsMapper
{
return $this->get(ArgumentsMapper::class);
}

public function cacheWarmupService(): RecursiveCacheWarmupService
{
return $this->get(RecursiveCacheWarmupService::class);
Expand Down
17 changes: 17 additions & 0 deletions src/Mapper/ArgumentsMapper.php
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper;

/** @api */
interface ArgumentsMapper
{
/**
* @param mixed $source
* @return array<string, mixed>
*
* @throws MappingError
*/
public function mapArguments(callable $callable, $source): array;
}
42 changes: 42 additions & 0 deletions src/Mapper/ArgumentsMapperError.php
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper;

use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Mapper\Tree\Message\Messages;
use CuyZ\Valinor\Mapper\Tree\Node;
use CuyZ\Valinor\Utility\ValueDumper;
use RuntimeException;

/** @internal */
final class ArgumentsMapperError extends RuntimeException implements MappingError
{
private Node $node;

public function __construct(FunctionDefinition $function, Node $node)
{
$this->node = $node;

$errors = Messages::flattenFromNode($node)->errors();
$errorsCount = count($errors);

if ($errorsCount === 1) {
$body = $errors
->toArray()[0]
->withBody("Could not map arguments of `{$function->signature()}`. An error occurred at path {node_path}: {original_message}")
->toString();
} else {
$source = ValueDumper::dump($node->sourceValue());
$body = "Could not map arguments of `{$function->signature()}` with value $source. A total of $errorsCount errors were encountered.";
}

parent::__construct($body, 1617193185);
}

public function node(): Node
{
return $this->node;
}
}

0 comments on commit 9c7e884

Please sign in to comment.