Browse Source

Add mixin and template to associations, add PHPStan support for it

Adam Halfar 1 year ago
parent
commit
ec74c1f018

+ 5 - 0
phpstan.neon.dist

@@ -24,3 +24,8 @@ services:
 		tags:
 			- phpstan.broker.methodsClassReflectionExtension
 			- phpstan.broker.propertiesClassReflectionExtension
+
+	-
+		class: Cake\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
+		tags:
+			- phpstan.phpDoc.typeNodeResolverExtension

+ 3 - 0
src/ORM/Association/BelongsTo.php

@@ -31,6 +31,9 @@ use function Cake\Core\pluginSplit;
  * related to only one record in the target table.
  *
  * An example of a BelongsTo association would be Article belongs to Author.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class BelongsTo extends Association
 {

+ 3 - 0
src/ORM/Association/BelongsToMany.php

@@ -37,6 +37,9 @@ use SplObjectStorage;
  *
  * An example of a BelongsToMany association would be Article belongs to many Tags.
  * In this example 'Article' is the source table and 'Tags' is the target table.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class BelongsToMany extends Association
 {

+ 3 - 0
src/ORM/Association/HasMany.php

@@ -33,6 +33,9 @@ use InvalidArgumentException;
  * will have one or multiple records per each one in the source side.
  *
  * An example of a HasMany association would be Author has many Articles.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class HasMany extends Association
 {

+ 3 - 0
src/ORM/Association/HasOne.php

@@ -29,6 +29,9 @@ use function Cake\Core\pluginSplit;
  * related to only one record in the target table and vice versa.
  *
  * An example of a HasOne association would be User has one Profile.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class HasOne extends Association
 {

+ 87 - 0
tests/PHPStan/PhpDoc/TableAssociationTypeNodeResolverExtension.php

@@ -0,0 +1,87 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\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;
+    }
+}