diff --git a/README.md b/README.md index fad7889..d023aca 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI Status](https://github.com/zenstruck/callback/workflows/CI/badge.svg)](https://github.com/zenstruck/callback/actions?query=workflow%3ACI) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/zenstruck/callback/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/zenstruck/callback/?branch=1.x) [![Code Coverage](https://codecov.io/gh/zenstruck/callback/branch/1.x/graph/badge.svg?token=R7OHYYGPKM)](https://codecov.io/gh/zenstruck/callback) +[![Latest Version](https://img.shields.io/packagist/v/zenstruck/callback.svg)](https://packagist.org/packages/zenstruck/callback) Callable wrapper to validate and inject arguments. diff --git a/composer.json b/composer.json index 890a4c3..1c6a55e 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ } ], "require": { - "php": ">=7.2.5" + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.14" }, "require-dev": { "symfony/phpunit-bridge": "^5.2" diff --git a/src/Callback.php b/src/Callback.php index f82cd6d..ed0a680 100644 --- a/src/Callback.php +++ b/src/Callback.php @@ -81,7 +81,11 @@ public function invoke(...$arguments) } } - return $this->function->invoke(...$arguments); + try { + return $this->function->invoke(...$arguments); + } catch (\ArgumentCountError $e) { + throw new \ArgumentCountError(\sprintf('Too few arguments passed to "%s". Expected %d, got %s.', $this, $this->function->getNumberOfRequiredParameters(), \count($arguments)), 0, $e); + } } /** diff --git a/src/Callback/Argument.php b/src/Callback/Argument.php index a60dbfa..58bbdb2 100644 --- a/src/Callback/Argument.php +++ b/src/Callback/Argument.php @@ -7,23 +7,52 @@ */ final class Argument { - /** @var \ReflectionNamedType[] */ - private $types = []; + /** + * Allow exact type (always enabled). + */ + public const EXACT = 0; - public function __construct(\ReflectionParameter $parameter) - { - if (!$type = $parameter->getType()) { - return; - } + /** + * If type is class, parent classes are supported. + */ + public const COVARIANCE = 2; - if ($type instanceof \ReflectionNamedType) { - $this->types = [$type]; + /** + * If type is class, child classes are supported. + */ + public const CONTRAVARIANCE = 4; - return; - } + /** + * If type is string, do not support other scalar types. Follows + * same logic as "declare(strict_types=1)". + */ + public const STRICT = 8; - /** @var \ReflectionUnionType $type */ - $this->types = $type->getTypes(); + /** + * If type is float, do not support int (implies {@see STRICT). + */ + public const VERY_STRICT = 16; + + private const TYPE_NORMALIZE_MAP = [ + 'boolean' => 'bool', + 'integer' => 'int', + 'double' => 'float', + 'resource (closed)' => 'resource', + ]; + + private const ALLOWED_TYPE_MAP = [ + 'string' => ['bool', 'int', 'float'], + 'bool' => ['string', 'int', 'float'], + 'float' => ['string', 'int', 'bool'], + 'int' => ['string', 'float', 'bool'], + ]; + + /** @var \ReflectionParameter */ + private $parameter; + + public function __construct(\ReflectionParameter $parameter) + { + $this->parameter = $parameter; } public function type(): ?string @@ -36,32 +65,130 @@ public function type(): ?string */ public function types(): array { - return \array_map(static function(\ReflectionNamedType $type) { return $type->getName(); }, $this->types); + return \array_map(static function(\ReflectionNamedType $type) { return $type->getName(); }, $this->reflectionTypes()); } public function hasType(): bool { - return !empty($this->types); + return !empty($this->types()); } public function isUnionType(): bool { - return \count($this->types) > 1; + return \count($this->types()) > 1; + } + + public function isOptional(): bool + { + return $this->parameter->isOptional(); + } + + /** + * @return mixed + */ + public function defaultValue() + { + return $this->parameter->getDefaultValue(); } - public function supports(string $type): bool + /** + * @param string $type The type to check if this argument supports + * @param int $options {@see EXACT}, {@see COVARIANCE}, {@see CONTRAVARIANCE} + * Bitwise disjunction of above is allowed + */ + public function supports(string $type, int $options = self::EXACT|self::COVARIANCE): bool { if (!$this->hasType()) { // no type-hint so any type is supported return true; } - foreach ($this->types() as $t) { - if ($t === $type || \is_a($t, $type, true)) { + if ('null' === \mb_strtolower($type) && $this->parameter->allowsNull()) { + return true; + } + + $type = self::TYPE_NORMALIZE_MAP[$type] ?? $type; + + foreach ($this->types() as $supportedType) { + if ($supportedType === $type) { + return true; + } + + if ($options & self::COVARIANCE && \is_a($type, $supportedType, true)) { + return true; + } + + if ($options & self::CONTRAVARIANCE && \is_a($supportedType, $type, true)) { + return true; + } + + if ($options & self::VERY_STRICT) { + continue; + } + + if ('float' === $supportedType && 'int' === $type) { + // strict typing allows int to pass a float validation + return true; + } + + if ($options & self::STRICT) { + continue; + } + + if (\in_array($type, self::ALLOWED_TYPE_MAP[$supportedType] ?? [], true)) { + return true; + } + + if (\method_exists($type, '__toString')) { return true; } } return false; } + + /** + * @param mixed $value + * @param bool $strict {@see STRICT} + */ + public function allows($value, bool $strict = false): bool + { + if (!$this->hasType()) { + // no type-hint so any type is supported + return true; + } + + $type = \is_object($value) ? \get_class($value) : \gettype($value); + $type = self::TYPE_NORMALIZE_MAP[$type] ?? $type; + $options = $strict ? self::EXACT|self::COVARIANCE|self::STRICT : self::EXACT|self::COVARIANCE; + $supports = $this->supports($type, $options); + + if (!$supports) { + return false; + } + + if ('string' === $type && !\is_numeric($value) && !\in_array('string', $this->types(), true)) { + // non-numeric strings cannot be used for float/int + return false; + } + + return true; + } + + /** + * @return \ReflectionNamedType[] + */ + private function reflectionTypes(): array + { + if (!$type = $this->parameter->getType()) { + return []; + } + + if ($type instanceof \ReflectionNamedType) { + return [$type]; + } + + /** @var \ReflectionUnionType $type */ + return $type->getTypes(); + } } diff --git a/src/Callback/Parameter.php b/src/Callback/Parameter.php index cb9298a..18c24a5 100644 --- a/src/Callback/Parameter.php +++ b/src/Callback/Parameter.php @@ -15,21 +15,33 @@ abstract class Parameter /** @var bool */ private $optional = false; + /** + * @see UnionParameter::__construct() + */ final public static function union(self ...$parameters): self { return new UnionParameter(...$parameters); } + /** + * @see TypedParameter::__construct() + */ final public static function typed(string $type, $value): self { return new TypedParameter($type, $value); } + /** + * @see UntypedParameter::__construct() + */ final public static function untyped($value): self { return new UntypedParameter($value); } + /** + * @see ValueFactory::__construct() + */ final public static function factory(callable $factory): ValueFactory { return new ValueFactory($factory); @@ -51,13 +63,25 @@ final public function optional(): self */ final public function resolve(Argument $argument) { - $value = $this->valueFor($argument); + try { + $value = $this->valueFor($argument); + } catch (UnresolveableArgument $e) { + if ($argument->isOptional()) { + return $argument->defaultValue(); + } + + throw $e; + } + + if ($value instanceof ValueFactory) { + $value = $value($argument); + } - if (!$value instanceof ValueFactory) { - return $value; + if (!$argument->allows($value)) { + throw new UnresolveableArgument(\sprintf('Unable to resolve argument. Expected "%s", got "%s".', $argument->type(), get_debug_type($value))); } - return $value($argument); + return $value; } /** diff --git a/src/Callback/Parameter/TypedParameter.php b/src/Callback/Parameter/TypedParameter.php index 7392bed..73c08f6 100644 --- a/src/Callback/Parameter/TypedParameter.php +++ b/src/Callback/Parameter/TypedParameter.php @@ -5,6 +5,7 @@ use Zenstruck\Callback\Argument; use Zenstruck\Callback\Exception\UnresolveableArgument; use Zenstruck\Callback\Parameter; +use Zenstruck\Callback\ValueFactory; /** * @author Kevin Bond @@ -13,12 +14,23 @@ final class TypedParameter extends Parameter { /** @var string */ private $type; + + /** @var mixed */ private $value; - public function __construct(string $type, $value) + /** @var int */ + private $options; + + /** + * @param string $type The supported type (native or class) + * @param mixed|ValueFactory $value + * @param int $options {@see Argument::supports()} + */ + public function __construct(string $type, $value, int $options = Argument::EXACT|Argument::COVARIANCE|Argument::CONTRAVARIANCE|Argument::VERY_STRICT) { $this->type = $type; $this->value = $value; + $this->options = $options; } public function type(): string @@ -32,7 +44,7 @@ protected function valueFor(Argument $argument) throw new UnresolveableArgument('Argument has no type.'); } - if ($argument->supports($this->type)) { + if ($argument->supports($this->type, $this->options)) { return $this->value; } diff --git a/src/Callback/Parameter/UntypedParameter.php b/src/Callback/Parameter/UntypedParameter.php index eb6b177..33cf3c7 100644 --- a/src/Callback/Parameter/UntypedParameter.php +++ b/src/Callback/Parameter/UntypedParameter.php @@ -5,6 +5,7 @@ use Zenstruck\Callback\Argument; use Zenstruck\Callback\Exception\UnresolveableArgument; use Zenstruck\Callback\Parameter; +use Zenstruck\Callback\ValueFactory; /** * @author Kevin Bond @@ -13,6 +14,9 @@ final class UntypedParameter extends Parameter { private $value; + /** + * @param mixed|ValueFactory $value + */ public function __construct($value) { $this->value = $value; diff --git a/src/Callback/ValueFactory.php b/src/Callback/ValueFactory.php index 8b9a6d4..a0308ba 100644 --- a/src/Callback/ValueFactory.php +++ b/src/Callback/ValueFactory.php @@ -12,6 +12,9 @@ final class ValueFactory /** @var callable */ private $factory; + /** + * @param callable $factory + */ public function __construct(callable $factory) { $this->factory = $factory; diff --git a/tests/CallbackTest.php b/tests/CallbackTest.php index 23435f4..2c86823 100644 --- a/tests/CallbackTest.php +++ b/tests/CallbackTest.php @@ -166,7 +166,7 @@ public function invoke_all_unresolvable_parameter(): void $callback = Callback::createFor(static function(Object1 $object1, Object2 $object2, Object3 $object3) {}); $this->expectException(UnresolveableArgument::class); - $this->expectExceptionMessage('Unable to resolve argument 3 for callback. Expected type: "mixed|Zenstruck\Callback\Tests\Object1"'); + $this->expectExceptionMessage('Unable to resolve argument 2 for callback. Expected type: "mixed|Zenstruck\Callback\Tests\Object1"'); $callback->invokeAll(Parameter::union( Parameter::untyped(new Object1()), @@ -179,13 +179,35 @@ public function invoke_all_unresolvable_parameter(): void */ public function invoke_with_no_args(): void { - $actual = Callback::createFor(function() { return 'ret'; }) - ->invoke() - ; + $actual = Callback::createFor(function() { return 'ret'; })->invoke(); $this->assertSame('ret', $actual); } + /** + * @test + */ + public function invoke_with_too_few_parameters(): void + { + $this->expectException(\ArgumentCountError::class); + $this->expectExceptionMessage('Too few arguments passed to "Zenstruck\Callback\Tests\CallbackTest'); + $this->expectExceptionMessage('Expected 2, got 1.'); + + Callback::createFor(function(string $string, float $float, ?int $int = null) { return 'ret'; })->invoke('2'); + } + + /** + * @test + */ + public function invoke_with_non_parameters(): void + { + $callback = Callback::createFor( + function(string $string, float $float, ?int $int = null) { return [$string, $float, $int]; } + ); + + $this->assertSame(['value', 3.4, null], $callback->invoke('value', 3.4)); + } + /** * @test */ @@ -368,7 +390,7 @@ public function value_factory_injects_argument_if_type_hinted(): void { $callback = Callback::createFor(function(string $a, int $b, $c) { return [$a, $b, $c]; }); $factory = Parameter::factory(function(Argument $argument) { - if ($argument->supports('string')) { + if ($argument->supports('string', Argument::STRICT)) { return 'string'; } @@ -456,6 +478,109 @@ public function value_factory_cannot_accept_union_argument(): void ->invoke(Parameter::typed('string', Parameter::factory(function(string $type) { return $type; }))) ; } + + /** + * @test + */ + public function argument_supports(): void + { + $callback1 = Callback::createFor(function(?Object1 $object, string $string, int $int, $noType, float $float, bool $bool) {}); + $callback2 = Callback::createFor(function(Object2 $object, string $string, $noType) {}); + + $this->assertTrue($callback1->argument(0)->supports(Object1::class)); + $this->assertTrue($callback1->argument(0)->supports(Object2::class)); + $this->assertTrue($callback1->argument(0)->supports('null')); + $this->assertTrue($callback1->argument(0)->supports('NULL')); + $this->assertFalse($callback1->argument(0)->supports('string')); + $this->assertFalse($callback1->argument(0)->supports(Object3::class)); + $this->assertFalse($callback1->argument(0)->supports(Object2::class, Argument::CONTRAVARIANCE)); + $this->assertFalse($callback1->argument(0)->supports(Object2::class, Argument::EXACT)); + $this->assertTrue($callback1->argument(0)->supports(Object1::class, Argument::EXACT)); + $this->assertTrue($callback1->argument(0)->supports('null', Argument::EXACT)); + + $this->assertTrue($callback1->argument(1)->supports('string')); + $this->assertTrue($callback1->argument(1)->supports('int')); + $this->assertTrue($callback1->argument(1)->supports('float')); + $this->assertTrue($callback1->argument(1)->supports('bool')); + $this->assertTrue($callback1->argument(1)->supports(Object5::class)); + $this->assertFalse($callback1->argument(1)->supports('int', Argument::STRICT)); + $this->assertFalse($callback1->argument(1)->supports(Object5::class, Argument::STRICT)); + + $this->assertTrue($callback1->argument(2)->supports('int')); + $this->assertTrue($callback1->argument(2)->supports('integer')); + $this->assertTrue($callback1->argument(2)->supports('float')); + $this->assertFalse($callback1->argument(2)->supports('float', Argument::STRICT)); + $this->assertTrue($callback1->argument(2)->supports('bool')); + $this->assertFalse($callback1->argument(2)->supports('bool', Argument::STRICT)); + $this->assertTrue($callback1->argument(2)->supports('string')); + $this->assertFalse($callback1->argument(2)->supports('string', Argument::STRICT)); + + $this->assertTrue($callback1->argument(3)->supports(Object1::class)); + $this->assertTrue($callback1->argument(3)->supports(Object2::class)); + $this->assertTrue($callback1->argument(3)->supports('string')); + $this->assertTrue($callback1->argument(3)->supports('int')); + + $this->assertTrue($callback1->argument(4)->supports('float')); + $this->assertTrue($callback1->argument(4)->supports('double')); + $this->assertTrue($callback1->argument(4)->supports('int')); + $this->assertTrue($callback1->argument(4)->supports('int', Argument::STRICT)); + $this->assertFalse($callback1->argument(4)->supports('int', Argument::VERY_STRICT)); + $this->assertTrue($callback1->argument(4)->supports('string')); + $this->assertFalse($callback1->argument(4)->supports('string', Argument::STRICT)); + $this->assertTrue($callback1->argument(4)->supports('bool')); + $this->assertFalse($callback1->argument(4)->supports('bool', Argument::STRICT)); + + $this->assertTrue($callback1->argument(5)->supports('bool')); + $this->assertTrue($callback1->argument(5)->supports('boolean')); + $this->assertTrue($callback1->argument(5)->supports('float')); + $this->assertFalse($callback1->argument(5)->supports('float', Argument::STRICT)); + $this->assertTrue($callback1->argument(5)->supports('int')); + $this->assertFalse($callback1->argument(5)->supports('int', Argument::STRICT)); + $this->assertTrue($callback1->argument(5)->supports('string')); + $this->assertFalse($callback1->argument(5)->supports('string', Argument::STRICT)); + + $this->assertTrue($callback2->argument(0)->supports(Object1::class, Argument::COVARIANCE|Argument::CONTRAVARIANCE)); + $this->assertFalse($callback2->argument(0)->supports(Object3::class, Argument::COVARIANCE|Argument::CONTRAVARIANCE)); + } + + /** + * @test + */ + public function argument_allows(): void + { + $callback1 = Callback::createFor(function(Object1 $object, string $string, int $int, $noType, float $float) {}); + $callback2 = Callback::createFor(function(Object2 $object, string $string, $noType) {}); + + $this->assertTrue($callback1->argument(0)->allows(new Object1())); + $this->assertTrue($callback1->argument(0)->allows(new Object2())); + $this->assertFalse($callback1->argument(0)->allows('string')); + $this->assertFalse($callback1->argument(0)->allows(new Object3())); + + $this->assertTrue($callback1->argument(1)->allows('string')); + $this->assertTrue($callback1->argument(1)->allows(16)); + $this->assertTrue($callback1->argument(1)->allows(16.7)); + $this->assertTrue($callback1->argument(1)->allows(true)); + $this->assertFalse($callback1->argument(1)->allows(16, true)); + + $this->assertTrue($callback1->argument(2)->allows(16)); + $this->assertTrue($callback1->argument(2)->allows('17')); + $this->assertTrue($callback1->argument(2)->allows(18.0)); + $this->assertFalse($callback1->argument(2)->allows('string'), 'non-numeric strings are not allowed'); + + $this->assertTrue($callback1->argument(3)->allows(new Object1())); + $this->assertTrue($callback1->argument(3)->allows(new Object2())); + $this->assertTrue($callback1->argument(3)->allows('string')); + $this->assertTrue($callback1->argument(3)->allows(16)); + + $this->assertTrue($callback1->argument(4)->allows(16)); + $this->assertTrue($callback1->argument(4)->allows('17')); + $this->assertTrue($callback1->argument(4)->allows('17.3')); + $this->assertTrue($callback1->argument(4)->allows(18.0)); + $this->assertFalse($callback1->argument(4)->allows('string'), 'non-numeric strings are not allowed'); + + $this->assertFalse($callback2->argument(0)->allows(new Object1())); + $this->assertFalse($callback2->argument(0)->allows(new Object3())); + } } class Object1 @@ -481,6 +606,14 @@ public static function staticMethod() } } +class Object5 +{ + public function __toString(): string + { + return 'value'; + } +} + function test_function() { } diff --git a/tests/StrictCallbackTest.php b/tests/StrictCallbackTest.php new file mode 100644 index 0000000..378c333 --- /dev/null +++ b/tests/StrictCallbackTest.php @@ -0,0 +1,62 @@ + + */ +final class StrictCallbackTest extends TestCase +{ + /** + * @test + */ + public function invoke_with_non_parameters(): void + { + $callback = Callback::createFor( + function(string $string, float $float, ?int $int = null) { return [$string, $float, $int]; } + ); + + $this->assertSame(['6.2', 3.0, null], $callback->invoke(6.2, '3')); + } + + /** + * @test + */ + public function invoke_with_parameter(): void + { + $ret = Callback::createFor( + function(string $string, float $float, ?int $int = null) { return [$string, $float, $int]; } + )->invoke( + Parameter::typed('string', 6.2), + Parameter::union( + Parameter::typed('float', 3), + Parameter::typed('string', '6.2') + ) + ); + + $this->assertSame(['6.2', 3.0, null], $ret); + } + + /** + * @test + */ + public function invoke_all(): void + { + $ret = Callback::createFor( + function(string $string, float $float, int $int = 16) { return [$string, $float, $int]; } + )->invokeAll( + Parameter::union( + Parameter::typed('float', 3), + Parameter::typed('string', '6.2') + ) + ); + + $this->assertSame(['6.2', 3.0, 16], $ret); + } +}