Skip to content

Commit 6b5a5e3

Browse files
authored
Merge pull request #68 from dereuromark/feature/association-find-narrowing
Narrow Cake\ORM\Association::find() return to SelectQuery<TargetEntity>
2 parents 31832e9 + 4567fc4 commit 6b5a5e3

6 files changed

Lines changed: 251 additions & 22 deletions

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ services:
3434
factory: CakeDC\PHPStan\Type\RepositoryFirstArgIsTheReturnTypeExtension(Cake\ORM\Association)
3535
tags:
3636
- phpstan.broker.dynamicMethodReturnTypeExtension
37+
-
38+
class: CakeDC\PHPStan\Type\AssociationFindDynamicReturnTypeExtension
39+
tags:
40+
- phpstan.broker.dynamicMethodReturnTypeExtension
3741
-
3842
class: CakeDC\PHPStan\Type\ComponentLoadDynamicReturnTypeExtension
3943
tags:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Traits;
15+
16+
use Cake\Utility\Inflector;
17+
18+
/**
19+
* Resolves the entity class FQCN that belongs to a given table class FQCN,
20+
* using the standard CakePHP convention (`Foo\Model\Table\BarsTable` →
21+
* `Foo\Model\Entity\Bar`).
22+
*/
23+
trait EntityClassFromTableClassTrait
24+
{
25+
/**
26+
* @param string $className Fully-qualified table class name.
27+
* @return string|null Fully-qualified entity class name, or null if the
28+
* input does not match the `*\Model\Table\*Table` convention.
29+
*/
30+
protected function getEntityClassByTableClass(string $className): ?string
31+
{
32+
$parts = explode('\\', $className);
33+
$count = count($parts);
34+
$nameIndex = $count - 1;
35+
$folderIndex = $count - 2;
36+
if ($count < 3 || $parts[$folderIndex] !== 'Table') {
37+
return null;
38+
}
39+
$name = str_replace('Table', '', $parts[$nameIndex]);
40+
$name = Inflector::singularize($name);
41+
$parts[$folderIndex] = 'Entity';
42+
$parts[$nameIndex] = $name;
43+
44+
return implode('\\', $parts);
45+
}
46+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Type;
15+
16+
use Cake\ORM\Association;
17+
use Cake\ORM\Query\SelectQuery;
18+
use CakeDC\PHPStan\Traits\EntityClassFromTableClassTrait;
19+
use CakeDC\PHPStan\Traits\RepositoryReferenceTrait;
20+
use PhpParser\Node\Expr\MethodCall;
21+
use PHPStan\Analyser\Scope;
22+
use PHPStan\Reflection\MethodReflection;
23+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
24+
use PHPStan\Type\Generic\GenericObjectType;
25+
use PHPStan\Type\ObjectType;
26+
use PHPStan\Type\Type;
27+
28+
/**
29+
* Narrows the return type of {@see Association::find()} to
30+
* `SelectQuery<TargetEntity>`.
31+
*
32+
* Cake core declares `Association::find()` as
33+
* `\Cake\ORM\Query\SelectQuery<EntityInterface|array>` — it does not propagate
34+
* the target table's `TEntity` template parameter. As a result, chains such as
35+
* `$this->Articles->Users->find()->first()` resolve to `EntityInterface|null`
36+
* instead of `User|null`, forcing every call-site to add inline `@var`
37+
* annotations.
38+
*
39+
* This extension reads the association's target table type — once
40+
* {@see \CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension} has
41+
* converted the intersection `BelongsTo&UsersTable` into the generic
42+
* `BelongsTo<UsersTable>` — derives the entity class via the standard CakePHP
43+
* naming convention, and replaces the return type with
44+
* `SelectQuery<UserEntity>`.
45+
*
46+
* Hydration-disabled queries are not detected here; PHPStan cannot follow
47+
* `$query->disableHydration()` calls regardless of which extension produces
48+
* the type, so narrowing to the entity is at least as accurate as the current
49+
* `EntityInterface|array` union and strictly better for the common hydrated
50+
* case.
51+
*/
52+
class AssociationFindDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
53+
{
54+
use EntityClassFromTableClassTrait;
55+
use RepositoryReferenceTrait;
56+
57+
/**
58+
* @inheritDoc
59+
*/
60+
public function getClass(): string
61+
{
62+
return Association::class;
63+
}
64+
65+
/**
66+
* @inheritDoc
67+
*/
68+
public function isMethodSupported(MethodReflection $methodReflection): bool
69+
{
70+
return $methodReflection->getName() === 'find';
71+
}
72+
73+
/**
74+
* @param \PHPStan\Reflection\MethodReflection $methodReflection
75+
* @param \PhpParser\Node\Expr\MethodCall $methodCall
76+
* @param \PHPStan\Analyser\Scope $scope
77+
* @return \PHPStan\Type\Type|null
78+
*/
79+
public function getTypeFromMethodCall(
80+
MethodReflection $methodReflection,
81+
MethodCall $methodCall,
82+
Scope $scope,
83+
): ?Type {
84+
$tableClass = $this->getReferenceClass($scope, $methodCall);
85+
if ($tableClass === null) {
86+
return null;
87+
}
88+
89+
$entityClass = $this->getEntityClassByTableClass($tableClass);
90+
if ($entityClass === null || !class_exists($entityClass)) {
91+
return null;
92+
}
93+
94+
return new GenericObjectType(SelectQuery::class, [new ObjectType($entityClass)]);
95+
}
96+
}

src/Type/RepositoryEntityDynamicReturnTypeExtension.php

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
use Cake\Datasource\EntityInterface;
1717
use Cake\ORM\Table;
18-
use Cake\Utility\Inflector;
1918
use CakeDC\PHPStan\Traits\BaseCakeRegistryReturnTrait;
19+
use CakeDC\PHPStan\Traits\EntityClassFromTableClassTrait;
2020
use CakeDC\PHPStan\Traits\RepositoryReferenceTrait;
2121
use CakeDC\PHPStan\Utility\CakeNameRegistry;
2222
use PhpParser\Node\Expr\MethodCall;
@@ -31,6 +31,7 @@
3131
class RepositoryEntityDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
3232
{
3333
use BaseCakeRegistryReturnTrait;
34+
use EntityClassFromTableClassTrait;
3435
use RepositoryReferenceTrait;
3536

3637
/**
@@ -108,25 +109,4 @@ public function getTypeFromMethodCall(
108109

109110
return null;
110111
}
111-
112-
/**
113-
* @param string $className
114-
* @return string|null
115-
*/
116-
protected function getEntityClassByTableClass(string $className): ?string
117-
{
118-
$parts = explode('\\', $className);
119-
$count = count($parts);
120-
$nameIndex = $count - 1;
121-
$folderIndex = $count - 2;
122-
if ($count < 3 || $parts[$folderIndex] !== 'Table') {
123-
return null;
124-
}
125-
$name = str_replace('Table', '', $parts[$nameIndex]);
126-
$name = Inflector::singularize($name);
127-
$parts[$folderIndex] = 'Entity';
128-
$parts[$nameIndex] = $name;
129-
130-
return implode('\\', $parts);
131-
}
132112
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Test\TestCase\Type;
15+
16+
use PHPUnit\Framework\TestCase;
17+
18+
class AssociationFindDynamicReturnTypeExtensionTest extends TestCase
19+
{
20+
/**
21+
* Association::find()->first()/firstOrFail() must narrow to the target
22+
* table's entity type.
23+
*
24+
* @return void
25+
*/
26+
public function testAssociationFindNarrowsToTargetEntity(): void
27+
{
28+
$output = $this->runPhpStan(__DIR__ . '/Fake/AssociationFindCorrectUsage.php');
29+
static::assertStringContainsString('[OK] No errors', $output);
30+
}
31+
32+
/**
33+
* Run PHPStan on a file and return the output.
34+
*
35+
* @param string $file File to analyze.
36+
* @return string
37+
*/
38+
private function runPhpStan(string $file): string
39+
{
40+
$configFile = dirname(__DIR__, 3) . '/extension.neon';
41+
$command = sprintf(
42+
'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1',
43+
escapeshellarg(dirname(__DIR__, 3)),
44+
escapeshellarg($file),
45+
escapeshellarg($configFile),
46+
);
47+
48+
exec($command, $output, $exitCode);
49+
50+
return implode("\n", $output);
51+
}
52+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;
15+
16+
use App\Model\Table\NotesTable;
17+
use function PHPStan\Testing\assertType;
18+
19+
/**
20+
* Exercises {@see \CakeDC\PHPStan\Type\AssociationFindDynamicReturnTypeExtension}.
21+
*
22+
* `Cake\ORM\Association::find()` is declared in Cake core as
23+
* `SelectQuery<EntityInterface|array>` — the target entity type is lost.
24+
* The extension restores it by reading the association's target table type
25+
* (provided by {@see \CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension})
26+
* and returning `SelectQuery<TargetEntity>`.
27+
*
28+
* Note that this fixture only asserts the SelectQuery template parameter,
29+
* not the downstream `->first()` / `->firstOrFail()` return type. Those depend
30+
* on Cake core's per-method `@return TSubject|null` / `@return TSubject`
31+
* annotations (cakephp/cakephp#19439, merged for 5.3.6). On older Cake
32+
* versions `first()` still returns `mixed`; the entity narrowing kicks in
33+
* automatically once consumers upgrade.
34+
*/
35+
class AssociationFindCorrectUsage
36+
{
37+
public function narrowsSelectQueryGeneric(NotesTable $notes): void
38+
{
39+
// Sanity check: the existing TableAssociationTypeNodeResolverExtension
40+
// turns `BelongsTo&UsersTable` into the generic association type.
41+
assertType('Cake\ORM\Association\BelongsTo<App\Model\Table\UsersTable>', $notes->MyUsers);
42+
43+
// This is what the new extension contributes: the SelectQuery is
44+
// templated with the target table's entity type instead of the
45+
// default `EntityInterface|array`.
46+
assertType(
47+
'Cake\ORM\Query\SelectQuery<App\Model\Entity\User>',
48+
$notes->MyUsers->find(),
49+
);
50+
}
51+
}

0 commit comments

Comments
 (0)