diff --git a/extension.neon b/extension.neon index a98acb3..75ff6d8 100644 --- a/extension.neon +++ b/extension.neon @@ -34,6 +34,10 @@ services: factory: CakeDC\PHPStan\Type\RepositoryFirstArgIsTheReturnTypeExtension(Cake\ORM\Association) tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: CakeDC\PHPStan\Type\AssociationFindDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: CakeDC\PHPStan\Type\ComponentLoadDynamicReturnTypeExtension tags: diff --git a/src/Traits/EntityClassFromTableClassTrait.php b/src/Traits/EntityClassFromTableClassTrait.php new file mode 100644 index 0000000..2ddbc24 --- /dev/null +++ b/src/Traits/EntityClassFromTableClassTrait.php @@ -0,0 +1,46 @@ +`. + * + * Cake core declares `Association::find()` as + * `\Cake\ORM\Query\SelectQuery` — it does not propagate + * the target table's `TEntity` template parameter. As a result, chains such as + * `$this->Articles->Users->find()->first()` resolve to `EntityInterface|null` + * instead of `User|null`, forcing every call-site to add inline `@var` + * annotations. + * + * This extension reads the association's target table type — once + * {@see \CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension} has + * converted the intersection `BelongsTo&UsersTable` into the generic + * `BelongsTo` — derives the entity class via the standard CakePHP + * naming convention, and replaces the return type with + * `SelectQuery`. + * + * Hydration-disabled queries are not detected here; PHPStan cannot follow + * `$query->disableHydration()` calls regardless of which extension produces + * the type, so narrowing to the entity is at least as accurate as the current + * `EntityInterface|array` union and strictly better for the common hydrated + * case. + */ +class AssociationFindDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + use EntityClassFromTableClassTrait; + use RepositoryReferenceTrait; + + /** + * @inheritDoc + */ + public function getClass(): string + { + return Association::class; + } + + /** + * @inheritDoc + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'find'; + } + + /** + * @param \PHPStan\Reflection\MethodReflection $methodReflection + * @param \PhpParser\Node\Expr\MethodCall $methodCall + * @param \PHPStan\Analyser\Scope $scope + * @return \PHPStan\Type\Type|null + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $tableClass = $this->getReferenceClass($scope, $methodCall); + if ($tableClass === null) { + return null; + } + + $entityClass = $this->getEntityClassByTableClass($tableClass); + if ($entityClass === null || !class_exists($entityClass)) { + return null; + } + + return new GenericObjectType(SelectQuery::class, [new ObjectType($entityClass)]); + } +} diff --git a/src/Type/RepositoryEntityDynamicReturnTypeExtension.php b/src/Type/RepositoryEntityDynamicReturnTypeExtension.php index 7dc39ee..8596a95 100644 --- a/src/Type/RepositoryEntityDynamicReturnTypeExtension.php +++ b/src/Type/RepositoryEntityDynamicReturnTypeExtension.php @@ -15,8 +15,8 @@ use Cake\Datasource\EntityInterface; use Cake\ORM\Table; -use Cake\Utility\Inflector; use CakeDC\PHPStan\Traits\BaseCakeRegistryReturnTrait; +use CakeDC\PHPStan\Traits\EntityClassFromTableClassTrait; use CakeDC\PHPStan\Traits\RepositoryReferenceTrait; use CakeDC\PHPStan\Utility\CakeNameRegistry; use PhpParser\Node\Expr\MethodCall; @@ -31,6 +31,7 @@ class RepositoryEntityDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { use BaseCakeRegistryReturnTrait; + use EntityClassFromTableClassTrait; use RepositoryReferenceTrait; /** @@ -108,25 +109,4 @@ public function getTypeFromMethodCall( return null; } - - /** - * @param string $className - * @return string|null - */ - protected function getEntityClassByTableClass(string $className): ?string - { - $parts = explode('\\', $className); - $count = count($parts); - $nameIndex = $count - 1; - $folderIndex = $count - 2; - if ($count < 3 || $parts[$folderIndex] !== 'Table') { - return null; - } - $name = str_replace('Table', '', $parts[$nameIndex]); - $name = Inflector::singularize($name); - $parts[$folderIndex] = 'Entity'; - $parts[$nameIndex] = $name; - - return implode('\\', $parts); - } } diff --git a/tests/TestCase/Type/AssociationFindDynamicReturnTypeExtensionTest.php b/tests/TestCase/Type/AssociationFindDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..3e10756 --- /dev/null +++ b/tests/TestCase/Type/AssociationFindDynamicReturnTypeExtensionTest.php @@ -0,0 +1,52 @@ +first()/firstOrFail() must narrow to the target + * table's entity type. + * + * @return void + */ + public function testAssociationFindNarrowsToTargetEntity(): void + { + $output = $this->runPhpStan(__DIR__ . '/Fake/AssociationFindCorrectUsage.php'); + static::assertStringContainsString('[OK] No errors', $output); + } + + /** + * Run PHPStan on a file and return the output. + * + * @param string $file File to analyze. + * @return string + */ + private function runPhpStan(string $file): string + { + $configFile = dirname(__DIR__, 3) . '/extension.neon'; + $command = sprintf( + 'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1', + escapeshellarg(dirname(__DIR__, 3)), + escapeshellarg($file), + escapeshellarg($configFile), + ); + + exec($command, $output, $exitCode); + + return implode("\n", $output); + } +} diff --git a/tests/TestCase/Type/Fake/AssociationFindCorrectUsage.php b/tests/TestCase/Type/Fake/AssociationFindCorrectUsage.php new file mode 100644 index 0000000..c842d6c --- /dev/null +++ b/tests/TestCase/Type/Fake/AssociationFindCorrectUsage.php @@ -0,0 +1,51 @@ +` — the target entity type is lost. + * The extension restores it by reading the association's target table type + * (provided by {@see \CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension}) + * and returning `SelectQuery`. + * + * Note that this fixture only asserts the SelectQuery template parameter, + * not the downstream `->first()` / `->firstOrFail()` return type. Those depend + * on Cake core's per-method `@return TSubject|null` / `@return TSubject` + * annotations (cakephp/cakephp#19439, merged for 5.3.6). On older Cake + * versions `first()` still returns `mixed`; the entity narrowing kicks in + * automatically once consumers upgrade. + */ +class AssociationFindCorrectUsage +{ + public function narrowsSelectQueryGeneric(NotesTable $notes): void + { + // Sanity check: the existing TableAssociationTypeNodeResolverExtension + // turns `BelongsTo&UsersTable` into the generic association type. + assertType('Cake\ORM\Association\BelongsTo', $notes->MyUsers); + + // This is what the new extension contributes: the SelectQuery is + // templated with the target table's entity type instead of the + // default `EntityInterface|array`. + assertType( + 'Cake\ORM\Query\SelectQuery', + $notes->MyUsers->find(), + ); + } +}