Skip to content

Commit ef3d5a1

Browse files
simPodclaude
authored 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. Catch Throwable (not just ArgumentCountError) around the reflective method invocation to also handle TypeError from typed signatures in ORM 3.x and other potential exceptions. Closes #711 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f666176 commit ef3d5a1

5 files changed

Lines changed: 100 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+
}

tests/Type/Doctrine/data/QueryResult/baseExpressionAndOr.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@
1414

1515
$string = $and->__toString();
1616
assertType("string", $string);
17+
18+
$invalidAdd = $and->add(123);
19+
assertType("Doctrine\ORM\Query\Expr\Andx", $invalidAdd);

0 commit comments

Comments
 (0)