From 5e125478f641e77c1fdb71cff0fad40559a156e9 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Wed, 15 Oct 2025 16:47:45 -0300 Subject: [PATCH 1/4] New extension TableMethodThrowTypeExtension --- tests/test_app/Controller/NotesController.php | 39 +++++++++++++++++++ tests/test_app/Model/Table/NotesTable.php | 1 + 2 files changed, 40 insertions(+) diff --git a/tests/test_app/Controller/NotesController.php b/tests/test_app/Controller/NotesController.php index d96d27b..73d5c62 100644 --- a/tests/test_app/Controller/NotesController.php +++ b/tests/test_app/Controller/NotesController.php @@ -14,6 +14,8 @@ namespace App\Controller; use Cake\Controller\Controller; +use Cake\Datasource\Exception\InvalidPrimaryKeyException; +use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Log\Log; use Cake\ORM\TableRegistry; @@ -183,4 +185,41 @@ 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) { + } + + try { + $note = $this->Notes->get(1); + $note->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/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 { From 3e1140bd089fad45a6aea566ec272ab41c576e49 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Wed, 15 Oct 2025 16:47:51 -0300 Subject: [PATCH 2/4] New extension TableMethodThrowTypeExtension --- .../TableMethodThrowTypeExtension.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/ThrowType/TableMethodThrowTypeExtension.php diff --git a/src/ThrowType/TableMethodThrowTypeExtension.php b/src/ThrowType/TableMethodThrowTypeExtension.php new file mode 100644 index 0000000..5455b2d --- /dev/null +++ b/src/ThrowType/TableMethodThrowTypeExtension.php @@ -0,0 +1,86 @@ +reflectionProvider = $reflectionProvider; + } + + /** + * @param \PHPStan\Reflection\MethodReflection $methodReflection + * @return bool + */ + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'get'; + } + + /** + * @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]; + $isAssociation = $classReflection->is(Association::class); + if ($isAssociation) { + 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; + } + } +} From 120b5ba28f007deaa429a6bb72a1d83a5171ad02 Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Wed, 15 Oct 2025 16:47:59 -0300 Subject: [PATCH 3/4] New extension TableMethodThrowTypeExtension --- extension.neon | 4 ++++ 1 file changed, 4 insertions(+) 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 From 506e545e29f52a63b0c8396b61894ba343bccc4f Mon Sep 17 00:00:00 2001 From: Marcelo Rocha Date: Wed, 15 Oct 2025 17:49:30 -0300 Subject: [PATCH 4/4] New extension TableMethodThrowTypeExtension --- .../TableMethodThrowTypeExtension.php | 22 ++++++++++-- tests/test_app/Controller/NotesController.php | 35 +++++++++++++++++-- tests/test_app/Model/Table/MyUsersTable.php | 7 ++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/ThrowType/TableMethodThrowTypeExtension.php b/src/ThrowType/TableMethodThrowTypeExtension.php index 5455b2d..3986f17 100644 --- a/src/ThrowType/TableMethodThrowTypeExtension.php +++ b/src/ThrowType/TableMethodThrowTypeExtension.php @@ -20,6 +20,19 @@ class TableMethodThrowTypeExtension implements DynamicMethodThrowTypeExtension */ protected ReflectionProvider $reflectionProvider; + /** + * @var array + */ + protected array $methods = [ + 'get', + 'deleteManyOrFail', + 'findOrCreate', + 'save', + 'saveOrFail', + 'saveMany', + 'saveManyOrFail', + ]; + /** * @param \PHPStan\Reflection\ReflectionProvider $reflectionProvider */ @@ -34,7 +47,11 @@ public function __construct(ReflectionProvider $reflectionProvider) */ public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'get'; + if (!in_array($methodReflection->getName(), $this->methods, true)) { + return false; + } + + return $methodReflection->getDeclaringClass()->is(Table::class); } /** @@ -51,8 +68,7 @@ public function getThrowTypeFromMethodCall( $methodName = $methodReflection->getName(); $type = $scope->getType($methodCall->var); $classReflection = $type->getObjectClassReflections()[0]; - $isAssociation = $classReflection->is(Association::class); - if ($isAssociation) { + if ($classReflection->is(Association::class)) { return $this->getThrowType($methodName, $scope); } diff --git a/tests/test_app/Controller/NotesController.php b/tests/test_app/Controller/NotesController.php index 73d5c62..00858c6 100644 --- a/tests/test_app/Controller/NotesController.php +++ b/tests/test_app/Controller/NotesController.php @@ -17,6 +17,8 @@ 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; /** @@ -197,9 +199,38 @@ public function viewWithTryCatch() } catch (RecordNotFoundException) { } + $user = $this->Notes->NewMyUsers->get(1); try { - $note = $this->Notes->get(1); - $note->note = 'This is a test'; + $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) { } 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 {