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..fbd2cd2 --- /dev/null +++ b/src/Type/SelectQueryFindListReturnTypeExtension.php @@ -0,0 +1,147 @@ +toArray() + * + * 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 +{ + /** + * @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, + Scope $scope, + ): ?Type { + $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( + $keyType, + new ArrayType($keyType, $valueType), + ); + } + + // Return array for simple list + return new ArrayType($keyType, $valueType); + } + + /** + * Recursively find the find('list') call in the method call chain + */ + private function findFindListCall(mixed $expr): ?MethodCall + { + if (!$expr instanceof MethodCall) { + return null; + } + + if ($this->isFindListCall($expr)) { + return $expr; + } + + return $this->findFindListCall($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'; + } + + /** + * 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/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/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..e00883f --- /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 $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/Fake/FindListGroupedUsage.php b/tests/TestCase/Type/Fake/FindListGroupedUsage.php new file mode 100644 index 0000000..364cc3e --- /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 $items) { + // Inner loop: items in group + foreach ($items as $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 new file mode 100644 index 0000000..4da9c64 --- /dev/null +++ b/tests/TestCase/Type/SelectQueryFindListReturnTypeExtensionTest.php @@ -0,0 +1,55 @@ +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); + } + + /** + * 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); + } +} diff --git a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php index 9ae3694..6060df6 100644 --- a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php +++ b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php @@ -13,10 +13,13 @@ 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 +46,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); - } }