From efbda02f204ced8fe3cf33f88b691affdeac5fa2 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 25 Mar 2026 07:02:38 +0100 Subject: [PATCH 1/4] Add SelectQueryFindListReturnTypeExtension for find('list')->toArray() This extension provides proper return type inference for find('list')->toArray(), returning array instead of the generic entity array type. The extension: - Detects find('list') in the method chain before toArray() - Works with chained queries (where, orderBy, limit, etc.) - Returns null for non-list finders to preserve default behavior Fixes the common issue where $roles = $table->find('list')->toArray() would require a @var annotation to suppress PHPStan varTag.type errors. --- extension.neon | 4 + ...SelectQueryFindListReturnTypeExtension.php | 108 ++++++++++++++++++ .../Type/Fake/FindListChainedUsage.php | 62 ++++++++++ .../Type/Fake/FindListCorrectUsage.php | 46 ++++++++ ...ctQueryFindListReturnTypeExtensionTest.php | 62 ++++++++++ 5 files changed, 282 insertions(+) create mode 100644 src/Type/SelectQueryFindListReturnTypeExtension.php create mode 100644 tests/TestCase/Type/Fake/FindListChainedUsage.php create mode 100644 tests/TestCase/Type/Fake/FindListCorrectUsage.php create mode 100644 tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php diff --git a/extension.neon b/extension.neon index e1bb695..b7be3bb 100644 --- a/extension.neon +++ b/extension.neon @@ -58,3 +58,7 @@ services: class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension tags: - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: CakeDC\PHPStan\Type\SelectQueryFindListReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/src/Type/SelectQueryFindListReturnTypeExtension.php b/src/Type/SelectQueryFindListReturnTypeExtension.php new file mode 100644 index 0000000..20478f0 --- /dev/null +++ b/src/Type/SelectQueryFindListReturnTypeExtension.php @@ -0,0 +1,108 @@ +toArray() + * + * When find('list') is detected in the method chain before toArray(), + * this returns array instead of the generic entity array. + * + * This handles chained queries like: + * - $table->find('list')->toArray() + * - $table->find('list')->where([...])->orderBy([...])->toArray() + */ +class SelectQueryFindListReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + public function getClass(): string + { + return SelectQuery::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'toArray'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($this->hasFindListInChain($methodCall->var)) { + // Return array for find('list') + return new ArrayType( + new UnionType([new IntegerType(), new StringType()]), + new StringType(), + ); + } + + return null; + } + + /** + * Recursively check if find('list') is in the method call chain + */ + private function hasFindListInChain(mixed $expr): bool + { + if (!$expr instanceof MethodCall) { + return false; + } + + if ($this->isFindListCall($expr)) { + return true; + } + + return $this->hasFindListInChain($expr->var); + } + + /** + * Check if a method call is find('list') + */ + private function isFindListCall(MethodCall $methodCall): bool + { + if (!$methodCall->name instanceof Identifier) { + return false; + } + + if ($methodCall->name->name !== 'find') { + return false; + } + + $args = $methodCall->getArgs(); + if (count($args) === 0) { + return false; + } + + $firstArg = $args[0]->value; + if (!$firstArg instanceof String_) { + return false; + } + + return $firstArg->value === 'list'; + } +} diff --git a/tests/TestCase/Type/Fake/FindListChainedUsage.php b/tests/TestCase/Type/Fake/FindListChainedUsage.php new file mode 100644 index 0000000..83b3b4d --- /dev/null +++ b/tests/TestCase/Type/Fake/FindListChainedUsage.php @@ -0,0 +1,62 @@ + + */ + public function testFindListWithWhere(): array + { + $table = $this->fetchTable('Articles'); + + // Chained where() should not affect the return type + return $table->find('list') + ->where(['published' => true]) + ->toArray(); + } + + /** + * Test find('list') with multiple chained methods + * + * @return array + */ + public function testFindListWithMultipleChains(): array + { + $table = $this->fetchTable('Articles'); + + // Multiple chained methods should not affect the return type + return $table->find('list') + ->where(['published' => true]) + ->orderBy(['title' => 'ASC']) + ->limit(100) + ->toArray(); + } + + /** + * Test that strlen() works on values (proves type is string) + * + * @return void + */ + public function testFindListValuesAreStrings(): void + { + $table = $this->fetchTable('Articles'); + $list = $table->find('list')->toArray(); + + foreach ($list as $value) { + // This would error if $value were Entity + echo strlen($value); + } + } +} diff --git a/tests/TestCase/Type/Fake/FindListCorrectUsage.php b/tests/TestCase/Type/Fake/FindListCorrectUsage.php new file mode 100644 index 0000000..3dc6957 --- /dev/null +++ b/tests/TestCase/Type/Fake/FindListCorrectUsage.php @@ -0,0 +1,46 @@ + + * + * @return array + */ + public function testFindList(): array + { + $table = $this->fetchTable('Articles'); + + // This should be inferred as array + $list = $table->find('list')->toArray(); + + // Iterating should work with string values + foreach ($list as $id => $title) { + echo strlen($title); + } + + return $list; + } + + /** + * Test find('list') with custom fields + * + * @return array + */ + public function testFindListWithFields(): array + { + $table = $this->fetchTable('Articles'); + + return $table->find('list', keyField: 'id', valueField: 'title')->toArray(); + } +} diff --git a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php new file mode 100644 index 0000000..e6f9ffe --- /dev/null +++ b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php @@ -0,0 +1,62 @@ +toArray() returns correct type. + * + * @return void + */ + public function testFindListReturnsCorrectType(): void + { + $output = $this->runPhpStan(__DIR__ . '/Fake/FindListCorrectUsage.php'); + static::assertStringContainsString('[OK] No errors', $output); + } + + /** + * Test that find('list') type is properly inferred in chained queries. + * + * @return void + */ + public function testFindListChainedQueriesReturnCorrectType(): void + { + $output = $this->runPhpStan(__DIR__ . '/Fake/FindListChainedUsage.php'); + static::assertStringContainsString('[OK] No errors', $output); + } + + /** + * Run PHPStan on a file and return the output. + * + * @param string $file File to analyze + * @return string + */ + private function runPhpStan(string $file): string + { + $configFile = dirname(__DIR__, 3) . '/extension.neon'; + $command = sprintf( + 'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1', + escapeshellarg(dirname(__DIR__, 3)), + escapeshellarg($file), + escapeshellarg($configFile), + ); + + exec($command, $output, $exitCode); + + return implode("\n", $output); + } +} From cfb0f6b96886c85b7674fdaaf5b48311e0e802f5 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 25 Mar 2026 17:12:10 +0100 Subject: [PATCH 2/4] Add groupField support for find('list') return type When find('list', groupField: '...') is used, the return type changes to array> instead of the simple array. --- ...SelectQueryFindListReturnTypeExtension.php | 50 ++++++++++--- .../Type/Fake/FindListGroupedUsage.php | 74 +++++++++++++++++++ ...ctQueryFindListReturnTypeExtensionTest.php | 11 +++ 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 tests/TestCase/Type/Fake/FindListGroupedUsage.php diff --git a/src/Type/SelectQueryFindListReturnTypeExtension.php b/src/Type/SelectQueryFindListReturnTypeExtension.php index 20478f0..73f5a4b 100644 --- a/src/Type/SelectQueryFindListReturnTypeExtension.php +++ b/src/Type/SelectQueryFindListReturnTypeExtension.php @@ -32,9 +32,12 @@ * When find('list') is detected in the method chain before toArray(), * this returns array instead of the generic entity array. * + * When groupField is used, returns array> + * * This handles chained queries like: * - $table->find('list')->toArray() * - $table->find('list')->where([...])->orderBy([...])->toArray() + * - $table->find('list', groupField: 'category_id')->toArray() */ class SelectQueryFindListReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -53,31 +56,41 @@ public function getTypeFromMethodCall( MethodCall $methodCall, Scope $scope, ): ?Type { - if ($this->hasFindListInChain($methodCall->var)) { - // Return array for find('list') + $findListCall = $this->findFindListCall($methodCall->var); + if ($findListCall === null) { + return null; + } + + $keyType = new UnionType([new IntegerType(), new StringType()]); + $valueType = new StringType(); + + // Check if groupField is present + if ($this->hasGroupField($findListCall)) { + // Return array> for grouped list return new ArrayType( - new UnionType([new IntegerType(), new StringType()]), - new StringType(), + $keyType, + new ArrayType($keyType, $valueType), ); } - return null; + // Return array for simple list + return new ArrayType($keyType, $valueType); } /** - * Recursively check if find('list') is in the method call chain + * Recursively find the find('list') call in the method call chain */ - private function hasFindListInChain(mixed $expr): bool + private function findFindListCall(mixed $expr): ?MethodCall { if (!$expr instanceof MethodCall) { - return false; + return null; } if ($this->isFindListCall($expr)) { - return true; + return $expr; } - return $this->hasFindListInChain($expr->var); + return $this->findFindListCall($expr->var); } /** @@ -105,4 +118,21 @@ private function isFindListCall(MethodCall $methodCall): bool return $firstArg->value === 'list'; } + + /** + * Check if the find('list') call has a groupField argument + */ + private function hasGroupField(MethodCall $methodCall): bool + { + $args = $methodCall->getArgs(); + + foreach ($args as $arg) { + // Check for named argument: groupField: 'something' + if ($arg->name instanceof Identifier && $arg->name->name === 'groupField') { + return true; + } + } + + return false; + } } diff --git a/tests/TestCase/Type/Fake/FindListGroupedUsage.php b/tests/TestCase/Type/Fake/FindListGroupedUsage.php new file mode 100644 index 0000000..49880ff --- /dev/null +++ b/tests/TestCase/Type/Fake/FindListGroupedUsage.php @@ -0,0 +1,74 @@ +> + */ + public function testFindListWithGroupField(): array + { + $table = $this->fetchTable('Articles'); + + // This should be inferred as array> + return $table->find('list', groupField: 'category_id')->toArray(); + } + + /** + * Test grouped list with chained methods + * + * @return array> + */ + public function testFindListGroupedWithChain(): array + { + $table = $this->fetchTable('Articles'); + + return $table->find('list', groupField: 'category_id') + ->where(['published' => true]) + ->orderBy(['title' => 'ASC']) + ->toArray(); + } + + /** + * Test iterating grouped list (proves nested type is correct) + * + * @return void + */ + public function testFindListGroupedIteration(): void + { + $table = $this->fetchTable('Articles'); + $grouped = $table->find('list', groupField: 'category_id')->toArray(); + + // Outer loop: groups + foreach ($grouped as $groupKey => $items) { + // Inner loop: items in group + foreach ($items as $itemKey => $value) { + // This would error if $value were not string + echo strlen($value); + } + } + } + + /** + * Test grouped list with all options + * + * @return array> + */ + public function testFindListGroupedWithAllFields(): array + { + $table = $this->fetchTable('Articles'); + + return $table->find('list', keyField: 'id', valueField: 'title', groupField: 'category_id')->toArray(); + } +} diff --git a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php index e6f9ffe..e61eae5 100644 --- a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php @@ -39,6 +39,17 @@ public function testFindListChainedQueriesReturnCorrectType(): void static::assertStringContainsString('[OK] No errors', $output); } + /** + * Test that find('list') with groupField returns nested array type. + * + * @return void + */ + public function testFindListWithGroupFieldReturnsNestedArray(): void + { + $output = $this->runPhpStan(__DIR__ . '/Fake/FindListGroupedUsage.php'); + static::assertStringContainsString('[OK] No errors', $output); + } + /** * Run PHPStan on a file and return the output. * From dc43964792d2554212306e45c5ce36df974d2198 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 25 Mar 2026 17:15:34 +0100 Subject: [PATCH 3/4] refactor: extract runPhpStan() to PhpStanTestTrait --- tests/TestCase/PhpStanTestTrait.php | 38 +++++++++++++++++++ ...ctQueryFindListReturnTypeExtensionTest.php | 23 +---------- ...oryBuildDynamicReturnTypeExtensionTest.php | 23 +---------- 3 files changed, 42 insertions(+), 42 deletions(-) create mode 100644 tests/TestCase/PhpStanTestTrait.php diff --git a/tests/TestCase/PhpStanTestTrait.php b/tests/TestCase/PhpStanTestTrait.php new file mode 100644 index 0000000..d2301e6 --- /dev/null +++ b/tests/TestCase/PhpStanTestTrait.php @@ -0,0 +1,38 @@ +&1', + escapeshellarg(dirname(__DIR__, 2)), + escapeshellarg($file), + escapeshellarg($configFile), + ); + + exec($command, $output, $exitCode); + + return implode("\n", $output); + } +} diff --git a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php index e61eae5..27dd2e1 100644 --- a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php @@ -13,10 +13,12 @@ namespace CakeDC\PHPStan\Test\TestCase\Type; +use CakeDC\PHPStan\Test\TestCase\PhpStanTestTrait; use PHPUnit\Framework\TestCase; class SelectQueryFindListReturnTypeExtensionTest extends TestCase { + use PhpStanTestTrait; /** * Test that find('list')->toArray() returns correct type. * @@ -49,25 +51,4 @@ public function testFindListWithGroupFieldReturnsNestedArray(): void $output = $this->runPhpStan(__DIR__ . '/Fake/FindListGroupedUsage.php'); static::assertStringContainsString('[OK] No errors', $output); } - - /** - * Run PHPStan on a file and return the output. - * - * @param string $file File to analyze - * @return string - */ - private function runPhpStan(string $file): string - { - $configFile = dirname(__DIR__, 3) . '/extension.neon'; - $command = sprintf( - 'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1', - escapeshellarg(dirname(__DIR__, 3)), - escapeshellarg($file), - escapeshellarg($configFile), - ); - - exec($command, $output, $exitCode); - - return implode("\n", $output); - } } diff --git a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php index 9ae3694..94020ba 100644 --- a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php @@ -13,10 +13,12 @@ namespace CakeDC\PHPStan\Test\TestCase\Type; +use CakeDC\PHPStan\Test\TestCase\PhpStanTestTrait; use PHPUnit\Framework\TestCase; class TypeFactoryBuildDynamicReturnTypeExtensionTest extends TestCase { + use PhpStanTestTrait; /** * Test that TypeFactory::build() returns correct types and allows valid method calls. * @@ -43,25 +45,4 @@ public function testTypeFactoryBuildCatchesInvalidMethodCalls(): void static::assertStringContainsString('JsonType::nonExistentMethod()', $output); static::assertStringContainsString('Found 4 errors', $output); } - - /** - * Run PHPStan on a file and return the output. - * - * @param string $file File to analyze - * @return string - */ - private function runPhpStan(string $file): string - { - $configFile = dirname(__DIR__, 3) . '/extension.neon'; - $command = sprintf( - 'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1', - escapeshellarg(dirname(__DIR__, 3)), - escapeshellarg($file), - escapeshellarg($configFile), - ); - - exec($command, $output, $exitCode); - - return implode("\n", $output); - } } From 5f5252233d0c2288c175291fe0b9167c3c52c0ce Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 25 Mar 2026 17:20:09 +0100 Subject: [PATCH 4/4] fix: resolve phpcs issues --- src/Type/SelectQueryFindListReturnTypeExtension.php | 9 +++++++++ tests/TestCase/Type/Fake/FindListCorrectUsage.php | 2 +- tests/TestCase/Type/Fake/FindListGroupedUsage.php | 4 ++-- .../Type/SelectQueryFindListReturnTypeExtensionTest.php | 1 + .../TypeFactoryBuildDynamicReturnTypeExtensionTest.php | 1 + 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Type/SelectQueryFindListReturnTypeExtension.php b/src/Type/SelectQueryFindListReturnTypeExtension.php index 73f5a4b..fbd2cd2 100644 --- a/src/Type/SelectQueryFindListReturnTypeExtension.php +++ b/src/Type/SelectQueryFindListReturnTypeExtension.php @@ -41,16 +41,25 @@ */ class SelectQueryFindListReturnTypeExtension implements DynamicMethodReturnTypeExtension { + /** + * @inheritDoc + */ public function getClass(): string { return SelectQuery::class; } + /** + * @inheritDoc + */ public function isMethodSupported(MethodReflection $methodReflection): bool { return $methodReflection->getName() === 'toArray'; } + /** + * @inheritDoc + */ public function getTypeFromMethodCall( MethodReflection $methodReflection, MethodCall $methodCall, diff --git a/tests/TestCase/Type/Fake/FindListCorrectUsage.php b/tests/TestCase/Type/Fake/FindListCorrectUsage.php index 3dc6957..e00883f 100644 --- a/tests/TestCase/Type/Fake/FindListCorrectUsage.php +++ b/tests/TestCase/Type/Fake/FindListCorrectUsage.php @@ -25,7 +25,7 @@ public function testFindList(): array $list = $table->find('list')->toArray(); // Iterating should work with string values - foreach ($list as $id => $title) { + foreach ($list as $title) { echo strlen($title); } diff --git a/tests/TestCase/Type/Fake/FindListGroupedUsage.php b/tests/TestCase/Type/Fake/FindListGroupedUsage.php index 49880ff..364cc3e 100644 --- a/tests/TestCase/Type/Fake/FindListGroupedUsage.php +++ b/tests/TestCase/Type/Fake/FindListGroupedUsage.php @@ -51,9 +51,9 @@ public function testFindListGroupedIteration(): void $grouped = $table->find('list', groupField: 'category_id')->toArray(); // Outer loop: groups - foreach ($grouped as $groupKey => $items) { + foreach ($grouped as $items) { // Inner loop: items in group - foreach ($items as $itemKey => $value) { + foreach ($items as $value) { // This would error if $value were not string echo strlen($value); } diff --git a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php index 27dd2e1..4da9c64 100644 --- a/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php @@ -19,6 +19,7 @@ class SelectQueryFindListReturnTypeExtensionTest extends TestCase { use PhpStanTestTrait; + /** * Test that find('list')->toArray() returns correct type. * diff --git a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php index 94020ba..6060df6 100644 --- a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php @@ -19,6 +19,7 @@ class TypeFactoryBuildDynamicReturnTypeExtensionTest extends TestCase { use PhpStanTestTrait; + /** * Test that TypeFactory::build() returns correct types and allows valid method calls. *