From 2a720317c1f131ec9a9874dd590d84b67310d0f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:33:58 +0000 Subject: [PATCH 1/4] feat(database): add Eloquent forwarding PHPStan extension Ship a database-package PHPStan extension that preserves the receiving Eloquent builder or relation across fluent methods forwarded to the underlying query builder. Keep passthru and non-decorated methods on their real return types, including custom builder generics and relation-specific behavior. Register the extension through Composer metadata and the framework analysis profiles, document automatic and manual activation, and remove the global Relation forwarding suppression that it replaces. Correct the affected collection and after-query annotations, including pivot intersections, and add focused type fixtures for standard builders, custom builders, relation forwarding, passthru methods, and collection results. --- phpstan.neon.dist | 8 +- phpstan.types.neon.dist | 3 + src/database/composer.json | 8 +- src/database/extension.neon | 5 + src/database/src/Eloquent/Builder.php | 7 + .../src/Eloquent/Relations/BelongsToMany.php | 5 + .../Relations/HasOneOrManyThrough.php | 5 + .../src/Eloquent/Relations/Relation.php | 2 +- .../ForwardedFluentMethodExtension.php | 267 ++++++++++++++++++ .../ForwardedFluentMethodReflection.php | 154 ++++++++++ src/docs/database.md | 17 ++ types/Database/Eloquent/Builder.php | 6 + types/Database/Eloquent/Relations.php | 11 +- 13 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 src/database/extension.neon create mode 100644 src/database/src/PHPStan/ForwardedFluentMethodExtension.php create mode 100644 src/database/src/PHPStan/ForwardedFluentMethodReflection.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 42128e2162..4a1cca4c87 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,9 @@ # should be fixed at the source, or suppressed inline for genuine static analysis # limitations. +includes: + - src/database/extension.neon + parameters: tmpDir: .cache/phpstan level: 5 @@ -85,11 +88,6 @@ parameters: - identifier: method.childParameterType path: src/database/* - # Relation @mixin forwarding - Relation methods returning $this forward to Builder methods - # PHPStan sees Builder return type but the actual return is $this (the Relation) - - message: '#should return \$this\(Hypervel\\Database\\Eloquent\\Relations\\.+\) but returns Hypervel\\Database\\(Query|Eloquent)\\Builder#' - path: src/database/* - # BelongsToMany pivot intersection type - PHPDoc uses object{pivot: ...}&TRelatedModel # to document that models get a pivot property attached, but PHPStan can't track dynamic attachment - message: '#object\{pivot:#' diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index bc29860cc4..6fb17ddc6b 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -1,3 +1,6 @@ +includes: + - src/database/extension.neon + parameters: level: max tmpDir: .cache/phpstan-types diff --git a/src/database/composer.json b/src/database/composer.json index da98265033..41d8c70166 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -62,7 +62,8 @@ "hypervel/support": "^0.4" }, "require-dev": { - "fakerphp/faker": "^1.24" + "fakerphp/faker": "^1.24", + "phpstan/phpstan": "^2.0" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." @@ -76,6 +77,11 @@ "Hypervel\\Database\\DatabaseServiceProvider" ] }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { "dev-main": "0.4-dev" } diff --git a/src/database/extension.neon b/src/database/extension.neon new file mode 100644 index 0000000000..388f13a1e9 --- /dev/null +++ b/src/database/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Hypervel\Database\PHPStan\ForwardedFluentMethodExtension + tags: + - phpstan.broker.methodsClassReflectionExtension diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 60654583fb..439a00c8f4 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -934,6 +934,13 @@ public function afterQuery(Closure $callback): static /** * Invoke the "after query" modification callbacks. + * + * A callback that replaces the collection type owns the resulting type change. + * + * @template TCollection of BaseCollection + * + * @param TCollection $result + * @return TCollection */ public function applyAfterQueryCallbacks(BaseCollection $result): BaseCollection { diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 4d1232d81f..692a7a5373 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -852,6 +852,11 @@ public function getResults() : $this->related->newCollection(); } + /** + * Execute the query as a "select" statement. + * + * @return \Hypervel\Database\Eloquent\Collection + */ public function get(array $columns = ['*']): BaseCollection { // First we'll add the proper select columns onto the query so it is run with diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index b6d69c7596..10c383b3ba 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -417,6 +417,11 @@ public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closur return $callback(); } + /** + * Execute the query as a "select" statement. + * + * @return \Hypervel\Database\Eloquent\Collection + */ public function get(array $columns = ['*']): BaseCollection { $builder = $this->prepareQueryBuilder($columns); diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 7313730db6..5d28c16d69 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -202,7 +202,7 @@ public function sole(array|string $columns = ['*']): Model /** * Execute the query as a "select" statement. * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Database\Eloquent\Collection */ public function get(array $columns = ['*']): BaseCollection { diff --git a/src/database/src/PHPStan/ForwardedFluentMethodExtension.php b/src/database/src/PHPStan/ForwardedFluentMethodExtension.php new file mode 100644 index 0000000000..71a74b98d5 --- /dev/null +++ b/src/database/src/PHPStan/ForwardedFluentMethodExtension.php @@ -0,0 +1,267 @@ + */ + private const array RELATION_NON_DECORATED_METHODS = [ + 'applyscopes', + 'clone', + ]; + + /** @var list */ + private const array RELATION_DOCUMENTED_FLUENT_METHODS = [ + 'wherecan', + 'withcan', + ]; + + /** @var list */ + private readonly array $passthru; + + /** @var array */ + private array $methods = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a forwarded fluent method extension. + */ + public function __construct(private readonly ReflectionProvider $reflectionProvider) + { + /** @var list $passthru */ + $passthru = $this->reflectionProvider + ->getClass(EloquentBuilder::class) + ->getNativeReflection() + ->getDefaultProperties()['passthru']; + + $this->passthru = $passthru; + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the class exposes the forwarded fluent method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the forwarded fluent method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Forwarded fluent method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a forwarded fluent method. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $isEloquentHost = $this->isClassOrSubclassOf($classReflection, EloquentBuilder::class); + $isRelationHost = ! $isEloquentHost + && $this->isClassOrSubclassOf($classReflection, Relation::class); + + if (! $isEloquentHost && ! $isRelationHost) { + return null; + } + + $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $method = null; + + if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + if ($isEloquentHost) { + $method = $this->resolveEloquentBuilderMethod($classReflection, $methodName); + } else { + $method = $this->resolveRelationMethod($classReflection, $methodName); + } + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Resolve a fluent method forwarded by an Eloquent builder. + */ + private function resolveEloquentBuilderMethod( + ClassReflection $classReflection, + string $methodName, + ): ?MethodReflection { + if (in_array(strtolower($methodName), $this->passthru, strict: true)) { + return null; + } + + $queryBuilder = $this->reflectionProvider->getClass(QueryBuilder::class); + + if (! $queryBuilder->hasNativeMethod($methodName)) { + return null; + } + + if (! $this->returnsStatic($queryBuilder->getNativeMethod($methodName))) { + return null; + } + + $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); + $method = $this->queryBuilderType($modelType)->getMethod($methodName, $this->scope); + + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + /** + * Resolve a fluent method forwarded by a relation. + */ + private function resolveRelationMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $normalizedMethodName = strtolower($methodName); + $relatedType = $this->templateType($classReflection, Relation::class, 'TRelatedModel'); + $eloquentBuilder = $this->reflectionProvider->getClass(EloquentBuilder::class); + + if ($eloquentBuilder->hasNativeMethod($methodName)) { + if (in_array($normalizedMethodName, self::RELATION_NON_DECORATED_METHODS, strict: true) + || ! $this->returnsStatic($eloquentBuilder->getNativeMethod($methodName))) { + return null; + } + + $method = $this->eloquentBuilderType($relatedType)->getMethod($methodName, $this->scope); + + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + $queryBuilder = $this->reflectionProvider->getClass(QueryBuilder::class); + + if ($queryBuilder->hasNativeMethod($methodName)) { + if (in_array(strtolower($methodName), $this->passthru, strict: true) + || ! $this->returnsStatic($queryBuilder->getNativeMethod($methodName))) { + return null; + } + + $method = $this->queryBuilderType($relatedType)->getMethod($methodName, $this->scope); + + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + if (! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { + return null; + } + + $method = $this->eloquentBuilderType($relatedType)->getMethod($methodName, $this->scope); + + return new ForwardedFluentMethodReflection($classReflection, $method); + } + + /** + * Determine whether a class owns a native or documented method. + */ + private function hasNativeOrDocumentedMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($classReflection->hasNativeMethod($methodName)) { + return true; + } + + foreach (array_keys($classReflection->getMethodTags()) as $documentedMethod) { + if (strcasecmp($documentedMethod, $methodName) === 0) { + return true; + } + } + + return false; + } + + /** + * Determine whether the class is or extends the target class. + * + * @param class-string $targetClass + */ + private function isClassOrSubclassOf(ClassReflection $classReflection, string $targetClass): bool + { + return $classReflection->getName() === $targetClass + || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass($targetClass)); + } + + /** + * Return an active template type from a class ancestor. + * + * @param class-string $ancestorClass + */ + private function templateType( + ClassReflection $classReflection, + string $ancestorClass, + string $templateName, + ): Type { + $type = $classReflection + ->getAncestorWithClassName($ancestorClass) + ?->getActiveTemplateTypeMap() + ->getType($templateName); + + return $type ?? throw new LogicException(sprintf( + 'Template type [%s] is not available for [%s].', + $templateName, + $classReflection->getName(), + )); + } + + /** + * Create a generic Eloquent builder type. + */ + private function eloquentBuilderType(Type $modelType): GenericObjectType + { + return new GenericObjectType(EloquentBuilder::class, [$modelType]); + } + + /** + * Create a generic query builder type. + */ + private function queryBuilderType(Type $modelType): GenericObjectType + { + return new GenericObjectType(QueryBuilder::class, [new IntegerType, $modelType]); + } + + /** + * Determine whether every method variant returns static. + */ + private function returnsStatic(MethodReflection $method): bool + { + foreach ($method->getVariants() as $variant) { + if (! $variant->getReturnType() instanceof StaticType) { + return false; + } + } + + return true; + } +} diff --git a/src/database/src/PHPStan/ForwardedFluentMethodReflection.php b/src/database/src/PHPStan/ForwardedFluentMethodReflection.php new file mode 100644 index 0000000000..835b3ed7fc --- /dev/null +++ b/src/database/src/PHPStan/ForwardedFluentMethodReflection.php @@ -0,0 +1,154 @@ +forwardedMethod->getName(); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return $this->forwardedMethod->isStatic(); + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return $this->forwardedMethod->isPrivate(); + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return $this->forwardedMethod->isPublic(); + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->forwardedMethod->getDocComment(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the callable variants with the receiving object as their result. + * + * @return list + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant->getParameters(), + $variant->isVariadic(), + new ThisType($this->declaringClass), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->forwardedMethod->getVariants(), + ); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->forwardedMethod->isDeprecated(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->forwardedMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->forwardedMethod->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->forwardedMethod->isInternal(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return $this->forwardedMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->forwardedMethod->hasSideEffects(); + } +} diff --git a/src/docs/database.md b/src/docs/database.md index 1c82ca4941..0a2c66d956 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -6,6 +6,7 @@ - [Read and Write Connections](#read-and-write-connections) - [Connection Pooling](#connection-pooling) - [Configuring Database Session State](#configuring-database-session-state) + - [Static Analysis](#static-analysis) - [Running SQL Queries](#running-queries) - [Using Multiple Database Connections](#using-multiple-database-connections) - [Listening for Query Events](#listening-for-query-events) @@ -277,6 +278,22 @@ The `getPdo` and `getReadPdo` methods configure the PDO before returning it. The > [!WARNING] > If a setting must persist across queries and your application connects through a database proxy or connection pooler, use a mode that keeps the same database session. For example, PgBouncer must use session pooling. Transaction and statement pooling may send consecutive queries to different database sessions, so the setting may be missing from the next query. Hypervel cannot detect the pooler's mode for you. Direct database connections are not affected. + +### Static Analysis + +The Hypervel database package includes a PHPStan extension that understands fluent methods forwarded by Eloquent builders and relationships. If your application uses `phpstan/extension-installer`, the extension is loaded automatically: + +```shell +composer require --dev phpstan/extension-installer +``` + +Without the extension installer, add the package extension to your `phpstan.neon` file: + +```neon +includes: + - vendor/hypervel/database/extension.neon +``` + ## Running SQL Queries diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 751d1c8a70..5657957669 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -30,6 +30,11 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->withCan([Ability::Edit, 'delete'], $user)); assertType('Hypervel\Database\Eloquent\Builder', $query->useWritePdo()); assertType('Hypervel\Types\Builder\User|null', $query->orderBy('id')->first()); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereIn('id', [1])->with('relation')); + assertType('Hypervel\Database\Eloquent\Builder', $query->orderBy('id')->with('relation')); + assertType('Hypervel\Database\Eloquent\Builder', $query->limit(1)->with('relation')); + assertType('Hypervel\Database\Query\Builder', $query->dump()); + assertType('Hypervel\Database\Query\Builder', $query->dumpRawSql()); assertType('stdClass|null', $query->toBase()->first()); assertType('stdClass|null', $query->getQuery()->first()); assertType('Hypervel\Database\Eloquent\Builder', $query->with('relation')); @@ -190,6 +195,7 @@ function test( }); assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()); + assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()->whereIn('id', [1])->with('comments')); assertType('Hypervel\Types\Builder\CommonBuilder', Post::query()->whereCan('edit')); assertType('Hypervel\Types\Builder\CommonBuilder', Post::on()); assertType('Hypervel\Types\Builder\CommonBuilder', Post::onWriteConnection()); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index ba41f3f508..3f7dce4424 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -25,7 +25,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void { assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->address()); assertType('Hypervel\Types\Relations\Address|null', $user->address()->getResults()); - assertType('Hypervel\Support\Collection', $user->address()->get()); + assertType('Hypervel\Database\Eloquent\Collection', $user->address()->get()); assertType('Hypervel\Types\Relations\Address', $user->address()->make()); assertType('Hypervel\Types\Relations\Address', $user->address()->create()); assertType('Hypervel\Database\Eloquent\Relations\HasOne', $child->address()); @@ -38,6 +38,13 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->getResults()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->fetchUsing(PDO::FETCH_ASSOC)->get()); assertType('Hypervel\Types\Relations\Post|null', $user->posts()->useWritePdo()->first()); + assertType('Hypervel\Database\Query\Builder', $user->posts()->dump()); + assertType('Hypervel\Database\Query\Builder', $user->posts()->dumpRawSql()); + assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->clone()); + assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->applyScopes()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereIn('id', [1])); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->whereIn('id', [1])->get()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereCan('edit')); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->makeMany([])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createMany([])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->createManyQuietly([])); @@ -49,6 +56,8 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType("Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany", $user->roles()); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->get()); + assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->whereIn('id', [1])->get()); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->find([1])); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->findMany([1, 2, 3])); assertType('Hypervel\Database\Eloquent\Collection', $user->roles()->findOrNew([1])); From 4637055d71e004fc1fe49478e428d9e581a27ed9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:57:46 +0000 Subject: [PATCH 2/4] fix(database): validate legacy scope method discovery Cache legacy Eloquent scope discovery by model class while keeping the cache bounded to declared methods for long-lived workers. Match PHP's case-insensitive method lookup without treating ordinary scope-prefixed methods such as scoped() as query scopes, and reject private methods from the public scope surface. Flush the new cache with the existing Eloquent scope caches and cover attributed, legacy, visibility, case, cache-reset, and real builder-dispatch behavior. --- src/database/src/Eloquent/Model.php | 49 +++++++++++++++-- ...tabaseEloquentAttributedScopeCacheTest.php | 53 +++++++++++++++++++ .../DatabaseEloquentLocalScopesTest.php | 24 +++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 4f4eafe163..21ca8d7797 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -297,6 +297,13 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ protected static array $scopeMethodAttributes = []; + /** + * Cache of whether methods are callable legacy local scopes. + * + * @var array + */ + protected static array $legacyScopeMethods = []; + /** * Cache of soft deletable models. * @@ -547,7 +554,7 @@ public static function clearBootedModels(): void static::$classDeclaredAttributes = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; - static::$scopeMethodAttributes = []; + self::flushScopeCaches(); static::$globalScopes = []; } @@ -1975,7 +1982,7 @@ public function newPivot(self $parent, array $attributes, string $table, bool $e */ public function hasNamedScope(string $scope): bool { - return method_exists($this, 'scope' . ucfirst($scope)) + return static::isLegacyScopeMethod($scope) || static::isScopeMethodWithAttribute($scope); } @@ -2016,6 +2023,33 @@ protected static function isScopeMethodWithAttribute(string $method): bool && $reflection->getAttributes(LocalScope::class) !== []; } + /** + * Determine if the given method is a callable legacy local scope. + */ + protected static function isLegacyScopeMethod(string $scope): bool + { + $key = static::class . "\0" . strtolower($scope); + + if (array_key_exists($key, static::$legacyScopeMethods)) { + return static::$legacyScopeMethods[$key]; + } + + $method = 'scope' . ucfirst($scope); + + // Query-derived dynamic scope names can be arbitrary. Do not retain misses + // for nonexistent methods in a long-running worker. + if (! method_exists(static::class, $method)) { + return false; + } + + $reflection = new ReflectionMethod(static::class, $method); + $declaredName = $reflection->getName(); + + return static::$legacyScopeMethods[$key] = strlen($declaredName) > 5 + && ! $reflection->isPrivate() + && ! ctype_lower($declaredName[5]); + } + /** * Convert the model instance to an array. */ @@ -2652,6 +2686,15 @@ public static function flushGuardableColumns(): void static::$guardableColumns = []; } + /** + * Flush local scope caches. + */ + private static function flushScopeCaches(): void + { + static::$scopeMethodAttributes = []; + static::$legacyScopeMethods = []; + } + /** * Flush all static state. */ @@ -2668,7 +2711,7 @@ public static function flushState(): void static::$classDeclaredAttributes = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; - static::$scopeMethodAttributes = []; + self::flushScopeCaches(); static::$modelsShouldPreventLazyLoading = false; static::$modelsShouldAutomaticallyEagerLoadRelationships = false; static::$lazyLoadingViolationCallback = null; diff --git a/tests/Database/DatabaseEloquentAttributedScopeCacheTest.php b/tests/Database/DatabaseEloquentAttributedScopeCacheTest.php index ab7e4dd145..2213c5feb7 100644 --- a/tests/Database/DatabaseEloquentAttributedScopeCacheTest.php +++ b/tests/Database/DatabaseEloquentAttributedScopeCacheTest.php @@ -61,20 +61,55 @@ public function testMissingMethodNamesAreNotCached(): void $this->assertSame(0, AttributedScopeCacheModel::cachedScopeMethodCount()); } + public function testLegacyScopesRespectVisibilityAndShareCaseInsensitiveCacheEntries(): void + { + $model = new AttributedScopeCacheModel; + + $this->assertTrue($model->hasNamedScope('visible')); + $this->assertTrue($model->hasNamedScope('VISIBLE')); + $this->assertFalse($model->hasNamedScope('hidden')); + $this->assertSame(2, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); + } + + public function testInheritedLegacyScopesRespectVisibility(): void + { + $model = new SecondAttributedScopeCacheModel; + + $this->assertTrue($model->hasNamedScope('visible')); + $this->assertFalse($model->hasNamedScope('hidden')); + } + + public function testScopePrefixedMethodsAreNotTreatedAsLegacyScopes(): void + { + $model = new AttributedScopeCacheModel; + + $this->assertFalse($model->hasNamedScope('d')); + $this->assertSame(1, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); + + $this->assertFalse($model->hasNamedScope('d')); + $this->assertSame(1, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); + } + public function testModelStaticStateResetsClearTheCache(): void { $model = new AttributedScopeCacheModel; $this->assertTrue($model->hasNamedScope('active')); + $this->assertTrue($model->hasNamedScope('visible')); $this->assertSame(1, AttributedScopeCacheModel::cachedScopeMethodCount()); + $this->assertSame(1, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); Model::clearBootedModels(); $this->assertSame(0, AttributedScopeCacheModel::cachedScopeMethodCount()); + $this->assertSame(0, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); $this->assertTrue($model->hasNamedScope('active')); + $this->assertTrue($model->hasNamedScope('visible')); $this->assertSame(1, AttributedScopeCacheModel::cachedScopeMethodCount()); + $this->assertSame(1, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); Model::flushState(); $this->assertSame(0, AttributedScopeCacheModel::cachedScopeMethodCount()); + $this->assertSame(0, AttributedScopeCacheModel::cachedLegacyScopeMethodCount()); } } @@ -89,6 +124,19 @@ protected function ordinary(): void { } + public static function scoped(array $attributes): string + { + return 'scoped'; + } + + protected function scopeVisible(): void + { + } + + private function scopeHidden(): void + { + } + #[Scope] private function privateScope(Builder $builder): void { @@ -99,6 +147,11 @@ public static function cachedScopeMethodCount(): int return count(static::$scopeMethodAttributes); } + public static function cachedLegacyScopeMethodCount(): int + { + return count(static::$legacyScopeMethods); + } + public static function seedScopeMethodCache(string $method, bool $isScope): void { static::$scopeMethodAttributes[static::class . "\0" . strtolower($method)] = $isScope; diff --git a/tests/Database/DatabaseEloquentLocalScopesTest.php b/tests/Database/DatabaseEloquentLocalScopesTest.php index 93bdfba378..95280a0ea4 100644 --- a/tests/Database/DatabaseEloquentLocalScopesTest.php +++ b/tests/Database/DatabaseEloquentLocalScopesTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentLocalScopesTest; +use BadMethodCallException; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Eloquent\Model; use Hypervel\Testbench\TestCase; @@ -57,6 +58,20 @@ public function testLocalScopesCanChained() $this->assertEquals([true, 'foo'], $query->getBindings()); } + public function testPrivateLegacyScopeIsNotDispatched(): void + { + $this->expectException(BadMethodCallException::class); + + (new ScopedModel)->newQuery()->hidden(); + } + + public function testScopePrefixedMethodIsNotDispatched(): void + { + $this->expectException(BadMethodCallException::class); + + (new ScopedModel)->newQuery()->d(); + } + public function testLocalScopeNestingDoesntDoubleFirstWhereClauseNegation() { $model = new ScopedModel; @@ -97,4 +112,13 @@ public function scopeType($query, $type) { $query->where('type', $type); } + + public static function scoped(array $attributes): string + { + return 'scoped'; + } + + private function scopeHidden($query): void + { + } } From 8e2f3a25a068e4d2fbafc73465219eb4b29dea73 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:58:13 +0000 Subject: [PATCH 3/4] feat(database): analyze Eloquent model forwarding and scopes Teach PHPStan about query methods forwarded through Eloquent models and about attributed and legacy named scopes invoked from models, builders, and relations. Preserve concrete model and custom-builder generics across inherited scope parameters and returns, while keeping native methods and active builder methods authoritative during dispatch. Add focused type fixtures for plain and custom builders, inherited models, relations, scalar and model results, scope collisions, and receiver-preserving returns. Tighten the Permission and Telescope annotations exposed by the new analysis, remove obsolete local suppressions, and document the shipped Eloquent analysis support for package consumers. --- src/database/extension.neon | 10 + .../PHPStan/ForwardedModelMethodExtension.php | 146 +++++++++ .../ForwardedModelMethodReflection.php | 141 +++++++++ .../src/PHPStan/ModelScopeMethodResolver.php | 63 ++++ .../PHPStan/ModelScopeParameterReflection.php | 77 +++++ .../src/PHPStan/ModelScopeTypeResolver.php | 31 ++ .../src/PHPStan/NamedScopeMethodExtension.php | 227 ++++++++++++++ .../PHPStan/NamedScopeMethodReflection.php | 193 ++++++++++++ src/docs/database.md | 2 +- src/permission/src/Traits/HasPermissions.php | 4 + src/permission/src/Traits/HasRoles.php | 8 + src/telescope/src/IncomingEntry.php | 6 +- .../src/Storage/DatabaseEntriesRepository.php | 14 +- src/telescope/src/Storage/EntryModel.php | 15 + types/Database/Eloquent/ModelForwarding.php | 61 ++++ types/Database/Eloquent/NamedScopes.php | 280 ++++++++++++++++++ 16 files changed, 1270 insertions(+), 8 deletions(-) create mode 100644 src/database/src/PHPStan/ForwardedModelMethodExtension.php create mode 100644 src/database/src/PHPStan/ForwardedModelMethodReflection.php create mode 100644 src/database/src/PHPStan/ModelScopeMethodResolver.php create mode 100644 src/database/src/PHPStan/ModelScopeParameterReflection.php create mode 100644 src/database/src/PHPStan/ModelScopeTypeResolver.php create mode 100644 src/database/src/PHPStan/NamedScopeMethodExtension.php create mode 100644 src/database/src/PHPStan/NamedScopeMethodReflection.php create mode 100644 types/Database/Eloquent/ModelForwarding.php create mode 100644 types/Database/Eloquent/NamedScopes.php diff --git a/src/database/extension.neon b/src/database/extension.neon index 388f13a1e9..0905e50497 100644 --- a/src/database/extension.neon +++ b/src/database/extension.neon @@ -1,5 +1,15 @@ services: + - + class: Hypervel\Database\PHPStan\ModelScopeMethodResolver - class: Hypervel\Database\PHPStan\ForwardedFluentMethodExtension tags: - phpstan.broker.methodsClassReflectionExtension + - + class: Hypervel\Database\PHPStan\ForwardedModelMethodExtension + tags: + - phpstan.broker.methodsClassReflectionExtension + - + class: Hypervel\Database\PHPStan\NamedScopeMethodExtension + tags: + - phpstan.broker.methodsClassReflectionExtension diff --git a/src/database/src/PHPStan/ForwardedModelMethodExtension.php b/src/database/src/PHPStan/ForwardedModelMethodExtension.php new file mode 100644 index 0000000000..94cf0a7077 --- /dev/null +++ b/src/database/src/PHPStan/ForwardedModelMethodExtension.php @@ -0,0 +1,146 @@ + */ + private array $methods = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a forwarded model method extension. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly ModelScopeMethodResolver $scopeMethods, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the model exposes the forwarded builder method. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the forwarded builder method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Forwarded model method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a builder method forwarded through a model. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + if (! $this->isModel($classReflection)) { + return null; + } + + $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $method = null; + + if (! $this->hasNativeOrDocumentedMethod($classReflection, $methodName)) { + $builderType = $this->activeBuilderType($classReflection); + $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); + + if (($scopeMethod === null || $this->hasNativeMethod($builderType, $methodName)) + && $builderType->hasMethod($methodName)->yes()) { + $method = new ForwardedModelMethodReflection( + $classReflection, + $builderType->getMethod($methodName, $this->scope), + ); + } + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Return the active query builder type for a model. + * + * Preserve the raw static type so PHPStan can rebind it to the called-on model. + */ + private function activeBuilderType(ClassReflection $classReflection): Type + { + $variants = $classReflection->getMethod('query', $this->scope)->getVariants(); + + return $variants[0]->getReturnType(); + } + + /** + * Determine whether an active builder owns a native method. + */ + private function hasNativeMethod(Type $builderType, string $methodName): bool + { + foreach ($builderType->getObjectClassReflections() as $builderClass) { + if ($builderClass->hasNativeMethod($methodName)) { + return true; + } + } + + return false; + } + + /** + * Determine whether a class owns a native or documented method. + */ + private function hasNativeOrDocumentedMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($classReflection->hasNativeMethod($methodName)) { + return true; + } + + foreach (array_keys($classReflection->getMethodTags()) as $documentedMethod) { + if (strcasecmp($documentedMethod, $methodName) === 0) { + return true; + } + } + + return false; + } + + /** + * Determine whether the class is an Eloquent model. + */ + private function isModel(ClassReflection $classReflection): bool + { + return $classReflection->getName() === Model::class + || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass(Model::class)); + } +} diff --git a/src/database/src/PHPStan/ForwardedModelMethodReflection.php b/src/database/src/PHPStan/ForwardedModelMethodReflection.php new file mode 100644 index 0000000000..e124e626d8 --- /dev/null +++ b/src/database/src/PHPStan/ForwardedModelMethodReflection.php @@ -0,0 +1,141 @@ +forwardedMethod->getName(); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return true; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->forwardedMethod->getDocComment(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the forwarded builder method variants. + * + * @return list + */ + public function getVariants(): array + { + return $this->forwardedMethod->getVariants(); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->forwardedMethod->isDeprecated(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->forwardedMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->forwardedMethod->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->forwardedMethod->isInternal(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return $this->forwardedMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->forwardedMethod->hasSideEffects(); + } +} diff --git a/src/database/src/PHPStan/ModelScopeMethodResolver.php b/src/database/src/PHPStan/ModelScopeMethodResolver.php new file mode 100644 index 0000000000..bf3d8d3cb2 --- /dev/null +++ b/src/database/src/PHPStan/ModelScopeMethodResolver.php @@ -0,0 +1,63 @@ +> */ + private array $methods = []; + + /** + * Resolve a model scope by its exposed name. + */ + public function resolve(ClassReflection $modelClass, string $scope): ?MethodReflection + { + return $this->methods($modelClass)[strtolower($scope)] ?? null; + } + + /** + * Return the named scopes exposed by a model. + * + * @return array + */ + private function methods(ClassReflection $modelClass): array + { + $cacheKey = $modelClass->getCacheKey(); + + if (isset($this->methods[$cacheKey])) { + return $this->methods[$cacheKey]; + } + + $legacyScopes = []; + $attributedScopes = []; + + foreach ($modelClass->getNativeReflection()->getMethods() as $method) { + if ($method->isPrivate()) { + continue; + } + + $methodName = $method->getName(); + + if (strncasecmp($methodName, 'scope', 5) === 0 + && strlen($methodName) > 5 + && ! ctype_lower($methodName[5])) { + $legacyScopes[strtolower(substr($methodName, 5))] = $modelClass->getNativeMethod($methodName); + } + + if ($method->getAttributes(Scope::class) !== []) { + $attributedScopes[strtolower($methodName)] = $modelClass->getNativeMethod($methodName); + } + } + + return $this->methods[$cacheKey] = array_replace($legacyScopes, $attributedScopes); + } +} diff --git a/src/database/src/PHPStan/ModelScopeParameterReflection.php b/src/database/src/PHPStan/ModelScopeParameterReflection.php new file mode 100644 index 0000000000..fdda0520c2 --- /dev/null +++ b/src/database/src/PHPStan/ModelScopeParameterReflection.php @@ -0,0 +1,77 @@ +parameter->getName(); + } + + /** + * Determine whether the parameter is optional. + */ + public function isOptional(): bool + { + return $this->parameter->isOptional(); + } + + /** + * Return the parameter type. + */ + public function getType(): Type + { + return ModelScopeTypeResolver::bindToModel($this->parameter->getType(), $this->modelClass); + } + + /** + * Return the parameter reference mode. + */ + public function passedByReference(): PassedByReference + { + return $this->parameter->passedByReference(); + } + + /** + * Determine whether the parameter is variadic. + */ + public function isVariadic(): bool + { + return $this->parameter->isVariadic(); + } + + /** + * Return the default value type. + */ + public function getDefaultValue(): ?Type + { + $defaultValue = $this->parameter->getDefaultValue(); + + return $defaultValue === null + ? null + : ModelScopeTypeResolver::bindToModel($defaultValue, $this->modelClass); + } +} diff --git a/src/database/src/PHPStan/ModelScopeTypeResolver.php b/src/database/src/PHPStan/ModelScopeTypeResolver.php new file mode 100644 index 0000000000..49dd9c6d2f --- /dev/null +++ b/src/database/src/PHPStan/ModelScopeTypeResolver.php @@ -0,0 +1,31 @@ + $nestedType instanceof StaticType + ? $nestedType->changeBaseClass($modelClass)->getStaticObjectType() + : $traverse($nestedType), + ); + } +} diff --git a/src/database/src/PHPStan/NamedScopeMethodExtension.php b/src/database/src/PHPStan/NamedScopeMethodExtension.php new file mode 100644 index 0000000000..57d2bc851e --- /dev/null +++ b/src/database/src/PHPStan/NamedScopeMethodExtension.php @@ -0,0 +1,227 @@ + */ + private array $methods = []; + + private readonly OutOfClassScope $scope; + + /** + * Create a named scope method extension. + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly ModelScopeMethodResolver $scopeMethods, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the receiver exposes the named scope. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->resolveMethod($classReflection, $methodName) !== null; + } + + /** + * Return the named scope method. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveMethod($classReflection, $methodName) + ?? throw new LogicException(sprintf( + 'Named scope method [%s::%s] was not resolved.', + $classReflection->getName(), + $methodName, + )); + } + + /** + * Resolve and cache a named scope for an Eloquent receiver. + */ + private function resolveMethod(ClassReflection $classReflection, string $methodName): ?MethodReflection + { + $isModelHost = $this->isClassOrSubclassOf($classReflection, Model::class); + $isBuilderHost = ! $isModelHost + && $this->isClassOrSubclassOf($classReflection, Builder::class); + $isRelationHost = ! $isModelHost && ! $isBuilderHost + && $this->isClassOrSubclassOf($classReflection, Relation::class); + + if (! $isModelHost && ! $isBuilderHost && ! $isRelationHost) { + return null; + } + + $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + + if (array_key_exists($cacheKey, $this->methods)) { + $cachedMethod = $this->methods[$cacheKey]; + + return $cachedMethod === false ? null : $cachedMethod; + } + + $host = $this->host($classReflection, $isModelHost, $isBuilderHost); + + if ($host === null) { + return null; + } + + [$modelClass, $receiverType, $queryType, $static] = $host; + $scopeMethod = $this->scopeMethods->resolve($modelClass, $methodName); + $method = null; + + if ($scopeMethod !== null && ! $this->nativeMethodTakesPrecedence( + $classReflection, + $methodName, + $queryType, + $static, + )) { + $method = new NamedScopeMethodReflection( + $classReflection, + $modelClass, + $scopeMethod, + $methodName, + $receiverType, + $queryType, + $static, + ); + } + + $this->methods[$cacheKey] = $method ?? false; + + return $method; + } + + /** + * Resolve the model, receiver and query types, and static mode for an Eloquent host. + * + * @return null|array{ClassReflection, Type, Type, bool} + */ + private function host( + ClassReflection $classReflection, + bool $isModelHost, + bool $isBuilderHost, + ): ?array { + if ($isModelHost) { + $queryType = $this->activeBuilderType($classReflection); + + return [ + $classReflection, + $queryType, + $queryType, + true, + ]; + } + + $templateName = $isBuilderHost ? 'TModel' : 'TRelatedModel'; + + $modelType = $this->templateType( + $classReflection, + $isBuilderHost ? Builder::class : Relation::class, + $templateName, + ); + $modelClasses = $modelType->getObjectClassReflections(); + + if (count($modelClasses) !== 1) { + return null; + } + + $receiverType = new ThisType($classReflection); + + return [ + $modelClasses[0], + $receiverType, + $isBuilderHost ? $receiverType : $this->activeBuilderType($modelClasses[0]), + false, + ]; + } + + /** + * Determine whether a native method wins over dynamic scope dispatch. + */ + private function nativeMethodTakesPrecedence( + ClassReflection $classReflection, + string $methodName, + Type $queryType, + bool $static, + ): bool { + if ($classReflection->hasNativeMethod($methodName) + && (! $static || $classReflection->getNativeMethod($methodName)->isPublic())) { + return true; + } + + // Model and relation hosts also yield to methods owned by their active builder. + foreach ($queryType->getObjectClassReflections() as $builderClass) { + if ($builderClass->hasNativeMethod($methodName)) { + return true; + } + } + + return false; + } + + /** + * Return the active query builder type for a model. + * + * Bind static here because scope returns are compared before PHPStan can rebind the synthesized receiver. + */ + private function activeBuilderType(ClassReflection $modelClass): Type + { + $variants = $modelClass->getMethod('query', $this->scope)->getVariants(); + + return ModelScopeTypeResolver::bindToModel($variants[0]->getReturnType(), $modelClass); + } + + /** + * Return an active template type from a class ancestor. + * + * @param class-string $ancestorClass + */ + private function templateType( + ClassReflection $classReflection, + string $ancestorClass, + string $templateName, + ): Type { + $type = $classReflection + ->getAncestorWithClassName($ancestorClass) + ?->getActiveTemplateTypeMap() + ->getType($templateName); + + return $type ?? throw new LogicException(sprintf( + 'Template type [%s] is not available for [%s].', + $templateName, + $classReflection->getName(), + )); + } + + /** + * Determine whether a class is or extends the target class. + * + * @param class-string $targetClass + */ + private function isClassOrSubclassOf(ClassReflection $classReflection, string $targetClass): bool + { + return $classReflection->getName() === $targetClass + || $classReflection->isSubclassOfClass($this->reflectionProvider->getClass($targetClass)); + } +} diff --git a/src/database/src/PHPStan/NamedScopeMethodReflection.php b/src/database/src/PHPStan/NamedScopeMethodReflection.php new file mode 100644 index 0000000000..67095f1d55 --- /dev/null +++ b/src/database/src/PHPStan/NamedScopeMethodReflection.php @@ -0,0 +1,193 @@ +methodName; + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the exposed scope is static. + */ + public function isStatic(): bool + { + return $this->static; + } + + /** + * Determine whether the exposed scope is private. + */ + public function isPrivate(): bool + { + return false; + } + + /** + * Determine whether the exposed scope is public. + */ + public function isPublic(): bool + { + return true; + } + + /** + * Return no docblock for the synthesized signature. + */ + public function getDocComment(): ?string + { + return null; + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return scope variants without the engine-supplied builder parameter. + * + * @return list + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + array_map( + fn (ParameterReflection $parameter): ModelScopeParameterReflection => new ModelScopeParameterReflection( + $parameter, + $this->modelClass, + ), + array_slice($variant->getParameters(), 1), + ), + $variant->isVariadic(), + $this->returnType($variant->getReturnType()), + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->scopeMethod->getVariants(), + ); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->scopeMethod->isDeprecated(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->scopeMethod->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->scopeMethod->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->scopeMethod->isInternal(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return $this->scopeMethod->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->scopeMethod->hasSideEffects(); + } + + /** + * Resolve the result produced by Builder::callScope(). + */ + private function returnType(Type $returnType): Type + { + $returnType = ModelScopeTypeResolver::bindToModel($returnType, $this->modelClass); + $nonNullReturnType = TypeCombinator::removeNull($returnType); + + if ($nonNullReturnType->isVoid()->yes() + || $returnType->isNull()->yes() + || $nonNullReturnType->isSuperTypeOf($this->queryType)->yes()) { + return $this->receiverType; + } + + if (TypeCombinator::containsNull($returnType)) { + return TypeCombinator::union( + $nonNullReturnType, + $this->receiverType, + ); + } + + return $returnType; + } +} diff --git a/src/docs/database.md b/src/docs/database.md index 0a2c66d956..315b4f631f 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -281,7 +281,7 @@ The `getPdo` and `getReadPdo` methods configure the PDO before returning it. The ### Static Analysis -The Hypervel database package includes a PHPStan extension that understands fluent methods forwarded by Eloquent builders and relationships. If your application uses `phpstan/extension-installer`, the extension is loaded automatically: +The Hypervel database package includes a PHPStan extension that understands named scopes and query methods forwarded through Eloquent models, builders, and relationships. If your application uses `phpstan/extension-installer`, the extension is loaded automatically: ```shell composer require --dev phpstan/extension-installer diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php index 06d9ac3a57..7d4963478d 100644 --- a/src/permission/src/Traits/HasPermissions.php +++ b/src/permission/src/Traits/HasPermissions.php @@ -491,7 +491,9 @@ protected function allowedDirectPermissions(): Collection /** * Scope the model query to certain permissions only. * + * @param Builder $query * @param array|Collection|int|Permission|string|UnitEnum $permissions + * @return Builder */ public function scopePermission(Builder $query, $permissions, bool $without = false): Builder { @@ -588,7 +590,9 @@ protected function whereRolePermissionEffect(Builder $query, int|string $permiss * Scope the model query to only those without certain permissions, * whether indirectly by role or by direct permission. * + * @param Builder $query * @param array|Collection|int|Permission|string|UnitEnum $permissions + * @return Builder */ public function scopeWithoutPermission(Builder $query, $permissions): Builder { diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php index 29266367a9..5a3f355c62 100644 --- a/src/permission/src/Traits/HasRoles.php +++ b/src/permission/src/Traits/HasRoles.php @@ -169,7 +169,9 @@ protected function getCachedRoles(): Collection /** * Scope the model query to certain roles only. * + * @param Builder $query * @param array|Collection|int|Role|string|UnitEnum $roles + * @return Builder */ public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $without = false): Builder { @@ -215,7 +217,9 @@ public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $w /** * Scope the model query to only those without certain roles. * + * @param Builder $query * @param array|Collection|int|Role|string|UnitEnum $roles + * @return Builder */ public function scopeWithoutRole(Builder $query, $roles, ?string $guard = null): Builder { @@ -261,7 +265,9 @@ public function teams(): BelongsToMany /** * Scope the model query to certain teams only. * + * @param Builder $query * @param array|Collection|int|Model|string $teams + * @return Builder */ public function scopeTeam(Builder $query, $teams, bool $without = false): Builder { @@ -300,7 +306,9 @@ function ($subQuery) use ($pivotTable, $morphKey, $query, $teamsKey, $teamIds, $ /** * Scope the model query to those without certain teams. * + * @param Builder $query * @param array|Collection|int|Model|string $teams + * @return Builder */ public function scopeWithoutTeam(Builder $query, $teams): Builder { diff --git a/src/telescope/src/IncomingEntry.php b/src/telescope/src/IncomingEntry.php index 7b4da37621..8e22dc08f0 100644 --- a/src/telescope/src/IncomingEntry.php +++ b/src/telescope/src/IncomingEntry.php @@ -4,7 +4,7 @@ namespace Hypervel\Telescope; -use DateTimeInterface; +use Carbon\CarbonInterface; use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Support\Str; @@ -50,7 +50,7 @@ class IncomingEntry /** * The DateTime that indicates when the entry was recorded. */ - public DateTimeInterface $recordedAt; + public CarbonInterface $recordedAt; /** * Create a new incoming entry instance. @@ -287,7 +287,7 @@ public function toArray(): array 'family_hash' => $this->familyHash, 'type' => $this->type, 'content' => $this->content, - 'created_at' => $this->recordedAt->toDateTimeString(), // @phpstan-ignore-line + 'created_at' => $this->recordedAt->toDateTimeString(), ]; } } diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 77a74cb6fd..a416b4a716 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -61,20 +61,23 @@ public function __construct(string $connection, ?int $chunkSize = null) */ public function find(mixed $id): EntryResult { - $entry = EntryModel::on($this->connection)->whereUuid($id)->firstOrFail(); // @phpstan-ignore method.notFound + $entry = EntryModel::on($this->connection)->where('uuid', $id)->firstOrFail(); $tags = $this->table('telescope_entries_tags') ->where('entry_uuid', $id) ->pluck('tag') ->all(); + /** @var array $content */ + $content = $entry->content; + return new EntryResult( $entry->uuid, null, $entry->batch_id, $entry->type, $entry->family_hash, - $entry->content, + $content, $entry->created_at, $tags ); @@ -86,19 +89,22 @@ public function find(mixed $id): EntryResult public function get(?string $type, EntryQueryOptions $options): Collection { return EntryModel::on($this->connection) - ->withTelescopeOptions($type, $options) // @phpstan-ignore method.notFound (scope method registered at runtime) + ->withTelescopeOptions($type, $options) ->take($options->limit) ->orderByDesc('sequence') ->get()->reject(function ($entry) { return ! is_array($entry->content); })->map(function ($entry) { + /** @var array $content */ + $content = $entry->content; + return new EntryResult( $entry->uuid, $entry->sequence, $entry->batch_id, $entry->type, $entry->family_hash, - $entry->content, + $content, $entry->created_at, [] ); diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index a7dc18d646..4452cbfb02 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -4,12 +4,24 @@ namespace Hypervel\Telescope\Storage; +use Carbon\CarbonInterface; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use Hypervel\Telescope\Database\Factories\EntryModelFactory; +/** + * Telescope supplies created_at for every entry even though its migration permits null. + * + * @property string $uuid + * @property int|string $sequence + * @property string $batch_id + * @property string $type + * @property null|string $family_hash + * @property mixed $content + * @property CarbonInterface $created_at + */ class EntryModel extends Model { use HasFactory; @@ -48,6 +60,9 @@ class EntryModel extends Model /** * Scope the query for the given query options. + * + * @param Builder $query + * @return Builder */ public function scopeWithTelescopeOptions(Builder $query, ?string $type, EntryQueryOptions $options): Builder { diff --git a/types/Database/Eloquent/ModelForwarding.php b/types/Database/Eloquent/ModelForwarding.php new file mode 100644 index 0000000000..fcfbe141bd --- /dev/null +++ b/types/Database/Eloquent/ModelForwarding.php @@ -0,0 +1,61 @@ +', User::where('active', true)); + assertType('Hypervel\Types\ModelForwarding\User|null', User::first()); + assertType('int<0, max>', User::count()); + assertType('Hypervel\Database\Eloquent\Builder', $user->where('active', true)); + + assertType('Hypervel\Types\ModelForwarding\PostBuilder', Post::where('active', true)); + assertType('Hypervel\Types\ModelForwarding\PostBuilder', Post::published()); + assertType('Hypervel\Types\ModelForwarding\Post|null', Post::first()); + assertType('Hypervel\Types\ModelForwarding\PostBuilder', $post->published()); + + assertType('Hypervel\Database\Eloquent\Builder', Admin::where('active', true)); + assertType('Hypervel\Types\ModelForwarding\Admin|null', Admin::first()); + assertType('Hypervel\Types\ModelForwarding\PostBuilder', EditorPost::where('active', true)); +} + +class User extends Model +{ +} + +class Admin extends User +{ +} + +class Post extends Model +{ + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = PostBuilder::class; +} + +class EditorPost extends Post +{ +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class PostBuilder extends Builder +{ + public function published(): static + { + return $this->whereNotNull('published_at'); + } +} diff --git a/types/Database/Eloquent/NamedScopes.php b/types/Database/Eloquent/NamedScopes.php new file mode 100644 index 0000000000..e594bee6c3 --- /dev/null +++ b/types/Database/Eloquent/NamedScopes.php @@ -0,0 +1,280 @@ +', Post::published()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', $post->published(false)); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->published()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->published()); + + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::ofType()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->ofType('article', 'tutorial')); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->ofType()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::inherited()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::recent()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->annotatedBuilder()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->annotatedBuilder()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->customBuilder()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->nullableBuilder()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::archived()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->archived()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->archived()); + + assertType('Hypervel\Database\Eloquent\Builder', Article::archived()); + assertType('Hypervel\Database\Eloquent\Builder', Article::query()->archived()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->articles()->archived()); + assertType('Hypervel\Database\Eloquent\Collection', Article::archived()->get()); + assertType('Hypervel\Database\Eloquent\Builder', Article::annotatedBuilder()); + assertType('Hypervel\Database\Eloquent\Builder', Article::query()->annotatedBuilder()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->articles()->annotatedBuilder()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::inheritedBuilder()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->inheritedBuilder()); + assertType('Hypervel\Database\Eloquent\Collection', Post::inheritedCollection()); + assertType('Hypervel\Types\NamedScopes\Post', Post::inheritedModel(new Post)); + + assertType('Hypervel\Database\Eloquent\Collection', Post::query()->asCollection()); + assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->asCollection()); + assertType('Hypervel\Types\NamedScopes\Post', Post::query()->asModel()); + assertType('Hypervel\Types\NamedScopes\Post', $user->posts()->asModel()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->withSameModel(new Post)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->withSameModel(new Post)); + assertType('Hypervel\Database\Query\Builder', Post::query()->baseQuery()); + assertType('Hypervel\Database\Query\Builder', $user->posts()->baseQuery()); + + assertType('int', Post::ranking()); + assertType('int', Post::query()->ranking()); + assertType('int', $user->posts()->ranking()); + assertType('Hypervel\Types\NamedScopes\PostBuilder|int', Post::optionalRanking()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany|int', $user->posts()->optionalRanking()); + + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::where('active', true)); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->where('active', true)); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->where('active', true)); + + assertType('Hypervel\Database\Eloquent\Builder', DatabaseNotification::query()->read()); + assertType('Hypervel\Database\Eloquent\Collection', DatabaseNotification::query()->unread()->get()); + assertType('bool', $notification->read()); + + assertType('never', $user->posts()->unavailable()); +} + +class User extends Model +{ + /** @return HasMany */ + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + /** @return HasMany */ + public function articles(): HasMany + { + return $this->hasMany(Article::class); + } +} + +class BaseArticle extends Model +{ + protected function scopeArchived(BuilderContract $query): void + { + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function annotatedBuilder(Builder $query): Builder + { + return $query; + } +} + +class Article extends BaseArticle +{ +} + +class BasePost extends Model +{ + #[Scope] + protected function inherited(BuilderContract $query): void + { + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function inheritedBuilder(Builder $query): Builder + { + return $query; + } + + /** @return Collection */ + #[Scope] + protected function inheritedCollection(BuilderContract $query): Collection + { + return new Collection; + } + + /** + * @param static $other + * @return static + */ + #[Scope] + protected function inheritedModel(BuilderContract $query, Model $other): Model + { + return $other; + } +} + +class Post extends BasePost +{ + use HasArchivedScope; + + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = PostBuilder::class; + + #[Scope] + protected function published(BuilderContract $query, bool $active = true): void + { + } + + protected function scopeOfType(BuilderContract $query, string $type = 'news', string ...$additionalTypes): void + { + } + + #[Scope] + protected static function recent(BuilderContract $query): null + { + return null; + } + + #[Scope] + protected function ranking(BuilderContract $query): int + { + return 1; + } + + #[Scope] + protected function optionalRanking(BuilderContract $query): ?int + { + return null; + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function annotatedBuilder(Builder $query): Builder + { + return $query; + } + + /** + * @param PostBuilder $query + * @return PostBuilder + */ + #[Scope] + protected function customBuilder(PostBuilder $query): PostBuilder + { + return $query; + } + + /** + * @param Builder $query + * @return null|Builder + */ + #[Scope] + protected function nullableBuilder(Builder $query): ?Builder + { + return null; + } + + /** + * @param Builder $query + * @return Collection + */ + #[Scope] + protected function asCollection(Builder $query): Collection + { + return $query->getModel()->newCollection(); + } + + /** @return $this */ + #[Scope] + protected function asModel(BuilderContract $query): Model + { + return $this; + } + + /** @param static $other */ + #[Scope] + protected function withSameModel(BuilderContract $query, Model $other): void + { + } + + /** @param Builder $query */ + #[Scope] + protected function baseQuery(Builder $query): QueryBuilder + { + return $query->toBase(); + } + + #[Scope] + protected function unavailable(BuilderContract $query): never + { + throw new LogicException; + } + + protected function scopeWhere(BuilderContract $query): void + { + } + + public function verifyNativeScopeSignature(BuilderContract $query): void + { + $this->published($query); + } +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class PostBuilder extends Builder +{ +} + +trait HasArchivedScope +{ + /** + * @param Builder $query + * @return Builder + */ + protected function scopeArchived(Builder $query): Builder + { + return $query; + } +} From 122ffc3240a6cfaeb24afba2a169504a92c91b1f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:32:09 +0000 Subject: [PATCH 4/4] fix(database): preserve named scope result unions Classify named-scope return unions one member at a time so builder-compatible members become the active fluent receiver without discarding scalar, collection, or other declared results. Distinguish conventional scopes with no declared return type from explicit mixed and object declarations. This keeps legacy Laravel-style scopes chainable while preserving broader types that may represent real runtime values. Add max-level fixtures for model, builder, and relation hosts, including implicit and explicit mixed returns, broad object returns, and builder unions. Type the new private legacy-scope fixture and document the static-analysis behavior for application authors. --- .../PHPStan/NamedScopeMethodReflection.php | 38 +++++++++---- src/docs/database.md | 2 + .../DatabaseEloquentLocalScopesTest.php | 3 +- types/Database/Eloquent/NamedScopes.php | 55 +++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/database/src/PHPStan/NamedScopeMethodReflection.php b/src/database/src/PHPStan/NamedScopeMethodReflection.php index 67095f1d55..c954264593 100644 --- a/src/database/src/PHPStan/NamedScopeMethodReflection.php +++ b/src/database/src/PHPStan/NamedScopeMethodReflection.php @@ -12,8 +12,10 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; /** * Describe a model scope as invoked through an Eloquent receiver. @@ -173,21 +175,37 @@ public function hasSideEffects(): TrinaryLogic private function returnType(Type $returnType): Type { $returnType = ModelScopeTypeResolver::bindToModel($returnType, $this->modelClass); - $nonNullReturnType = TypeCombinator::removeNull($returnType); - if ($nonNullReturnType->isVoid()->yes() - || $returnType->isNull()->yes() - || $nonNullReturnType->isSuperTypeOf($this->queryType)->yes()) { + if ($returnType->isVoid()->yes()) { return $this->receiverType; } - if (TypeCombinator::containsNull($returnType)) { - return TypeCombinator::union( - $nonNullReturnType, - $this->receiverType, - ); + // A scalar/object union has no class names as a whole, so classify each member separately. + $declaredTypes = $returnType instanceof UnionType ? $returnType->getTypes() : [$returnType]; + + return TypeCombinator::union(...array_map( + fn (Type $declaredType): Type => $this->isReceiverResult($declaredType) + ? $this->receiverType + : $declaredType, + $declaredTypes, + )); + } + + /** + * Determine whether a declared scope result maps to the fluent receiver. + */ + private function isReceiverResult(Type $declaredType): bool + { + if ($declaredType->isNull()->yes()) { + return true; + } + + if ($declaredType instanceof MixedType) { + // PHPStan cannot inspect the body here; conventional untyped Laravel scopes are fluent. + return ! $declaredType->isExplicitMixed(); } - return $returnType; + return $declaredType->getObjectClassNames() !== [] + && $declaredType->isSuperTypeOf($this->queryType)->yes(); } } diff --git a/src/docs/database.md b/src/docs/database.md index 315b4f631f..50604ac579 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -294,6 +294,8 @@ includes: - vendor/hypervel/database/extension.neon ``` +A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept. + ## Running SQL Queries diff --git a/tests/Database/DatabaseEloquentLocalScopesTest.php b/tests/Database/DatabaseEloquentLocalScopesTest.php index 95280a0ea4..416dd7a06b 100644 --- a/tests/Database/DatabaseEloquentLocalScopesTest.php +++ b/tests/Database/DatabaseEloquentLocalScopesTest.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Hypervel\Database\Capsule\Manager as DB; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Testbench\TestCase; @@ -118,7 +119,7 @@ public static function scoped(array $attributes): string return 'scoped'; } - private function scopeHidden($query): void + private function scopeHidden(Builder $query): void { } } diff --git a/types/Database/Eloquent/NamedScopes.php b/types/Database/Eloquent/NamedScopes.php index e594bee6c3..9fd2a64fae 100644 --- a/types/Database/Eloquent/NamedScopes.php +++ b/types/Database/Eloquent/NamedScopes.php @@ -64,6 +64,16 @@ function test(User $user, Post $post, DatabaseNotification $notification): void assertType('Hypervel\Types\NamedScopes\PostBuilder|int', Post::optionalRanking()); assertType('Hypervel\Database\Eloquent\Relations\HasMany|int', $user->posts()->optionalRanking()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::untypedReturn()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->untypedReturn()); + assertType('mixed', Post::mixedScope()); + assertType('mixed', Post::docblockMixed()); + assertType('object', Post::objectScope()); + assertType('Hypervel\Types\NamedScopes\PostBuilder|int', Post::builderOrInt()); + assertType('Hypervel\Types\NamedScopes\PostBuilder|int', Post::query()->builderOrInt()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany|int', $user->posts()->builderOrInt()); + assertType('Hypervel\Database\Eloquent\Collection|Hypervel\Types\NamedScopes\PostBuilder', Post::builderOrCollection()); + assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::where('active', true)); assertType('Hypervel\Types\NamedScopes\PostBuilder', Post::query()->where('active', true)); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->where('active', true)); @@ -182,6 +192,51 @@ protected function optionalRanking(BuilderContract $query): ?int return null; } + /** @phpstan-ignore missingType.return */ + #[Scope] + protected function untypedReturn(BuilderContract $query) + { + } + + #[Scope] + protected function mixedScope(BuilderContract $query): mixed + { + return null; + } + + /** @return mixed */ + #[Scope] + protected function docblockMixed(BuilderContract $query) + { + return null; + } + + #[Scope] + protected function objectScope(BuilderContract $query): object + { + return $this; + } + + /** + * @param Builder $query + * @return Builder|int + */ + #[Scope] + protected function builderOrInt(Builder $query): Builder|int + { + return 1; + } + + /** + * @param Builder $query + * @return Builder|Collection + */ + #[Scope] + protected function builderOrCollection(Builder $query): Builder|Collection + { + return $query; + } + /** * @param Builder $query * @return Builder