diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 208df4952f..30016c6c99 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -233,6 +233,10 @@ jobs: cd e2e/bug-11857 composer install ../../bin/phpstan + - script: | + cd e2e/bug-12585 + composer install + ../../bin/phpstan - script: | cd e2e/result-cache-meta-extension composer install diff --git a/e2e/bug-12585/.gitignore b/e2e/bug-12585/.gitignore new file mode 100644 index 0000000000..61ead86667 --- /dev/null +++ b/e2e/bug-12585/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/bug-12585/composer.json b/e2e/bug-12585/composer.json new file mode 100644 index 0000000000..a072011fe8 --- /dev/null +++ b/e2e/bug-12585/composer.json @@ -0,0 +1,5 @@ +{ + "autoload-dev": { + "classmap": ["src/"] + } +} diff --git a/e2e/bug-12585/composer.lock b/e2e/bug-12585/composer.lock new file mode 100644 index 0000000000..b383d88ac5 --- /dev/null +++ b/e2e/bug-12585/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d751713988987e9331980363e24189ce", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/e2e/bug-12585/phpstan.neon b/e2e/bug-12585/phpstan.neon new file mode 100644 index 0000000000..3d26591c82 --- /dev/null +++ b/e2e/bug-12585/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: 8 + paths: + - src + +services: + - + class: Bug12585\EloquentBuilderRelationParameterExtension + tags: + - phpstan.methodParameterClosureTypeExtension diff --git a/e2e/bug-12585/src/extension.php b/e2e/bug-12585/src/extension.php new file mode 100644 index 0000000000..7b4eb7e6c5 --- /dev/null +++ b/e2e/bug-12585/src/extension.php @@ -0,0 +1,251 @@ + */ + private array $methods = ['whereHas', 'withWhereHas']; + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + if (! $methodReflection->getDeclaringClass()->is(Builder::class)) { + return false; + } + + return in_array($methodReflection->getName(), $this->methods, strict: true); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): Type|null + { + $method = $methodReflection->getName(); + $relations = $this->getRelationsFromMethodCall($methodCall, $scope); + $models = $this->getModelsFromRelations($relations); + + if (count($models) === 0) { + return null; + } + + $type = $this->getBuilderTypeForModels($models); + + if ($method === 'withWhereHas') { + $type = TypeCombinator::union($type, ...$relations); + } + + return new ClosureType([new ClosureQueryParameter('query', $type)], new MixedType()); + } + + /** + * @param array $relations + * @return array + */ + private function getModelsFromRelations(array $relations): array + { + $models = []; + + foreach ($relations as $relation) { + $classNames = $relation->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames(); + foreach ($classNames as $className) { + $models[] = $className; + } + } + + return $models; + } + + /** @return array */ + private function getRelationsFromMethodCall(MethodCall $methodCall, Scope $scope): array + { + $relationType = null; + + foreach ($methodCall->args as $arg) { + if ($arg instanceof VariadicPlaceholder) { + continue; + } + + if ($arg->name === null || $arg->name->toString() === 'relation') { + $relationType = $scope->getType($arg->value); + break; + } + } + + if ($relationType === null) { + return []; + } + + $calledOnModels = $scope->getType($methodCall->var) + ->getTemplateType(Builder::class, 'TModel') + ->getObjectClassNames(); + + $values = array_map(fn ($type) => $type->getValue(), $relationType->getConstantStrings()); + $relationTypes = [$relationType]; + + foreach ($values as $relation) { + $relationTypes = array_merge( + $relationTypes, + $this->getRelationTypeFromString($calledOnModels, explode('.', $relation), $scope) + ); + } + + return array_values(array_filter( + $relationTypes, + static fn ($r) => (new ObjectType(Relation::class))->isSuperTypeOf($r)->yes() + )); + } + + /** + * @param list $calledOnModels + * @param list $relationParts + * @return list + */ + private function getRelationTypeFromString(array $calledOnModels, array $relationParts, Scope $scope): array + { + $relations = []; + + while ($relationName = array_shift($relationParts)) { + $relations = []; + $relatedModels = []; + + foreach ($calledOnModels as $model) { + $modelType = new ObjectType($model); + + if (! $modelType->hasMethod($relationName)->yes()) { + continue; + } + + $relationType = $modelType->getMethod($relationName, $scope)->getVariants()[0]->getReturnType(); + + if (! (new ObjectType(Relation::class))->isSuperTypeOf($relationType)->yes()) { + continue; + } + + $relations[] = $relationType; + + array_push($relatedModels, ...$relationType->getTemplateType(Relation::class, 'TRelatedModel')->getObjectClassNames()); + } + + $calledOnModels = $relatedModels; + } + + return $relations; + } + + private function determineBuilderName(string $modelClassName): string + { + $method = $this->reflectionProvider->getClass($modelClassName)->getNativeMethod('query'); + + $returnType = $method->getVariants()[0]->getReturnType(); + + if (in_array(Builder::class, $returnType->getReferencedClasses(), true)) { + return Builder::class; + } + + $classNames = $returnType->getObjectClassNames(); + + if (count($classNames) === 1) { + return $classNames[0]; + } + + return $returnType->describe(VerbosityLevel::value()); + } + + /** + * @param array|string|TypeWithClassName $models + * @return ($models is array ? Type : ObjectType) + */ + private function getBuilderTypeForModels(array|string|TypeWithClassName $models): Type + { + $models = is_array($models) ? $models : [$models]; + $models = array_unique($models, SORT_REGULAR); + + $mappedModels = []; + foreach ($models as $model) { + if (is_string($model)) { + $mappedModels[$model] = new ObjectType($model); + } else { + $mappedModels[$model->getClassName()] = $model; + } + } + + $groupedByBuilder = []; + foreach ($mappedModels as $class => $type) { + $builderName = $this->determineBuilderName($class); + $groupedByBuilder[$builderName][] = $type; + } + + $builderTypes = []; + foreach ($groupedByBuilder as $builder => $models) { + $builderReflection = $this->reflectionProvider->getClass($builder); + + $builderTypes[] = $builderReflection->isGeneric() + ? new GenericObjectType($builder, [TypeCombinator::union(...$models)]) + : new ObjectType($builder); + } + + return TypeCombinator::union(...$builderTypes); + } +} + +final class ClosureQueryParameter implements ParameterReflection +{ + public function __construct(private string $name, private Type $type) + { + } + + public function getName(): string + { + return $this->name; + } + + public function isOptional(): bool + { + return false; + } + + public function getType(): Type + { + return $this->type; + } + + public function passedByReference(): PassedByReference + { + return PassedByReference::createNo(); + } + + public function isVariadic(): bool + { + return false; + } + + public function getDefaultValue(): Type|null + { + return null; + } +} diff --git a/e2e/bug-12585/src/test.php b/e2e/bug-12585/src/test.php new file mode 100644 index 0000000000..b218f17ce4 --- /dev/null +++ b/e2e/bug-12585/src/test.php @@ -0,0 +1,151 @@ + $related + * @return BelongsTo + */ + public function belongsTo(string $related): BelongsTo + { + return new BelongsTo(); // @phpstan-ignore return.type + } + + /** + * @template T of Model + * @param class-string $related + * @return HasMany + */ + public function hasMany(string $related): HasMany + { + return new HasMany(); // @phpstan-ignore return.type + } + + /** @return Builder */ + public static function query(): Builder + { + return new Builder(new static()); + } +} + +/** @template TModel of Model */ +class Builder +{ + /** @param TModel $model */ + final public function __construct(protected Model $model) + { + } + + /** + * @param (\Closure(static): mixed)|string $column + * @return $this + */ + public function where(Closure|string $column, mixed $value = null) + { + return $this; + } + + /** + * @template TRelatedModel of Model + * + * @param Relation|string $relation + * @param (\Closure(Builder): mixed)|null $callback + * @return $this + */ + public function whereHas($relation, ?Closure $callback = null) + { + return $this; + } + + /** + * @param string $relation + * @param (\Closure(Builder<*>|Relation<*, *>): mixed)|null $callback + * @return $this + */ + public function withWhereHas($relation, ?Closure $callback = null) + { + return $this; + } +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @mixin Builder + */ +abstract class Relation +{ +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends Relation + */ +class BelongsTo extends Relation +{ +} + +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends Relation + */ +class HasMany extends Relation +{ +} + +final class User extends Model +{ + /** @return HasMany */ + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } +} + +final class Post extends Model +{ + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function query(): PostBuilder + { + return new PostBuilder(new self()); + } +} + +/** @extends Builder */ +class PostBuilder extends Builder +{ +} + +function test(): void +{ + User::query()->whereHas('posts', function ($query) { + assertType('Bug12585\PostBuilder', $query); + }); + User::query()->whereHas('posts', fn (Builder $q) => $q->where('name', 'test')); + User::query()->whereHas('posts', fn (PostBuilder $q) => $q->where('name', 'test')); + + Post::query()->whereHas('user', fn ($q) => $q->where('name', 'test')); + Post::query()->withWhereHas('user', function ($query) { + assertType('Bug12585\BelongsTo|Bug12585\Builder', $query); + }); + Post::query()->withWhereHas('user', fn (Builder|Relation $q) => $q->where('name', 'test')); + Post::query()->withWhereHas('user', fn (Builder|BelongsTo $q) => $q->where('name', 'test')); +}