diff --git a/extension.neon b/extension.neon index e1bb695..4effb26 100644 --- a/extension.neon +++ b/extension.neon @@ -58,3 +58,7 @@ services: class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension tags: - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: CakeDC\PHPStan\ThrowType\TableMethodThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension diff --git a/src/ThrowType/TableMethodThrowTypeExtension.php b/src/ThrowType/TableMethodThrowTypeExtension.php new file mode 100644 index 0000000..3986f17 --- /dev/null +++ b/src/ThrowType/TableMethodThrowTypeExtension.php @@ -0,0 +1,102 @@ + + */ + protected array $methods = [ + 'get', + 'deleteManyOrFail', + 'findOrCreate', + 'save', + 'saveOrFail', + 'saveMany', + 'saveManyOrFail', + ]; + + /** + * @param \PHPStan\Reflection\ReflectionProvider $reflectionProvider + */ + public function __construct(ReflectionProvider $reflectionProvider) + { + $this->reflectionProvider = $reflectionProvider; + } + + /** + * @param \PHPStan\Reflection\MethodReflection $methodReflection + * @return bool + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + if (!in_array($methodReflection->getName(), $this->methods, true)) { + return false; + } + + return $methodReflection->getDeclaringClass()->is(Table::class); + } + + /** + * @param \PHPStan\Reflection\MethodReflection $methodReflection + * @param \PhpParser\Node\Expr\MethodCall $methodCall + * @param \PHPStan\Analyser\Scope $scope + * @return \PHPStan\Type\Type|null + */ + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $methodName = $methodReflection->getName(); + $type = $scope->getType($methodCall->var); + $classReflection = $type->getObjectClassReflections()[0]; + if ($classReflection->is(Association::class)) { + return $this->getThrowType($methodName, $scope); + } + + if (!$classReflection->is(Table::class)) { + return null; + } + + $tag = $classReflection->getResolvedPhpDoc()?->getMethodTags()['get'] ?? null; + if ($tag === null) { + return null; + } + + return $this->getThrowType($methodName, $scope); + } + + /** + * @param string $methodName + * @param \PHPStan\Analyser\Scope $scope + * @return \PHPStan\Type\Type|null + */ + protected function getThrowType(string $methodName, Scope $scope): ?Type + { + $reflection = $this->reflectionProvider->getClass(Table::class); + + try { + return $reflection->getMethod($methodName, $scope)->getThrowType(); + } catch (MissingMethodFromReflectionException $e) { + return null; + } + } +} diff --git a/tests/test_app/Controller/NotesController.php b/tests/test_app/Controller/NotesController.php index d96d27b..00858c6 100644 --- a/tests/test_app/Controller/NotesController.php +++ b/tests/test_app/Controller/NotesController.php @@ -14,7 +14,11 @@ namespace App\Controller; use Cake\Controller\Controller; +use Cake\Datasource\Exception\InvalidPrimaryKeyException; +use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Log\Log; +use Cake\ORM\Exception\PersistenceFailedException; +use Cake\ORM\Exception\RolledbackTransactionException; use Cake\ORM\TableRegistry; /** @@ -183,4 +187,70 @@ public function listUsers() //BelongsTo should match the correct Users table methods. $this->Notes->Users->blockOld(); } + + /** + * @return void + */ + public function viewWithTryCatch() + { + try { + $note = $this->Notes->get(1); + $note->note = 'This is a test'; + } catch (RecordNotFoundException) { + } + + $user = $this->Notes->NewMyUsers->get(1); + try { + $this->Notes->NewMyUsers->save($user); + } catch (RolledbackTransactionException) { + } + try { + $this->Notes->NewMyUsers->findOrCreate(['name' => 'This is a test']); + } catch (PersistenceFailedException) { + } + try { + $this->Notes->NewMyUsers->saveOrFail($user); + } catch (PersistenceFailedException) { + } + + try { + $this->Notes->NewMyUsers->saveMany([$user]); + } catch (PersistenceFailedException) { + } + + try { + $this->Notes->NewMyUsers->saveManyOrFail([$user]); + } catch (PersistenceFailedException) { + } + + try { + $this->Notes->NewMyUsers->deleteManyOrFail([$user]); + } catch (PersistenceFailedException) { + } + + try { + $user = $this->Notes->get(1); + $user->note = 'This is a test'; + } catch (InvalidPrimaryKeyException) { + } + + try { + $user = $this->Notes->Users->get(1); + $user->name = 'user1'; + } catch (RecordNotFoundException) { + //TableMethodThrowTypeExtension avoids dead catch + } + try { + $user = $this->Notes->MyUsers->get(2); + $user->name = 'user2'; + } catch (RecordNotFoundException) { + //TableMethodThrowTypeExtension avoids dead catch + } + try { + $user = $this->Notes->NewMyUsers->get(3); + $user->name = 'user3'; + } catch (RecordNotFoundException) { + //TableMethodThrowTypeExtension avoids dead catch + } + } } diff --git a/tests/test_app/Model/Table/MyUsersTable.php b/tests/test_app/Model/Table/MyUsersTable.php index fef7fb0..c5401c8 100644 --- a/tests/test_app/Model/Table/MyUsersTable.php +++ b/tests/test_app/Model/Table/MyUsersTable.php @@ -7,6 +7,13 @@ /** * @method \App\Model\Entity\User get($primaryKey, $options = []) + * @method \App\Model\Entity\User findOrCreate($search, ?callable $callback = null, $options = []) + * @method \App\Model\Entity\User|false save(\Cake\Datasource\EntityInterface $entity, $options = []) + * @method \App\Model\Entity\User saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = []) + * @method iterable<\App\Model\Entity\User>|false saveMany(iterable<\App\Model\Entity\User> $entities, $options = []) + * @method iterable<\App\Model\Entity\User> saveManyOrFail(iterable<\App\Model\Entity\User> $entities, $options = []) + * @method iterable<\App\Model\Entity\User>|false deleteMany(iterable<\App\Model\Entity\User> $entities, $options = []) + * @method iterable<\App\Model\Entity\User> deleteManyOrFail(iterable<\App\Model\Entity\User> $entities, $options = []) */ class MyUsersTable extends Table { diff --git a/tests/test_app/Model/Table/NotesTable.php b/tests/test_app/Model/Table/NotesTable.php index 53f1627..0e7e09f 100644 --- a/tests/test_app/Model/Table/NotesTable.php +++ b/tests/test_app/Model/Table/NotesTable.php @@ -22,6 +22,7 @@ * @property \App\Model\Table\VeryCustomize00009ArticlesTable&\Cake\ORM\Association\HasMany $VeryCustomize00009Articles * @property \Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable> $Users * @property \Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable $MyUsers//Don't use generic here, we need this way for testing + * @property \Cake\ORM\Association\BelongsTo<\App\Model\Table\MyUsersTable> $NewMyUsers */ class NotesTable extends Table {