Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ services:
class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
-
class: CakeDC\PHPStan\Type\SelectQueryFindListReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
147 changes: 147 additions & 0 deletions src/Type/SelectQueryFindListReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);

/**
* Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2024, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Type;

use Cake\ORM\Query\SelectQuery;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;

/**
* PHPStan extension to provide proper return types for find('list')->toArray()
*
* When find('list') is detected in the method chain before toArray(),
* this returns array<int|string, string> instead of the generic entity array.
*
* When groupField is used, returns array<int|string, array<int|string, string>>
*
* 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<int|string, array<int|string, string>> for grouped list
return new ArrayType(
$keyType,
new ArrayType($keyType, $valueType),
);
}

// Return array<int|string, string> 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;
}
}
38 changes: 38 additions & 0 deletions tests/TestCase/PhpStanTestTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Test\TestCase;

trait PhpStanTestTrait
{
/**
* 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__, 2) . '/extension.neon';
$command = sprintf(
'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1',
escapeshellarg(dirname(__DIR__, 2)),
escapeshellarg($file),
escapeshellarg($configFile),
);

exec($command, $output, $exitCode);

return implode("\n", $output);
}
}
62 changes: 62 additions & 0 deletions tests/TestCase/Type/Fake/FindListChainedUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use Cake\ORM\Locator\LocatorAwareTrait;

/**
* Test file for find('list') with chained method calls.
*/
class FindListChainedUsage
{
use LocatorAwareTrait;

/**
* Test find('list') with where clause
*
* @return array<int|string, string>
*/
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<int|string, string>
*/
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);
}
}
}
46 changes: 46 additions & 0 deletions tests/TestCase/Type/Fake/FindListCorrectUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use Cake\ORM\Locator\LocatorAwareTrait;

/**
* Test file for find('list') return type extension.
*/
class FindListCorrectUsage
{
use LocatorAwareTrait;

/**
* Test that find('list') returns array<int|string, string>
*
* @return array<int|string, string>
*/
public function testFindList(): array
{
$table = $this->fetchTable('Articles');

// This should be inferred as array<int|string, string>
$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<int|string, string>
*/
public function testFindListWithFields(): array
{
$table = $this->fetchTable('Articles');

return $table->find('list', keyField: 'id', valueField: 'title')->toArray();
}
}
74 changes: 74 additions & 0 deletions tests/TestCase/Type/Fake/FindListGroupedUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use Cake\ORM\Locator\LocatorAwareTrait;

/**
* Test file for find('list') with groupField return type extension.
*/
class FindListGroupedUsage
{
use LocatorAwareTrait;

/**
* Test that find('list') with groupField returns nested array
*
* @return array<int|string, array<int|string, string>>
*/
public function testFindListWithGroupField(): array
{
$table = $this->fetchTable('Articles');

// This should be inferred as array<int|string, array<int|string, string>>
return $table->find('list', groupField: 'category_id')->toArray();
}

/**
* Test grouped list with chained methods
*
* @return array<int|string, array<int|string, string>>
*/
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<int|string, array<int|string, string>>
*/
public function testFindListGroupedWithAllFields(): array
{
$table = $this->fetchTable('Articles');

return $table->find('list', keyField: 'id', valueField: 'title', groupField: 'category_id')->toArray();
}
}
Loading
Loading