Skip to content

Commit

Permalink
Add CallbackVisitor to handle callback filters
Browse files Browse the repository at this point in the history
  • Loading branch information
matt committed Mar 2, 2024
1 parent e7f853f commit fd97dca
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 6 deletions.
5 changes: 5 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
<code><![CDATA[visitProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/Filter/CallbackVisitorTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[visitProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/Filter/DigitsVisitorTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[visitProvider]]></code>
Expand Down
2 changes: 2 additions & 0 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Kynx\Laminas\FormShape\Filter\AllowListVisitor;
use Kynx\Laminas\FormShape\Filter\AllowListVisitorFactory;
use Kynx\Laminas\FormShape\Filter\BooleanVisitor;
use Kynx\Laminas\FormShape\Filter\CallbackVisitor;
use Kynx\Laminas\FormShape\Filter\DigitsVisitor as DigitsFilterVisitor;
use Kynx\Laminas\FormShape\Filter\InflectorVisitor;
use Kynx\Laminas\FormShape\Filter\ToFloatVisitor;
Expand Down Expand Up @@ -128,6 +129,7 @@ private function getLaminasFormShapeConfig(): array
'filter-visitors' => [
AllowListVisitor::class,
BooleanVisitor::class,
CallbackVisitor::class,
DigitsFilterVisitor::class,
InflectorVisitor::class,
ToFloatVisitor::class,
Expand Down
180 changes: 180 additions & 0 deletions src/Filter/CallbackVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

namespace Kynx\Laminas\FormShape\Filter;

use Closure;
use Kynx\Laminas\FormShape\FilterVisitorInterface;
use Laminas\Filter\Callback;
use Laminas\Filter\FilterInterface;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Union;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;

use function array_combine;
use function array_map;
use function array_merge;
use function array_shift;
use function assert;
use function count;
use function is_array;
use function is_object;
use function is_string;

final readonly class CallbackVisitor implements FilterVisitorInterface
{
public function visit(FilterInterface $filter, Union $previous): Union
{
if (! $filter instanceof Callback) {
return $previous;
}

$callable = $filter->getCallback();
$union = match (true) {
$callable instanceof Closure => $this->getClosureReturnType($callable),
is_array($callable) => $this->getArrayReturnType($callable),
is_object($callable) => $this->getInvokableReturnType($callable),
is_string($callable) => $this->getStringReturnType($callable),
};

return $union ?? $previous;
}

/**
* @param array{0: class-string|object, 1: string} $callable
*/
private function getArrayReturnType(array $callable): ?Union
{
try {
$reflection = new ReflectionClass($callable[0]);
$method = $reflection->getMethod($callable[1]);
} catch (ReflectionException) {
return null;
}

return $this->getUnion($this->getReturnType($method), $reflection);
}

private function getInvokableReturnType(object $invokable): ?Union
{
try {
$reflection = new ReflectionClass($invokable);
$method = $reflection->getMethod('__invoke');
} catch (ReflectionException) {
return null;
}

return $this->getUnion($this->getReturnType($method), $reflection);
}

/**
* @param callable-string $function
*/
private function getStringReturnType(string $function): ?Union
{
try {
$reflection = new ReflectionFunction($function);
} catch (ReflectionException) {
return null;
}

return $this->getUnion($this->getReturnType($reflection));
}

private function getClosureReturnType(Closure $closure): ?Union
{
try {
$reflection = new ReflectionFunction($closure);
} catch (ReflectionException) {
return null;
}

return $this->getUnion($this->getReturnType($reflection));
}

private function getReturnType(ReflectionFunctionAbstract $function): ?ReflectionType
{
if ($function->hasReturnType()) {
return $function->getReturnType();
}

return $function->getTentativeReturnType();
}

private function getUnion(?ReflectionType $type, ?ReflectionClass $self = null): ?Union
{
if ($type === null) {
return null;
}

$types = [];
if ($type instanceof ReflectionIntersectionType) {
/** @var array<TNamedObject> $intersection */
$intersection = self::getAtomicTypes($type, $self);
$first = array_shift($intersection);
$keys = array_map(static fn (TNamedObject $type): string => $type->getKey(), $intersection);
$types = [
new TNamedObject(
$first->value,
false,
false,
array_combine($keys, $intersection)
),
];
} elseif ($type instanceof ReflectionUnionType) {
$types = self::getAtomicTypes($type, $self);
} else {
assert($type instanceof ReflectionNamedType);
$types[] = self::getAtomicType($type, $self);
}

assert(count($types) > 0);
return new Union($types);
}

/**
* @return array<string, Atomic>
*/
private static function getAtomicTypes(
ReflectionIntersectionType|ReflectionUnionType $type,
?ReflectionClass $self
): array {
$types = [];
foreach ($type->getTypes() as $subType) {
if ($subType instanceof ReflectionIntersectionType) {
$types = array_merge($types, self::getAtomicTypes($subType, $self));
} else {
$atomicType = self::getAtomicType($subType, $self);
$types[$atomicType->getKey()] = $atomicType;
}
}

return $types;
}

private static function getAtomicType(ReflectionNamedType $type, ?ReflectionClass $self): Atomic
{
if ($self !== null) {
$parent = $self->getParentClass();
$atomic = match ($type->getName()) {
'parent' => new TNamedObject($parent->getName()),
'self', 'static' => new TNamedObject($self->getName()),
default => null,
};
if ($atomic) {
return $atomic;
}
}

return Atomic::create($type->getName());
}
}
7 changes: 5 additions & 2 deletions src/InputFilter/AbstractInputVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Kynx\Laminas\FormShape\InputVisitorInterface;
use Kynx\Laminas\FormShape\Psalm\TypeUtil;
use Kynx\Laminas\FormShape\ValidatorVisitorInterface;
use Laminas\Filter\Callback;
use Laminas\Filter\FilterInterface;
use Laminas\InputFilter\EmptyContextInterface;
use Laminas\InputFilter\Input;
Expand All @@ -19,6 +20,7 @@
use function array_filter;
use function array_map;
use function array_unshift;
use function is_callable;

abstract readonly class AbstractInputVisitor implements InputVisitorInterface
{
Expand All @@ -35,9 +37,10 @@ protected function visitInput(InputInterface $input, Union $initial): Union
$union = $initial->getBuilder()->freeze();

foreach ($input->getFilterChain()->getIterator() as $filter) {
if (! $filter instanceof FilterInterface) {
continue;
if (is_callable($filter) && ! $filter instanceof FilterInterface) {
$filter = new Callback($filter);
}

$union = $this->visitFilters($filter, $union);
}

Expand Down
96 changes: 96 additions & 0 deletions test/Filter/CallbackVisitorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace KynxTest\Laminas\FormShape\Filter;

use DateTime;
use DateTimeImmutable;
use Kynx\Laminas\FormShape\Filter\CallbackVisitor;
use Kynx\Laminas\FormShape\FilterVisitorInterface;
use Laminas\Filter\Callback;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\Stub;
use Psalm\Type\Atomic\TInt;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TString;

#[CoversClass(CallbackVisitor::class)]
final class CallbackVisitorTest extends AbstractFilterVisitorTestCase
{
public static function visitProvider(): array
{
/** @psalm-suppress MissingClosureReturnType */
$noReturn = static fn () => 123;
$closure = static fn (): int => 123;
$union = static fn (): DateTime|DateTimeImmutable => new DateTimeImmutable('now');
$intersection = static fn (): FilterVisitorInterface&Stub => self::createStub(FilterVisitorInterface::class);
$invokable = new class () {
public function __invoke(): int
{
return 123;
}
};
$callable = new class () {
public function filter(): int
{
return 123;
}
};
$self = new class () {
public function __invoke(): self
{
return $this;
}
};
$static = new class () {
public function __invoke(): static
{
return $this;
}
};
$parent = new class () extends DateTimeImmutable {
public function __invoke(): parent
{
return new DateTimeImmutable();
}
};

return [
'no return' => [new Callback($noReturn), [new TString()], [new TString()]],
'closure' => [new Callback($closure), [new TString()], [new TInt()]],
'invokable' => [new Callback($invokable), [new TString()], [new TInt()]],
'array' => [new Callback([$callable, 'filter']), [new TString()], [new TInt()]],
'string' => [new Callback('intval'), [new TString()], [new TInt()]],
'self' => [new Callback($self), [new TString()], [new TNamedObject($self::class)]],
'static' => [new Callback($static), [new TString()], [new TNamedObject($static::class)]],
'parent' => [
new Callback($parent),
[new TString()],
[new TNamedObject(DateTimeImmutable::class)],
],
'union' => [
new Callback($union),
[new TString()],
[new TNamedObject(DateTime::class), new TNamedObject(DateTimeImmutable::class)],
],
'intersection' => [
new Callback($intersection),
[new TString()],
[
new TNamedObject(
FilterVisitorInterface::class,
false,
false,
[Stub::class => new TNamedObject(Stub::class)]
),
],
],
];
}

protected function getVisitor(): FilterVisitorInterface
{
return new CallbackVisitor();
}
}
22 changes: 18 additions & 4 deletions test/InputFilter/AbstractInputVisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace KynxTest\Laminas\FormShape\InputFilter;

use Kynx\Laminas\FormShape\Filter\CallbackVisitor;
use Kynx\Laminas\FormShape\Filter\ToIntVisitor;
use Kynx\Laminas\FormShape\InputFilter\AbstractInputVisitor;
use Kynx\Laminas\FormShape\Psalm\ConfigLoader;
use Kynx\Laminas\FormShape\Validator\DigitsVisitor;
use Kynx\Laminas\FormShape\Validator\NotEmptyVisitor;
use Kynx\Laminas\FormShape\ValidatorVisitorInterface;
use KynxTest\Laminas\FormShape\InputFilter\MockAbstractInputVisitor;
use Laminas\Filter\Callback;
use Laminas\Filter\ToInt;
use Laminas\InputFilter\Input;
use Laminas\Validator\Digits;
Expand Down Expand Up @@ -41,13 +43,25 @@ public function testVisitCallsFilter(): void
self::assertEquals($expected, $actual);
}

public function testVisitSkipsCallableFilters(): void
public function testVisitVisitsCallable(): void
{
$expected = new Union([new TNull(), new TString()]);
$filter = static fn (): never => self::fail("Should not be called");
$expected = new Union([new TInt()]);
$filter = static fn (): int => 123;
$input = new Input('foo');
$input->getFilterChain()->attach($filter);
$visitor = new MockAbstractInputVisitor([new ToIntVisitor()], []);
$visitor = new MockAbstractInputVisitor([new CallbackVisitor()], []);

$actual = $visitor->visit($input);
self::assertEquals($expected, $actual);
}

public function testVisitVisitsCallbackFilter(): void
{
$expected = new Union([new TInt()]);
$filter = new Callback('intval');
$input = new Input('foo');
$input->getFilterChain()->attach($filter);
$visitor = new MockAbstractInputVisitor([new CallbackVisitor()], []);

$actual = $visitor->visit($input);
self::assertEquals($expected, $actual);
Expand Down

0 comments on commit fd97dca

Please sign in to comment.