Skip to content

Commit

Permalink
Merge pull request #28 from CakeDC/feature/cakephp-rules-001
Browse files Browse the repository at this point in the history
Feature/cakephp rules 001
  • Loading branch information
steinkel authored Feb 22, 2024
2 parents 8c91584 + ec36895 commit 102f5cc
Show file tree
Hide file tree
Showing 28 changed files with 2,316 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .semver
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
:major: 3
:minor: 0
:minor: 1
:patch: 0
:special: ''
64 changes: 52 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@
* [PHPStan](https://phpstan.org/)
* [CakePHP](https://cakephp.org/)

Provide services and rules for a better PHPStan analyze on CakePHP applications, includes services to resolve types (Table, Helpers, Behaviors, etc)
and multiple rules.

# Installation

To use this extension, require it through [Composer](https://getcomposer.org/):

```
composer require --dev cakedc/cakephp-phpstan
```


If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer), then you're all set!

<details>
<summary>Manual installation</summary>

If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config:
```
includes:
- vendor/cakedc/cakephp-phpstan/extension.neon
```

</details>


# General class load|fetch extensions
Features included:
Expand Down Expand Up @@ -93,27 +118,42 @@ Features included:
```
</details>

## Installation
# Rules
All rules provided by this library are included in [rules.neon](rules.neon) and are enabled by default:

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

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

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

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

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

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

</details>
# PHPDoc Extensions
### TableAssociationTypeNodeResolverExtension
Fix intersection association phpDoc to correct generic object type, ex:

Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>`


### Tips
To make your life easier make sure to have `@mixin` and `@method` annotations in your table classes.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"require": {
"php": ">=8.1.0",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan": "^1.10",
"cakephp/cakephp": "^5.0"
},
"require-dev": {
Expand Down
7 changes: 6 additions & 1 deletion extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

includes:
- rules.neon
services:
-
class: CakeDC\PHPStan\Method\AssociationTableMixinClassReflectionExtension
Expand Down Expand Up @@ -57,3 +58,7 @@ services:
class: CakeDC\PHPStan\Type\ConsoleHelperLoadDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
tags:
- phpstan.phpDoc.typeNodeResolverExtension
40 changes: 40 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
parameters:
cakeDC:
addAssociationExistsTableClassRule: true
addAssociationMatchOptionsTypesRule: true
addBehaviorExistsClassRule: true
tableGetMatchOptionsTypesRule: true
ormSelectQueryFindMatchOptionsTypesRule: true

parametersSchema:
cakeDC: structure([
addAssociationExistsTableClassRule: anyOf(bool(), arrayOf(bool()))
addAssociationMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
addBehaviorExistsClassRule: anyOf(bool(), arrayOf(bool()))
tableGetMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
ormSelectQueryFindMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool()))
])

conditionalTags:
CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule:
phpstan.rules.rule: %cakeDC.addAssociationExistsTableClassRule%
CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule:
phpstan.rules.rule: %cakeDC.addAssociationMatchOptionsTypesRule%
CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule:
phpstan.rules.rule: %cakeDC.addBehaviorExistsClassRule%
CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule:
phpstan.rules.rule: %cakeDC.tableGetMatchOptionsTypesRule%
CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule:
phpstan.rules.rule: %cakeDC.ormSelectQueryFindMatchOptionsTypesRule%

services:
-
class: CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule
-
class: CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule
-
class: CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule
-
class: CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule
-
class: CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule
87 changes: 87 additions & 0 deletions src/PhpDoc/TableAssociationTypeNodeResolverExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\PhpDoc;

use Cake\ORM\Association;
use Cake\ORM\Association\BelongsTo;
use Cake\ORM\Association\BelongsToMany;
use Cake\ORM\Association\HasMany;
use Cake\ORM\Association\HasOne;
use PHPStan\Analyser\NameScope;
use PHPStan\PhpDoc\TypeNodeResolver;
use PHPStan\PhpDoc\TypeNodeResolverAwareExtension;
use PHPStan\PhpDoc\TypeNodeResolverExtension;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

/**
* Fix intersection association phpDoc to correct generic object type, ex:
*
* Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>`
*
* The type `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` is considered invalid (NeverType) by PHPStan
*/
class TableAssociationTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
{
private TypeNodeResolver $typeNodeResolver;

/**
* @var array<string>
*/
protected array $associationTypes = [
BelongsTo::class,
BelongsToMany::class,
HasMany::class,
HasOne::class,
Association::class,
];

/**
* @param \PHPStan\PhpDoc\TypeNodeResolver $typeNodeResolver
* @return void
*/
public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void
{
$this->typeNodeResolver = $typeNodeResolver;
}

/**
* @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode
* @param \PHPStan\Analyser\NameScope $nameScope
* @return \PHPStan\Type\Type|null
*/
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
{
if (!$typeNode instanceof IntersectionTypeNode) {
return null;
}
$types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope);
$config = [
'association' => null,
'table' => null,
];
foreach ($types as $type) {
if (!$type instanceof ObjectType) {
continue;
}
$className = $type->getClassName();
if ($config['association'] === null && in_array($className, $this->associationTypes)) {
$config['association'] = $type;
} elseif ($config['table'] === null && str_ends_with($className, 'Table')) {
$config['table'] = $type;
}
}
if ($config['table'] && $config['association']) {
return new GenericObjectType(
$config['association']->getClassName(),
[$config['table']]
);
}

return null;
}
}
133 changes: 133 additions & 0 deletions src/Rule/LoadObjectExistsCakeClassRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);

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

namespace CakeDC\PHPStan\Rule;

use CakeDC\PHPStan\Rule\Traits\ParseClassNameFromArgTrait;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

abstract class LoadObjectExistsCakeClassRule implements Rule
{
use ParseClassNameFromArgTrait;

/**
* @var string
*/
protected string $identifier;

/**
* @return string
*/
public function getNodeType(): string
{
return MethodCall::class;
}

/**
* @param \PhpParser\Node $node
* @param \PHPStan\Analyser\Scope $scope
* @return array<\PHPStan\Rules\RuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
assert($node instanceof MethodCall);
$args = $node->getArgs();
if (!$node->name instanceof Node\Identifier) {
return [];
}
$reference = $scope->getType($node->var)->getReferencedClasses()[0] ?? null;
if ($reference === null) {
return [];
}
$details = $this->getDetails($reference, $args);

if (
$details === null
|| !in_array($node->name->name, $details['sourceMethods'])
|| !$details['alias'] instanceof Arg
|| !$details['alias']->value instanceof String_
) {
return [];
}

$inputClassName = $this->getInputClassName(
$details['alias']->value,
$details['options']
);
if ($this->getTargetClassName($inputClassName)) {
return [];
}

return [
RuleErrorBuilder::message(sprintf(
'Call to %s::%s could not find the class for "%s"',
$reference,
$node->name->name,
$inputClassName,
))
->identifier($this->identifier)
->build(),
];
}

/**
* @param \PhpParser\Node\Scalar\String_ $nameArg
* @param \PhpParser\Node\Arg|null $options
* @return string
*/
protected function getInputClassName(String_ $nameArg, ?Arg $options): string
{
$className = $nameArg->value;

if (
$options === null
|| !$options->value instanceof Node\Expr\Array_
) {
return $className;
}
foreach ($options->value->items as $item) {
if (
!$item instanceof Node\Expr\ArrayItem
|| !$item->key instanceof String_
|| $item->key->value !== 'className'
) {
continue;
}
$name = $this->parseClassNameFromExprTrait($item->value);
if ($name !== null) {
return $name;
}
}

return $className;
}

/**
* @param string $name
* @return string|null
*/
abstract protected function getTargetClassName(string $name): ?string;

/**
* @param string $reference
* @param array<\PhpParser\Node\Arg> $args
* @return array{'alias': ?\PhpParser\Node\Arg, 'options': ?\PhpParser\Node\Arg, 'sourceMethods':array<string>}|null
*/
abstract protected function getDetails(string $reference, array $args): ?array;
}
Loading

0 comments on commit 102f5cc

Please sign in to comment.