Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions src/Traits/EntityClassFromTableClassTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Traits;

use Cake\Utility\Inflector;

/**
* Resolves the entity class FQCN that belongs to a given table class FQCN,
* using the standard CakePHP convention (`Foo\Model\Table\BarsTable` →
* `Foo\Model\Entity\Bar`).
*/
trait EntityClassFromTableClassTrait
{
/**
* @param string $className Fully-qualified table class name.
* @return string|null Fully-qualified entity class name, or null if the
* input does not match the `*\Model\Table\*Table` convention.
*/
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);
}
}
126 changes: 126 additions & 0 deletions src/Type/AssociationFindDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Type;

use Cake\ORM\Association;
use Cake\ORM\Query\SelectQuery;
use CakeDC\PHPStan\Traits\EntityClassFromTableClassTrait;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

/**
* Narrows the return type of {@see Association::find()} to
* `SelectQuery<TargetEntity>`.
*
* Cake core declares `Association::find()` as
* `\Cake\ORM\Query\SelectQuery<EntityInterface|array>` — 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<UsersTable>` — derives the entity class via the standard CakePHP
* naming convention, and replaces the return type with
* `SelectQuery<UserEntity>`.
*
* 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;

/**
* @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->extractTargetTableClass($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)]);
}

/**
* Returns the target table FQCN from an `Association<TargetTable>` generic
* type, or null if the association is not generic-typed.
*
* @param \PHPStan\Analyser\Scope $scope
* @param \PhpParser\Node\Expr\MethodCall $methodCall
* @return string|null
*/
protected function extractTargetTableClass(Scope $scope, MethodCall $methodCall): ?string
{
$calledOnType = $scope->getType($methodCall->var);
// GenericObjectType is the only way to read template parameter types
// from a typed Association — there is no non-deprecated equivalent in
// PHPStan 2.x yet.
// @phpstan-ignore-next-line phpstanApi.instanceofType
if (!$calledOnType instanceof GenericObjectType) {
Comment thread
dereuromark marked this conversation as resolved.
Outdated
return null;
}

$typeArgs = $calledOnType->getTypes();
if (count($typeArgs) === 0) {
return null;
}

$tableClassNames = $typeArgs[0]->getObjectClassNames();
if (count($tableClassNames) === 0) {
return null;
}

return $tableClassNames[0];
}
}
24 changes: 2 additions & 22 deletions src/Type/RepositoryEntityDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +31,7 @@
class RepositoryEntityDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
use BaseCakeRegistryReturnTrait;
use EntityClassFromTableClassTrait;
use RepositoryReferenceTrait;

/**
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Test\TestCase\Type;

use PHPUnit\Framework\TestCase;

class AssociationFindDynamicReturnTypeExtensionTest extends TestCase
{
/**
* Association::find()->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);
}
}
51 changes: 51 additions & 0 deletions tests/TestCase/Type/Fake/AssociationFindCorrectUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use App\Model\Table\NotesTable;
use function PHPStan\Testing\assertType;

/**
* Exercises {@see \CakeDC\PHPStan\Type\AssociationFindDynamicReturnTypeExtension}.
*
* `Cake\ORM\Association::find()` is declared in Cake core as
* `SelectQuery<EntityInterface|array>` — 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<TargetEntity>`.
*
* 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<App\Model\Table\UsersTable>', $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<App\Model\Entity\User>',
$notes->MyUsers->find(),
);
}
}
Loading