Skip to content

Commit 238be97

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 two guards: - isFirstClassCallable() for direct first-class callable syntax - try/catch ArgumentCountError around the reflective method invocation for indirect cases (match arms, variable assignments) Closes #711 Co-Authored-By: Simon Podlipsky <simon@podlipsky.net> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9763452 commit 238be97

4 files changed

Lines changed: 105 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Type\Doctrine\QueryBuilder\Expr;
44

5+
use ArgumentCountError;
56
use PhpParser\Node\Expr\MethodCall;
67
use PHPStan\Analyser\Scope;
78
use PHPStan\Reflection\MethodReflection;
@@ -38,6 +39,10 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
3839

3940
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
4041
{
42+
if ($methodCall->isFirstClassCallable()) {
43+
return null;
44+
}
45+
4146
try {
4247
$args = $this->argumentsProcessor->processArgs($scope, $methodReflection->getName(), $methodCall->getArgs());
4348
} catch (DynamicQueryBuilderArgumentException $e) {
@@ -55,7 +60,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
5560
return null;
5661
}
5762

58-
$exprValue = $expr->{$methodReflection->getName()}(...$args);
63+
try {
64+
$exprValue = $expr->{$methodReflection->getName()}(...$args);
65+
} catch (ArgumentCountError) {
66+
return null;
67+
}
68+
5969
if (is_object($exprValue)) {
6070
return new ExprType(get_class($exprValue), $exprValue);
6171
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Type\Doctrine\QueryBuilder\Expr;
44

5+
use ArgumentCountError;
56
use Doctrine\ORM\EntityManagerInterface;
67
use PhpParser\Node\Expr\MethodCall;
78
use PHPStan\Analyser\Scope;
@@ -43,6 +44,10 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
4344

4445
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
4546
{
47+
if ($methodCall->isFirstClassCallable()) {
48+
return null;
49+
}
50+
4651
$objectManager = $this->objectMetadataResolver->getObjectManager();
4752
if ($objectManager === null) {
4853
return null;
@@ -74,7 +79,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
7479
return null;
7580
}
7681

77-
$exprValue = $expr->{$methodReflection->getName()}(...$args);
82+
try {
83+
$exprValue = $expr->{$methodReflection->getName()}(...$args);
84+
} catch (ArgumentCountError) {
85+
return null;
86+
}
87+
7888
if (is_object($exprValue)) {
7989
return new ExprType(get_class($exprValue), $exprValue);
8090
}
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);
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)