Skip to content

Commit

Permalink
Merge pull request #3 from kbond/resolve-improvements
Browse files Browse the repository at this point in the history
Resolve improvements
  • Loading branch information
kbond committed Jul 20, 2021
2 parents d5d19c1 + 3eae5eb commit d72eaba
Show file tree
Hide file tree
Showing 10 changed files with 403 additions and 32 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion src/Callback.php
Expand Up @@ -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);
}
}

/**
Expand Down
165 changes: 146 additions & 19 deletions src/Callback/Argument.php
Expand Up @@ -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
Expand All @@ -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();
}
}
32 changes: 28 additions & 4 deletions src/Callback/Parameter.php
Expand Up @@ -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);
Expand All @@ -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;
}

/**
Expand Down
16 changes: 14 additions & 2 deletions src/Callback/Parameter/TypedParameter.php
Expand Up @@ -5,6 +5,7 @@
use Zenstruck\Callback\Argument;
use Zenstruck\Callback\Exception\UnresolveableArgument;
use Zenstruck\Callback\Parameter;
use Zenstruck\Callback\ValueFactory;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Callback/Parameter/UntypedParameter.php
Expand Up @@ -5,6 +5,7 @@
use Zenstruck\Callback\Argument;
use Zenstruck\Callback\Exception\UnresolveableArgument;
use Zenstruck\Callback\Parameter;
use Zenstruck\Callback\ValueFactory;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand All @@ -13,6 +14,9 @@ final class UntypedParameter extends Parameter
{
private $value;

/**
* @param mixed|ValueFactory $value
*/
public function __construct($value)
{
$this->value = $value;
Expand Down
3 changes: 3 additions & 0 deletions src/Callback/ValueFactory.php
Expand Up @@ -12,6 +12,9 @@ final class ValueFactory
/** @var callable */
private $factory;

/**
* @param callable<string|array|Argument|null> $factory
*/
public function __construct(callable $factory)
{
$this->factory = $factory;
Expand Down

0 comments on commit d72eaba

Please sign in to comment.