-
-
Notifications
You must be signed in to change notification settings - Fork 394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve return type inference of Collection::groupBy. #1860
base: 2.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Larastan\Larastan\ReturnTypes; | ||
|
||
use Illuminate\Support\Enumerable; | ||
use PHPStan\Analyser\OutOfClassScope; | ||
use PHPStan\Analyser\Scope; | ||
use PHPStan\Reflection\MethodReflection; | ||
use PHPStan\Type\BenevolentUnionType; | ||
use PHPStan\Type\Constant\ConstantStringType; | ||
use PHPStan\Type\DynamicMethodReturnTypeExtension; | ||
use PHPStan\Type\Generic\GenericObjectType; | ||
use PHPStan\Type\IntegerType; | ||
use PHPStan\Type\MixedType; | ||
use PHPStan\Type\StringType; | ||
use PHPStan\Type\Type; | ||
use PHPStan\Type\TypeCombinator; | ||
use PhpParser\Node\Expr\MethodCall; | ||
|
||
use function array_reverse; | ||
use function count; | ||
|
||
class EnumerableGroupByReturnTypeExtension implements DynamicMethodReturnTypeExtension | ||
{ | ||
public function getClass(): string | ||
{ | ||
return Enumerable::class; | ||
} | ||
|
||
public function isMethodSupported(MethodReflection $methodReflection): bool | ||
{ | ||
return $methodReflection->getName() === 'groupBy'; | ||
} | ||
|
||
public function getTypeFromMethodCall( | ||
MethodReflection $methodReflection, | ||
MethodCall $methodCall, | ||
Scope $scope | ||
): ?Type { | ||
if (count($methodCall->getArgs()) < 1) { | ||
return null; | ||
} | ||
|
||
$calledOnType = $scope->getType($methodCall->var); | ||
$objectClassReflections = $calledOnType->getObjectClassReflections(); | ||
|
||
if (!isset($objectClassReflections[0])) { | ||
return null; | ||
} | ||
|
||
$groupByType = $scope->getType($methodCall->getArgs()[0]->value); | ||
$propertyTypes = []; | ||
|
||
$collectionName = $objectClassReflections[0]->getName(); | ||
|
||
$valueType = new MixedType(); | ||
|
||
if ($objectClassReflections[0]->isGeneric()) { | ||
$tValueType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TValue'); | ||
|
||
if ($tValueType !== null) { | ||
$valueType = $tValueType; | ||
} | ||
} | ||
|
||
if ($groupByType->isString()->yes()) { | ||
$propertyTypes[] = $groupByType; | ||
} elseif ($groupByType->isConstantArray()->yes()) { | ||
if ($groupByType->isIterableAtLeastOnce()->no()) { | ||
return $this->unknownKeysType($collectionName, $valueType); | ||
} | ||
|
||
$valuesArray = $groupByType->getConstantArrays()[0]->getValueTypes(); | ||
|
||
foreach ($valuesArray as $valuesType) { | ||
$propertyTypes[] = $valuesType; | ||
} | ||
} else { | ||
return $this->unknownKeysType($collectionName, new MixedType()); | ||
} | ||
|
||
|
||
$innerKeyType = new IntegerType(); | ||
|
||
if (count($methodCall->getArgs()) >= 2) { | ||
$preserveType = $scope->getType($methodCall->getArgs()[1]->value); | ||
$tKeyType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TKey'); | ||
|
||
if ($tKeyType === null) { | ||
$innerKeyType = new BenevolentUnionType([new IntegerType(), new StringType()]); | ||
} elseif ($preserveType->isTrue()->yes()) { | ||
$innerKeyType = $tKeyType; | ||
} elseif ($preserveType->isTrue()->maybe()) { | ||
$innerKeyType = TypeCombinator::union($tKeyType, new IntegerType()); | ||
} | ||
} | ||
|
||
|
||
$inner = new GenericObjectType($collectionName, [$innerKeyType, $valueType]); | ||
|
||
foreach (array_reverse($propertyTypes) as $propertyType) { | ||
if (count($propertyType->getConstantStrings()) > 0) { | ||
$types = []; | ||
foreach ($propertyType->getConstantStrings() as $constantString) { | ||
if ($valueType->hasProperty($constantString->getValue())->yes()) { | ||
$types[] = $valueType->getProperty($constantString->getValue(), new OutOfClassScope())->getReadableType(); | ||
} elseif ($valueType->hasOffsetValueType($constantString)->yes()) { | ||
$types[] = $valueType->getOffsetValueType($constantString); | ||
} | ||
} | ||
|
||
if (count($types) === 0) { | ||
$keyType = new ConstantStringType(''); | ||
} else { | ||
$keyType = TypeCombinator::union(...$types); | ||
} | ||
} elseif ($propertyType->isCallable()->yes()) { | ||
$keyType = $propertyType->getCallableParametersAcceptors(new OutOfClassScope())[0]->getReturnType(); | ||
} else { | ||
$keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); | ||
} | ||
|
||
if ($keyType instanceof MixedType) { | ||
$keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); | ||
} | ||
|
||
$inner = new GenericObjectType($collectionName, [$keyType, $inner]); | ||
} | ||
|
||
return $inner; | ||
} | ||
|
||
/** | ||
* @param class-string $name | ||
*/ | ||
private function unknownKeysType(string $name, Type $inner): Type | ||
{ | ||
return new GenericObjectType($name, [ | ||
new BenevolentUnionType([new IntegerType(), new StringType()]), | ||
new GenericObjectType($name, [ | ||
new BenevolentUnionType([new IntegerType(), new StringType()]), | ||
$inner | ||
]) | ||
]); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,8 +44,8 @@ | |
assertType('Illuminate\Support\Collection<App\User, int>', $collection->flip()); | ||
assertType('Illuminate\Support\Collection<int, string>', $items->flip()); | ||
|
||
assertType('Illuminate\Database\Eloquent\Collection<(int|string), Illuminate\Database\Eloquent\Collection<(int|string), App\User>>', $collection->groupBy('id')); | ||
assertType('Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), int>>', $items->groupBy('id')); | ||
assertType('Illuminate\Database\Eloquent\Collection<int, Illuminate\Database\Eloquent\Collection<int, App\User>>', $collection->groupBy('id')); | ||
assertType("Illuminate\Support\Collection<'', Illuminate\Support\Collection<int, int>>", $items->groupBy('id')); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it can't read a property off the types you have in your collection it helpfully groups them all into an empty string group... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get why you do this, although not sure I really care for it... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i don't care for it either but it accurately models what laravel is doing with it's types.. i could make it be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it can't read the type then why not just default to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. because phpstan will report an error when trying to access any other key, alerting the developer to the mistake they have made: |
||
|
||
assertType('Illuminate\Database\Eloquent\Collection<(int|string), App\User>', $collection->keyBy(fn (User $user, int $key): string => $user->email)); | ||
|
||
|
@@ -183,7 +183,7 @@ | |
}) | ||
); | ||
|
||
assertType('Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), array{id: int, type: string}>>', collect([ | ||
assertType('Illuminate\Support\Collection<string, Illuminate\Support\Collection<int, array{id: int, type: string}>>', collect([ | ||
[ | ||
'id' => 1, | ||
'type' => 'A', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
<?php | ||
|
||
namespace CollectionGroupBy; | ||
|
||
use App\User; | ||
use Illuminate\Support\Collection; | ||
use function PHPStan\Testing\assertType; | ||
|
||
/** | ||
* @param Collection $untyped | ||
* @param Collection<int, User> $users | ||
* @param Collection<string, User> $stringUsers | ||
* @param Collection<string, string> $stringStrings | ||
* @param 'id'|'name' $key | ||
* @param bool $preserve | ||
* @param array<int, string> $keys | ||
*/ | ||
function test( | ||
Collection $untyped, | ||
Collection $users, | ||
Collection $stringUsers, | ||
Collection $stringStrings, | ||
string $key, | ||
bool $preserve, | ||
array $keys | ||
): void { | ||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<int, mixed>>', | ||
$untyped->groupBy('id') | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<int, mixed>>', | ||
$untyped->groupBy(['id']) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), mixed>>', | ||
$untyped->groupBy('id', $preserve) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<int, App\User>>', | ||
$users->groupBy('id') | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int|string, Illuminate\Support\Collection<int, App\User>>', | ||
$users->groupBy($key) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<string, Illuminate\Support\Collection<int, App\User>>>', | ||
$users->groupBy(['id', 'name']) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<int<0, 1>, Illuminate\Support\Collection<int, App\User>>>', | ||
$users->groupBy(['id', fn ($user) => rand(0, 1)]) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<string, App\User>>', | ||
$stringUsers->groupBy('id', true) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<int|string, App\User>>', | ||
$stringUsers->groupBy('id', $preserve) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<int, Illuminate\Support\Collection<string, Illuminate\Support\Collection<int|string, App\User>>>', | ||
$stringUsers->groupBy(['id', 'name'], $preserve) | ||
); | ||
assertType( | ||
"Illuminate\Support\Collection<'', Illuminate\Support\Collection<int, string>>", | ||
$stringStrings->groupBy(['id']) | ||
); | ||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), mixed>>', | ||
$users->groupBy($keys) | ||
mad-briller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
|
||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), App\User>>', | ||
$users->groupBy([]) | ||
); | ||
} | ||
|
||
/** | ||
* @param ?Collection<int, string> $collection | ||
*/ | ||
function testNullable(?Collection $collection): void | ||
{ | ||
$res = $collection->groupBy('key'); | ||
|
||
assertType( | ||
'Illuminate\Support\Collection<(int|string), Illuminate\Support\Collection<(int|string), string>>', | ||
$res | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't we check if the types are either
int
orstring
? What I mean is that for example objects can't be array keys. Or if any of the types were nullable we would have for exampleint|null
as collection key. Which does not make sense.Maybe we can call
toArrayKey
on this type?