Skip to content

Commit a23654a

Browse files
simPodclaude
andcommitted
fix: handle first-class callable crash on Expr methods
When first-class callable syntax ($expr->in(...)) is used inside match arms or assigned to variables, PHPStan resolves the closure and visits the extension with a MethodCall where isFirstClassCallable() is false and getArgs() is empty. This causes the actual Expr method to be called with 0 arguments, crashing with ArgumentCountError. Fix with try/catch Throwable around the reflective method invocation for indirect cases (match arms, variable assignments). We also need to cover TypeError from ORM 3.x's typed signatures, plus any other unexpected exception from the reflective call. Closes #711 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9763452 commit a23654a

4 files changed

Lines changed: 97 additions & 2 deletions

File tree

src/Type/Doctrine/QueryBuilder/Expr/BaseExpressionDynamicReturnTypeExtension.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Type\Doctrine\ArgumentsProcessor;
1010
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1111
use PHPStan\Type\Type;
12+
use Throwable;
1213
use function get_class;
1314
use function in_array;
1415
use function is_object;
@@ -55,7 +56,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
5556
return null;
5657
}
5758

58-
$exprValue = $expr->{$methodReflection->getName()}(...$args);
59+
try {
60+
$exprValue = $expr->{$methodReflection->getName()}(...$args);
61+
} catch (Throwable $e) {
62+
return null;
63+
}
64+
5965
if (is_object($exprValue)) {
6066
return new ExprType(get_class($exprValue), $exprValue);
6167
}

src/Type/Doctrine/QueryBuilder/Expr/ExpressionBuilderDynamicReturnTypeExtension.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
1212
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1313
use PHPStan\Type\Type;
14+
use Throwable;
1415
use function get_class;
1516
use function is_object;
1617
use function method_exists;
@@ -74,7 +75,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
7475
return null;
7576
}
7677

77-
$exprValue = $expr->{$methodReflection->getName()}(...$args);
78+
try {
79+
$exprValue = $expr->{$methodReflection->getName()}(...$args);
80+
} catch (Throwable $e) {
81+
return null;
82+
}
83+
7884
if (is_object($exprValue)) {
7985
return new ExprType(get_class($exprValue), $exprValue);
8086
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
8+
9+
/**
10+
* @extends RuleTestCase<QueryBuilderDqlRule>
11+
*/
12+
class QueryBuilderDqlFirstClassCallableTest extends RuleTestCase
13+
{
14+
15+
protected function getRule(): Rule
16+
{
17+
return new QueryBuilderDqlRule(
18+
new ObjectMetadataResolver(__DIR__ . '/entity-manager.php', __DIR__ . '/../../../../tmp'),
19+
true,
20+
);
21+
}
22+
23+
public function testFirstClassCallableInMatchArm(): void
24+
{
25+
$this->analyse([__DIR__ . '/data/query-builder-first-class-callable.php'], []);
26+
}
27+
28+
public static function getAdditionalConfigFiles(): array
29+
{
30+
return [
31+
__DIR__ . '/../../../../extension.neon',
32+
__DIR__ . '/entity-manager.neon',
33+
];
34+
}
35+
36+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace PHPStan\Rules\Doctrine\ORM;
4+
5+
use Doctrine\ORM\EntityManager;
6+
7+
class TestFirstClassCallableExpr
8+
{
9+
10+
/** @var EntityManager */
11+
private $entityManager;
12+
13+
public function __construct(EntityManager $entityManager)
14+
{
15+
$this->entityManager = $entityManager;
16+
}
17+
18+
public function matchArmWithFirstClassCallable(): void
19+
{
20+
$queryBuilder = $this->entityManager->createQueryBuilder();
21+
$expr = $queryBuilder->expr();
22+
23+
$comparator = match (random_int(0, 1)) {
24+
0 => $expr->in(...),
25+
1 => $expr->notIn(...),
26+
};
27+
28+
$queryBuilder->select('e')
29+
->from(MyEntity::class, 'e')
30+
->andWhere($comparator('e.id', [1, 2, 3]));
31+
$queryBuilder->getQuery();
32+
}
33+
34+
public function variableWithFirstClassCallable(): void
35+
{
36+
$queryBuilder = $this->entityManager->createQueryBuilder();
37+
$expr = $queryBuilder->expr();
38+
39+
$fn = $expr->eq(...);
40+
41+
$queryBuilder->select('e')
42+
->from(MyEntity::class, 'e')
43+
->andWhere($fn('e.id', '1'));
44+
$queryBuilder->getQuery();
45+
}
46+
47+
}

0 commit comments

Comments
 (0)