Skip to content

Commit

Permalink
feat: dynamically determine return type for Collection::reject (#2107)
Browse files Browse the repository at this point in the history
  • Loading branch information
spawnia authored and canvural committed Nov 26, 2024
1 parent a677364 commit ef598cc
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 31 deletions.
3 changes: 2 additions & 1 deletion extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,10 @@ services:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: Larastan\Larastan\ReturnTypes\CollectionFilterDynamicReturnTypeExtension
class: Larastan\Larastan\ReturnTypes\CollectionFilterRejectDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension

-
class: Larastan\Larastan\ReturnTypes\CollectionWhereNotNullDynamicReturnTypeExtension
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;

use function assert;
use function count;
use function in_array;
use function is_string;

class CollectionFilterDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
class CollectionFilterRejectDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
Expand All @@ -30,7 +32,7 @@ public function getClass(): string

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'filter';
return in_array($methodReflection->getName(), ['filter', 'reject'], true);
}

public function getTypeFromMethodCall(
Expand All @@ -51,10 +53,16 @@ public function getTypeFromMethodCall(
return null;
}

$methodName = $methodReflection->getName();
assert($methodName === 'filter' || $methodName === 'reject', 'proven in isMethodSupported');

if (count($methodCall->getArgs()) < 1) {
$nonFalseyTypes = TypeCombinator::removeFalsey($valueType);
$modifiedType = match ($methodName) {
'filter' => TypeCombinator::removeFalsey($valueType),
'reject' => TypeCombinator::removeTruthy($valueType)
};

return new GenericObjectType($calledOnType->getObjectClassNames()[0], [$keyType, $nonFalseyTypes]);
return new GenericObjectType($calledOnType->getObjectClassNames()[0], [$keyType, $modifiedType]);
}

$callbackArg = $methodCall->getArgs()[0]->value;
Expand Down Expand Up @@ -82,8 +90,11 @@ public function getTypeFromMethodCall(

$node = new Variable($itemVariableName);
// @phpstan-ignore-next-line
$scope = $scope->assignExpression($node, $valueType, $valueType);
$scope = $scope->filterByTruthyValue($expr);
$scope = $scope->assignExpression($node, $valueType, $valueType);
$scope = match ($methodName) {
'filter' => $scope->filterByTruthyValue($expr),
'reject' => $scope->filterByFalseyValue($expr),
};
$valueType = $scope->getVariableType($itemVariableName);
}

Expand Down
9 changes: 1 addition & 8 deletions tests/Type/CollectionDynamicReturnTypeExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,14 @@

use PHPStan\Testing\TypeInferenceTestCase;

use const PHP_VERSION_ID;

class CollectionDynamicReturnTypeExtensionTest extends TypeInferenceTestCase
{
/** @return iterable<mixed> */
public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/collection-filter.php');
yield from self::gatherAssertTypes(__DIR__ . '/data/collection-reject.php');
yield from self::gatherAssertTypes(__DIR__ . '/data/collection-where-not-null.php');

if (PHP_VERSION_ID < 70400) {
return;
}

yield from self::gatherAssertTypes(__DIR__ . '/data/collection-filter-arrow-function.php');
}

/** @dataProvider dataFileAsserts */
Expand Down
16 changes: 0 additions & 16 deletions tests/Type/data/collection-filter-arrow-function.php

This file was deleted.

2 changes: 2 additions & 0 deletions tests/Type/data/collection-filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ function test(User $user, SupportCollection $users): void
assertType('Illuminate\Support\Collection<int, int<3, max>>', collect([1, 2, 3, 4, 5, 6])->filter(function (int $value) {
return $value > 2;
}));
assertType('Illuminate\Support\Collection<int, int<3, max>>', collect([1, 2, 3, 4, 5, 6])->filter(fn (int $value) => $value > 2));

assertType("Illuminate\Database\Eloquent\Collection<int, App\User>", $users->filter(function (User $user): bool {
return ! $user->blocked;
}));
assertType("Illuminate\Database\Eloquent\Collection<int, App\User>", $users->filter(fn (User $user) => ! $user->blocked));

assertType(
'Illuminate\Support\Collection<int, App\Account>',
Expand Down
63 changes: 63 additions & 0 deletions tests/Type/data/collection-reject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace CollectionReject;

use App\Account;
use App\User;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection as SupportCollection;

use function PHPStan\Testing\assertType;

function convertToAccount(User $user): ?Account
{ }

function dummyReject($value)
{
if ($value instanceof User) {
return true;
}

return random_int(0, 1) > 1;
}

/** @param EloquentCollection<int, User> $users */
function test(User $user, SupportCollection $users): void
{
assertType("Illuminate\Support\Collection<(int|string), 0|0.0|''|'0'|array{}|false|null>", collect()->reject());

assertType("Illuminate\Support\Collection<int, ''|'0'|null>", collect(['foo', null, '', 'bar', null])->reject());

assertType('Illuminate\Support\Collection<int, int<min, 2>>', collect([1, 2, 3, 4, 5, 6])->reject(function (int $value) {
return $value > 2;
}));
assertType('Illuminate\Support\Collection<int, int<min, 2>>', collect([1, 2, 3, 4, 5, 6])->reject(fn (int $value) => $value > 2));

assertType("Illuminate\Database\Eloquent\Collection<int, App\User>", $users->reject(function (User $user): bool {
return ! $user->blocked;
}));
assertType("Illuminate\Database\Eloquent\Collection<int, App\User>", $users->reject(fn (User $user) => ! $user->blocked));

assertType(
'Illuminate\Support\Collection<int, null>',
collect($users->all())
->map(function (User $attachment): ?Account {
return convertToAccount($attachment);
})
->reject()
);

$accounts = $user->accounts()->active()->get();
assertType('App\AccountCollection<int, App\Account>', $accounts);

assertType('App\AccountCollection<int, App\Account>', $accounts->reject(function ($account) {
return \CollectionStubs\dummyReject($account);
}));

$accounts->reject(function ($account) {
return dummyReject($account);
})
->map(function ($account) {
assertType('App\Account', $account);
});
}

0 comments on commit ef598cc

Please sign in to comment.