Skip to content

Commit

Permalink
fix: withWhereHas closure parameter type
Browse files Browse the repository at this point in the history
  • Loading branch information
calebdw committed Feb 10, 2025
1 parent 0e15515 commit 6ed3161
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 91 deletions.
141 changes: 63 additions & 78 deletions src/Parameters/RelationClosureHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
use PhpParser\Node\Name;
use PhpParser\Node\VariadicPlaceholder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Type\ClosureType;
Expand All @@ -24,15 +23,14 @@
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;

use function array_push;
use function array_shift;
use function collect;
use function count;
use function dd;
use function explode;
use function in_array;
use function is_string;

final class RelationClosureHelper
{
Expand Down Expand Up @@ -81,18 +79,30 @@ public function getTypeFromMethodCall(
ParameterReflection $parameter,
Scope $scope,
): Type|null {
$isMorphMethod = in_array($methodReflection->getName(), $this->morphMethods, strict: true);
$method = $methodReflection->getName();
$isMorphMethod = in_array($method, $this->morphMethods, strict: true);
$models = [];
$relations = [];

$models = $isMorphMethod
? $this->getMorphModels($methodCall, $scope)
: $this->getModels($methodCall, $scope);
if ($isMorphMethod) {
$models = $this->getMorphModels($methodCall, $scope);
} else {
$relations = $this->getRelationsFromMethodCall($methodCall, $scope);
$models = $this->getModelsFromRelations($relations);
}

if (count($models) === 0) {
return null;
}

$type = $this->builderHelper->getBuilderTypeForModels($models);

if ($method === 'withWhereHas') {
$type = TypeCombinator::union($type, ...$relations);
}

return new ClosureType([
new ClosureQueryParameter('query', $this->builderHelper->getBuilderTypeForModels($models)),
new ClosureQueryParameter('query', $type),
new ClosureQueryParameter('type', $isMorphMethod ? new NeverType() : new StringType()),
], new MixedType());
}
Expand Down Expand Up @@ -121,45 +131,29 @@ private function getMorphModels(MethodCall|StaticCall $methodCall, Scope $scope)
->flatMap(static fn (ConstantArrayType $t) => $t->getValueTypes())
->flatMap(static fn (Type $t) => $t->getConstantStrings())
->merge($models->getConstantStrings())
->map(static function (ConstantStringType $t) {
$value = $t->getValue();

return $value === '*' ? Model::class : $value;
})
->map(static fn (ConstantStringType $t) => $t->getValue())
->map(static fn (string $v) => $v === '*' ? Model::class : $v)
->values()
->all();
}

/** @return array<int, string> */
private function getModels(MethodCall|StaticCall $methodCall, Scope $scope): array
/**
* @param array<int, Type> $relations
* @return array<int, string>
*/
private function getModelsFromRelations(array $relations): array
{
$relations = $this->getRelationsFromMethodCall($methodCall, $scope);

if (count($relations) === 0) {
return [];
}

if ($methodCall instanceof MethodCall) {
$calledOnModels = $scope->getType($methodCall->var)
->getTemplateType(EloquentBuilder::class, 'TModel')
->getObjectClassNames();
} else {
$calledOnModels = $methodCall->class instanceof Name
? [$scope->resolveName($methodCall->class)]
: dd($scope->getType($methodCall->class))->getReferencedClasses();
}

return collect($relations)
->flatMap(
fn ($relation) => is_string($relation)
? $this->getModelsFromStringRelation($calledOnModels, explode('.', $relation), $scope)
: $this->getModelsFromRelationReflection($relation),
static fn (Type $relation) => $relation
->getTemplateType(Relation::class, 'TRelatedModel')
->getObjectClassNames(),
)
->values()
->all();
}

/** @return array<int, string|ClassReflection> */
/** @return array<int, Type> */
public function getRelationsFromMethodCall(MethodCall|StaticCall $methodCall, Scope $scope): array
{
$relationType = null;
Expand All @@ -179,71 +173,62 @@ public function getRelationsFromMethodCall(MethodCall|StaticCall $methodCall, Sc
return [];
}

return collect([
...$relationType->getConstantStrings(),
...$relationType->getObjectClassReflections(),
])
->map(static function ($type) {
if ($type instanceof ClassReflection) {
return $type->is(Relation::class) ? $type : null;
}
if ($methodCall instanceof MethodCall) {
$calledOnModels = $scope->getType($methodCall->var)
->getTemplateType(EloquentBuilder::class, 'TModel')
->getObjectClassNames();
} else {
$calledOnModels = $methodCall->class instanceof Name
? [$scope->resolveName($methodCall->class)]
: $scope->getType($methodCall->class)->getReferencedClasses();
}

return $type->getValue();
})
->filter()
return collect($relationType->getConstantStrings())
->map(static fn ($type) => $type->getValue())
->flatMap(fn ($relation) => $this->getRelationTypeFromString($calledOnModels, explode('.', $relation), $scope))
->merge([$relationType])
->filter(static fn ($r) => (new ObjectType(Relation::class))->isSuperTypeOf($r)->yes())
->values()
->all();
}

/**
* @param list<string> $calledOnModels
* @param list<string> $relationParts
*
* @return list<string>
* @return list<Type>
*/
public function getModelsFromStringRelation(
public function getRelationTypeFromString(
array $calledOnModels,
array $relationParts,
Scope $scope,
): array {
$relationName = array_shift($relationParts);

if ($relationName === null) {
return $calledOnModels;
}

$models = [];
$relations = [];

foreach ($calledOnModels as $model) {
$modelType = new ObjectType($model);
if (! $modelType->hasMethod($relationName)->yes()) {
continue;
}
while ($relationName = array_shift($relationParts)) {
$relations = [];
$relatedModels = [];

$relationMethod = $modelType->getMethod($relationName, $scope);
$relationType = $relationMethod->getVariants()[0]->getReturnType();
foreach ($calledOnModels as $model) {
$modelType = new ObjectType($model);

if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
continue;
}
if (! $modelType->hasMethod($relationName)->yes()) {
continue;
}

$relatedModels = $relationType->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames();
$relationType = $modelType->getMethod($relationName, $scope)->getVariants()[0]->getReturnType();

array_push($models, ...$this->getModelsFromStringRelation($relatedModels, $relationParts, $scope));
}
if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) {
continue;
}

return $models;
}
$relations[] = $relationType;

/** @return list<string> */
public function getModelsFromRelationReflection(ClassReflection $relation): array
{
$relatedModel = $relation->getActiveTemplateTypeMap()->getType('TRelatedModel');
array_push($relatedModels, ...$relationType->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames());
}

if ($relatedModel === null) {
return [];
$calledOnModels = $relatedModels;
}

return $relatedModel->getObjectClassNames();
return $relations;
}
}
45 changes: 36 additions & 9 deletions tests/Type/data/eloquent-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use App\User;
use App\Address;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;

use function PHPStan\Testing\assertType;

Expand Down Expand Up @@ -57,8 +57,12 @@ function test(
assertType('Illuminate\Database\Eloquent\Builder<App\Account>', $query);
});

Post::query()->withWhereHas('users', function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\User>', $query);
User::query()->withWhereHas('accounts.posts', function (Builder|Relation $query) {
assertType('App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Relations\BelongsToMany<App\Post, App\Account>', $query);
});

Post::query()->withWhereHas('users', function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\User>|Illuminate\Database\Eloquent\Relations\BelongsToMany<App\User, App\Post>', $query);
});

User::query()->orWhereHas('accounts', function (Builder $query) {
Expand All @@ -81,16 +85,39 @@ function test(
assertType('Illuminate\Database\Eloquent\Builder<App\Account>', $query);
});

/** @var 'accounts'|'address' $relation */
$relation = 'address';
$relation = random_int(0, 1) ? 'accounts' : 'address';
User::query()->whereHas($relation, function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>', $query);
});
User::query()->withWhereHas($relation, function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>|Illuminate\Database\Eloquent\Relations\HasMany<App\Account, App\User>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});

$relation = random_int(0, 1) ? 'accounts.posts' : 'address';
User::query()->whereHas($relation, function (Builder $query) {
assertType('App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Builder<App\Address>', $query);
});
User::query()->withWhereHas($relation, function (Builder|Relation $query) {
assertType('App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Builder<App\Address>|Illuminate\Database\Eloquent\Relations\BelongsToMany<App\Post, App\Account>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});

$relation = random_int(0, 1) ? $user->accounts() : $user->address();
User::query()->whereHas($relation, function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>', $query);
});
User::query()->withWhereHas($relation, function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>|Illuminate\Database\Eloquent\Relations\HasMany<App\Account, App\User>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});


$user->has($user->accounts(), callback: function ($query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account>', $query);
});

$user->withWhereHas($user->accounts(), function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account>|Illuminate\Database\Eloquent\Relations\HasMany<App\Account, App\User>', $query);
});

$userOrTeamBuilder->has('address', function ($query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Address>', $query);
});
Expand Down Expand Up @@ -164,22 +191,22 @@ function test(
])->get());

assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', User::where('id', 1)->get());
assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', (new User)->where('id', 1)->get());
assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', (new User())->where('id', 1)->get());
assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', User::where('id', 1)
->whereNotNull('name')
->where('email', 'bar')
->whereFoo(['bar'])
->get());
assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', (new User)->whereNotNull('name')
assertType('Illuminate\Database\Eloquent\Collection<int, App\User>', (new User())->whereNotNull('name')
->where('email', 'bar')
->whereFoo(['bar'])
->get());
assertType('Illuminate\Support\Collection<string, string>', User::whereIn('id', [1, 2, 3])->get()->mapWithKeys(function (User $user): array {
return [$user->name => $user->email];
}));

assertType('mixed', (new User)->where('email', 1)->max('email'));
assertType('bool', (new User)->where('email', 1)->exists());
assertType('mixed', (new User())->where('email', 1)->max('email'));
assertType('bool', (new User())->where('email', 1)->exists());
assertType('Illuminate\Database\Eloquent\Builder<App\User>', User::with('accounts')->whereNull('name'));
assertType('Illuminate\Database\Eloquent\Builder<App\User>', User::with('accounts')
->where('email', 'bar')
Expand Down
26 changes: 22 additions & 4 deletions tests/Type/data/model.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ function test(
assertType('Illuminate\Database\Eloquent\Builder<App\Account>', $query);
});

Post::withWhereHas('users', function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\User>', $query);
Post::withWhereHas('users', function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\User>|Illuminate\Database\Eloquent\Relations\BelongsToMany<App\User, App\Post>', $query);
});

User::orWhereHas('accounts', function (Builder $query) {
Expand All @@ -246,11 +246,29 @@ function test(
assertType('Illuminate\Database\Eloquent\Builder<App\Account>', $query);
});

/** @var 'accounts'|'address' $relation */
$relation = 'address';
$relation = random_int(0, 1) ? 'accounts' : 'address';
User::whereHas($relation, function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>', $query);
});
User::withWhereHas($relation, function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>|Illuminate\Database\Eloquent\Relations\HasMany<App\Account, App\User>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});

$relation = random_int(0, 1) ? 'accounts.posts' : 'address';
User::whereHas($relation, function (Builder $query) {
assertType('App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Builder<App\Address>', $query);
});
User::withWhereHas($relation, function (Builder|Relation $query) {
assertType('App\PostBuilder<App\Post>|Illuminate\Database\Eloquent\Builder<App\Address>|Illuminate\Database\Eloquent\Relations\BelongsToMany<App\Post, App\Account>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});

$relation = random_int(0, 1) ? $user->accounts() : $user->address();
User::whereHas($relation, function (Builder $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>', $query);
});
User::withWhereHas($relation, function (Builder|Relation $query) {
assertType('Illuminate\Database\Eloquent\Builder<App\Account|App\Address>|Illuminate\Database\Eloquent\Relations\HasMany<App\Account, App\User>|Illuminate\Database\Eloquent\Relations\MorphMany<App\Address, App\User>', $query);
});

// currently a bug in PHPStan: https://github.com/phpstan/phpstan/issues/11742
$user::has($user->accounts(), callback: function ($query) {
Expand Down

0 comments on commit 6ed3161

Please sign in to comment.