Skip to content

Commit 102f5cc

Browse files
authored
Merge pull request #28 from CakeDC/feature/cakephp-rules-001
Feature/cakephp rules 001
2 parents 8c91584 + ec36895 commit 102f5cc

28 files changed

+2316
-43
lines changed

.semver

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
22
:major: 3
3-
:minor: 0
3+
:minor: 1
44
:patch: 0
55
:special: ''

README.md

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,31 @@
88
* [PHPStan](https://phpstan.org/)
99
* [CakePHP](https://cakephp.org/)
1010

11+
Provide services and rules for a better PHPStan analyze on CakePHP applications, includes services to resolve types (Table, Helpers, Behaviors, etc)
12+
and multiple rules.
13+
14+
# Installation
15+
16+
To use this extension, require it through [Composer](https://getcomposer.org/):
17+
18+
```
19+
composer require --dev cakedc/cakephp-phpstan
20+
```
21+
22+
23+
If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer), then you're all set!
24+
25+
<details>
26+
<summary>Manual installation</summary>
27+
28+
If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config:
29+
```
30+
includes:
31+
- vendor/cakedc/cakephp-phpstan/extension.neon
32+
```
33+
34+
</details>
35+
1136

1237
# General class load|fetch extensions
1338
Features included:
@@ -93,27 +118,42 @@ Features included:
93118
```
94119
</details>
95120

96-
## Installation
121+
# Rules
122+
All rules provided by this library are included in [rules.neon](rules.neon) and are enabled by default:
97123

98-
To use this extension, require it through [Composer](https://getcomposer.org/):
124+
### AddAssociationExistsTableClassRule
125+
This rule check if the target association has a valid table class when calling to Table::belongsTo,
126+
Table::hasMany, Table::belongsToMany, Table::hasOne and AssociationCollection::load.
99127

100-
```
101-
composer require --dev cakedc/cakephp-phpstan
102-
```
128+
### AddAssociationMatchOptionsTypesRule
129+
This rule check if association options are valid option types based on what each class expects. This cover calls to Table::belongsTo,
130+
Table::hasMany, Table::belongsToMany, Table::hasOne and AssociationCollection::load.
103131

132+
### AddBehaviorExistsClassRule
133+
This rule check if the target behavior has a valid table class when calling to Table::addBehavior and BehaviorRegistry::load.
104134

105-
If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer), then you're all set!
135+
### OrmSelectQueryFindMatchOptionsTypesRule
136+
This rule check if the options (args) passed to Table::find and SelectQuery are valid find options types.
106137

107-
<details>
108-
<summary>Manual installation</summary>
138+
### TableGetMatchOptionsTypesRule
139+
This rule check if the options (args) passed to Table::get are valid find options types.
109140

110-
If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config:
141+
### How to disable a rule
142+
Each rule has a parameter in cakeDC 'namespace' to enable or disable, it is the same name of the
143+
rule with first letter in lowercase.
144+
For example to disable the rule AddAssociationExistsTableClassRule you should have
111145
```
112-
includes:
113-
- vendor/cakedc/cakephp-phpstan/extension.neon
146+
parameters:
147+
cakeDC:
148+
addAssociationExistsTableClassRule: false
114149
```
115150

116-
</details>
151+
# PHPDoc Extensions
152+
### TableAssociationTypeNodeResolverExtension
153+
Fix intersection association phpDoc to correct generic object type, ex:
154+
155+
Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>`
156+
117157

118158
### Tips
119159
To make your life easier make sure to have `@mixin` and `@method` annotations in your table classes.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
],
1313
"require": {
1414
"php": ">=8.1.0",
15-
"phpstan/phpstan": "^1.0",
15+
"phpstan/phpstan": "^1.10",
1616
"cakephp/cakephp": "^5.0"
1717
},
1818
"require-dev": {

extension.neon

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
1+
includes:
2+
- rules.neon
23
services:
34
-
45
class: CakeDC\PHPStan\Method\AssociationTableMixinClassReflectionExtension
@@ -57,3 +58,7 @@ services:
5758
class: CakeDC\PHPStan\Type\ConsoleHelperLoadDynamicReturnTypeExtension
5859
tags:
5960
- phpstan.broker.dynamicMethodReturnTypeExtension
61+
-
62+
class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
63+
tags:
64+
- phpstan.phpDoc.typeNodeResolverExtension

rules.neon

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
parameters:
2+
cakeDC:
3+
addAssociationExistsTableClassRule: true
4+
addAssociationMatchOptionsTypesRule: true
5+
addBehaviorExistsClassRule: true
6+
tableGetMatchOptionsTypesRule: true
7+
ormSelectQueryFindMatchOptionsTypesRule: true
8+
9+
parametersSchema:
10+
cakeDC: structure([
11+
addAssociationExistsTableClassRule: anyOf(bool(), arrayOf(bool()))
12+
addAssociationMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
13+
addBehaviorExistsClassRule: anyOf(bool(), arrayOf(bool()))
14+
tableGetMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
15+
ormSelectQueryFindMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
16+
])
17+
18+
conditionalTags:
19+
CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule:
20+
phpstan.rules.rule: %cakeDC.addAssociationExistsTableClassRule%
21+
CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule:
22+
phpstan.rules.rule: %cakeDC.addAssociationMatchOptionsTypesRule%
23+
CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule:
24+
phpstan.rules.rule: %cakeDC.addBehaviorExistsClassRule%
25+
CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule:
26+
phpstan.rules.rule: %cakeDC.tableGetMatchOptionsTypesRule%
27+
CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule:
28+
phpstan.rules.rule: %cakeDC.ormSelectQueryFindMatchOptionsTypesRule%
29+
30+
services:
31+
-
32+
class: CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule
33+
-
34+
class: CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule
35+
-
36+
class: CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule
37+
-
38+
class: CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule
39+
-
40+
class: CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace CakeDC\PHPStan\PhpDoc;
5+
6+
use Cake\ORM\Association;
7+
use Cake\ORM\Association\BelongsTo;
8+
use Cake\ORM\Association\BelongsToMany;
9+
use Cake\ORM\Association\HasMany;
10+
use Cake\ORM\Association\HasOne;
11+
use PHPStan\Analyser\NameScope;
12+
use PHPStan\PhpDoc\TypeNodeResolver;
13+
use PHPStan\PhpDoc\TypeNodeResolverAwareExtension;
14+
use PHPStan\PhpDoc\TypeNodeResolverExtension;
15+
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
16+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
17+
use PHPStan\Type\Generic\GenericObjectType;
18+
use PHPStan\Type\ObjectType;
19+
use PHPStan\Type\Type;
20+
21+
/**
22+
* Fix intersection association phpDoc to correct generic object type, ex:
23+
*
24+
* Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>`
25+
*
26+
* The type `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` is considered invalid (NeverType) by PHPStan
27+
*/
28+
class TableAssociationTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
29+
{
30+
private TypeNodeResolver $typeNodeResolver;
31+
32+
/**
33+
* @var array<string>
34+
*/
35+
protected array $associationTypes = [
36+
BelongsTo::class,
37+
BelongsToMany::class,
38+
HasMany::class,
39+
HasOne::class,
40+
Association::class,
41+
];
42+
43+
/**
44+
* @param \PHPStan\PhpDoc\TypeNodeResolver $typeNodeResolver
45+
* @return void
46+
*/
47+
public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void
48+
{
49+
$this->typeNodeResolver = $typeNodeResolver;
50+
}
51+
52+
/**
53+
* @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode
54+
* @param \PHPStan\Analyser\NameScope $nameScope
55+
* @return \PHPStan\Type\Type|null
56+
*/
57+
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
58+
{
59+
if (!$typeNode instanceof IntersectionTypeNode) {
60+
return null;
61+
}
62+
$types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope);
63+
$config = [
64+
'association' => null,
65+
'table' => null,
66+
];
67+
foreach ($types as $type) {
68+
if (!$type instanceof ObjectType) {
69+
continue;
70+
}
71+
$className = $type->getClassName();
72+
if ($config['association'] === null && in_array($className, $this->associationTypes)) {
73+
$config['association'] = $type;
74+
} elseif ($config['table'] === null && str_ends_with($className, 'Table')) {
75+
$config['table'] = $type;
76+
}
77+
}
78+
if ($config['table'] && $config['association']) {
79+
return new GenericObjectType(
80+
$config['association']->getClassName(),
81+
[$config['table']]
82+
);
83+
}
84+
85+
return null;
86+
}
87+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2024, 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 2024, 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\Rule;
15+
16+
use CakeDC\PHPStan\Rule\Traits\ParseClassNameFromArgTrait;
17+
use PhpParser\Node;
18+
use PhpParser\Node\Arg;
19+
use PhpParser\Node\Expr\MethodCall;
20+
use PhpParser\Node\Scalar\String_;
21+
use PHPStan\Analyser\Scope;
22+
use PHPStan\Rules\Rule;
23+
use PHPStan\Rules\RuleErrorBuilder;
24+
25+
abstract class LoadObjectExistsCakeClassRule implements Rule
26+
{
27+
use ParseClassNameFromArgTrait;
28+
29+
/**
30+
* @var string
31+
*/
32+
protected string $identifier;
33+
34+
/**
35+
* @return string
36+
*/
37+
public function getNodeType(): string
38+
{
39+
return MethodCall::class;
40+
}
41+
42+
/**
43+
* @param \PhpParser\Node $node
44+
* @param \PHPStan\Analyser\Scope $scope
45+
* @return array<\PHPStan\Rules\RuleError>
46+
*/
47+
public function processNode(Node $node, Scope $scope): array
48+
{
49+
assert($node instanceof MethodCall);
50+
$args = $node->getArgs();
51+
if (!$node->name instanceof Node\Identifier) {
52+
return [];
53+
}
54+
$reference = $scope->getType($node->var)->getReferencedClasses()[0] ?? null;
55+
if ($reference === null) {
56+
return [];
57+
}
58+
$details = $this->getDetails($reference, $args);
59+
60+
if (
61+
$details === null
62+
|| !in_array($node->name->name, $details['sourceMethods'])
63+
|| !$details['alias'] instanceof Arg
64+
|| !$details['alias']->value instanceof String_
65+
) {
66+
return [];
67+
}
68+
69+
$inputClassName = $this->getInputClassName(
70+
$details['alias']->value,
71+
$details['options']
72+
);
73+
if ($this->getTargetClassName($inputClassName)) {
74+
return [];
75+
}
76+
77+
return [
78+
RuleErrorBuilder::message(sprintf(
79+
'Call to %s::%s could not find the class for "%s"',
80+
$reference,
81+
$node->name->name,
82+
$inputClassName,
83+
))
84+
->identifier($this->identifier)
85+
->build(),
86+
];
87+
}
88+
89+
/**
90+
* @param \PhpParser\Node\Scalar\String_ $nameArg
91+
* @param \PhpParser\Node\Arg|null $options
92+
* @return string
93+
*/
94+
protected function getInputClassName(String_ $nameArg, ?Arg $options): string
95+
{
96+
$className = $nameArg->value;
97+
98+
if (
99+
$options === null
100+
|| !$options->value instanceof Node\Expr\Array_
101+
) {
102+
return $className;
103+
}
104+
foreach ($options->value->items as $item) {
105+
if (
106+
!$item instanceof Node\Expr\ArrayItem
107+
|| !$item->key instanceof String_
108+
|| $item->key->value !== 'className'
109+
) {
110+
continue;
111+
}
112+
$name = $this->parseClassNameFromExprTrait($item->value);
113+
if ($name !== null) {
114+
return $name;
115+
}
116+
}
117+
118+
return $className;
119+
}
120+
121+
/**
122+
* @param string $name
123+
* @return string|null
124+
*/
125+
abstract protected function getTargetClassName(string $name): ?string;
126+
127+
/**
128+
* @param string $reference
129+
* @param array<\PhpParser\Node\Arg> $args
130+
* @return array{'alias': ?\PhpParser\Node\Arg, 'options': ?\PhpParser\Node\Arg, 'sourceMethods':array<string>}|null
131+
*/
132+
abstract protected function getDetails(string $reference, array $args): ?array;
133+
}

0 commit comments

Comments
 (0)