Skip to content

Commit 1a72c65

Browse files
committed
QueryResultTypeWalker: add option not to stringify expressions
1 parent 85def57 commit 1a72c65

7 files changed

+463
-26
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,19 @@ $query->getOneOrNullResult(Query::HYDRATE_OBJECT); // User
152152

153153
This is due to the design of the `Query` class preventing from determining the hydration mode used by these functions unless it is specified explicitly during the call.
154154

155+
### Numeric types inferring
156+
157+
By default, any expression like `MAX(e.id)` results in `int|numeric-string` as certain drivers & PHP versions do cast the result to string.
158+
If you are using setup that does not do that, you can disable stringification of such expressions by setting `parameters.doctrine.stringifyExpressions` to `false`.
159+
160+
This should be accurate behaviour for [PHP 8.1.25+ with PDO driver](https://github.com/php/php-src/blob/php-8.1.25/UPGRADING#L122-L139) and disabled `PDO::ATTR_STRINGIFY_FETCHES` (which is default).
161+
162+
```neon
163+
parameters:
164+
doctrine:
165+
stringifyExpressions: false
166+
```
167+
155168
## Custom types
156169

157170
If your application uses custom Doctrine types, you can write your own type descriptors to analyse them properly.

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ parameters:
88
objectManagerLoader: null
99
searchOtherMethodsForQueryBuilderBeginning: true
1010
queryBuilderFastAlgorithm: false
11+
stringifyExpressions: true
1112
featureToggles:
1213
skipCheckGenericClasses:
1314
- Doctrine\ODM\MongoDB\Mapping\ClassMetadata
@@ -65,6 +66,7 @@ parametersSchema:
6566
objectManagerLoader: schema(string(), nullable())
6667
searchOtherMethodsForQueryBuilderBeginning: bool()
6768
queryBuilderFastAlgorithm: bool()
69+
stringifyExpressions: bool()
6870
])
6971

7072
conditionalTags:
@@ -115,6 +117,7 @@ services:
115117
arguments:
116118
queryBuilderClass: %doctrine.queryBuilderClass%
117119
argumentsProcessor: @doctrineQueryBuilderArgumentsProcessor
120+
stringifyExpressions: %doctrine.stringifyExpressions%
118121
tags:
119122
- phpstan.broker.dynamicMethodReturnTypeExtension
120123
-
@@ -138,6 +141,7 @@ services:
138141
class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension
139142
arguments:
140143
objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
144+
stringifyExpressions: %doctrine.stringifyExpressions%
141145
tags:
142146
- phpstan.broker.dynamicMethodReturnTypeExtension
143147
-

rules.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ parametersSchema:
1717
reportDynamicQueryBuilders: bool()
1818
reportUnknownTypes: bool()
1919
allowNullablePropertyForRequiredField: bool()
20+
stringifyExpressions: bool()
2021
])
2122

2223
rules:

src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturn
3636
/** @var DescriptorRegistry */
3737
private $descriptorRegistry;
3838

39-
public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry)
39+
/** @var bool */
40+
private $stringifyExpressions;
41+
42+
public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry, bool $stringifyExpressions)
4043
{
4144
$this->objectMetadataResolver = $objectMetadataResolver;
4245
$this->descriptorRegistry = $descriptorRegistry;
46+
$this->stringifyExpressions = $stringifyExpressions;
4347
}
4448

4549
public function getClass(): string
@@ -86,7 +90,7 @@ public function getTypeFromMethodCall(
8690

8791
try {
8892
$query = $em->createQuery($queryString);
89-
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry);
93+
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->stringifyExpressions);
9094
} catch (ORMException | DBALException | NewDBALException | CommonException $e) {
9195
return new QueryType($queryString, null, null);
9296
} catch (AssertionError $e) {

src/Type/Doctrine/Query/QueryResultTypeWalker.php

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PHPStan\Type\Doctrine\Query;
44

55
use BackedEnum;
6+
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
67
use Doctrine\ORM\EntityManagerInterface;
78
use Doctrine\ORM\Mapping\ClassMetadata;
89
use Doctrine\ORM\Mapping\ClassMetadataInfo;
@@ -42,11 +43,13 @@
4243
use function get_class;
4344
use function gettype;
4445
use function intval;
46+
use function is_bool;
4547
use function is_numeric;
4648
use function is_object;
4749
use function is_string;
4850
use function serialize;
4951
use function sprintf;
52+
use function strpos;
5053
use function strtolower;
5154
use function unserialize;
5255

@@ -64,6 +67,8 @@ class QueryResultTypeWalker extends SqlWalker
6467

6568
private const HINT_DESCRIPTOR_REGISTRY = self::class . '::HINT_DESCRIPTOR_REGISTRY';
6669

70+
private const HINT_STRINGIFY_EXPRESSIONS = self::class . '::HINT_STRINGIFY_EXPRESSIONS';
71+
6772
/**
6873
* Counter for generating unique scalar result.
6974
*
@@ -106,14 +111,18 @@ class QueryResultTypeWalker extends SqlWalker
106111
/** @var bool */
107112
private $hasGroupByClause;
108113

114+
/** @var bool */
115+
private $stringifyExpressions;
116+
109117
/**
110118
* @param Query<mixed> $query
111119
*/
112-
public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry): void
120+
public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry, bool $stringifyExpressions): void
113121
{
114122
$query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, self::class);
115123
$query->setHint(self::HINT_TYPE_MAPPING, $typeBuilder);
116124
$query->setHint(self::HINT_DESCRIPTOR_REGISTRY, $descriptorRegistry);
125+
$query->setHint(self::HINT_STRINGIFY_EXPRESSIONS, $stringifyExpressions);
117126

118127
$parser = new Parser($query);
119128
$parser->parse();
@@ -165,6 +174,19 @@ public function __construct($query, $parserResult, array $queryComponents)
165174

166175
$this->descriptorRegistry = $descriptorRegistry;
167176

177+
$stringifyExpressions = $this->query->getHint(self::HINT_STRINGIFY_EXPRESSIONS);
178+
179+
if (!is_bool($stringifyExpressions)) {
180+
throw new ShouldNotHappenException(sprintf(
181+
'Expected the query hint %s to contain a %s, but got a %s',
182+
self::HINT_STRINGIFY_EXPRESSIONS,
183+
'boolean',
184+
is_object($stringifyExpressions) ? get_class($stringifyExpressions) : gettype($stringifyExpressions)
185+
));
186+
}
187+
188+
$this->stringifyExpressions = $stringifyExpressions;
189+
168190
parent::__construct($query, $parserResult, $queryComponents);
169191
}
170192

@@ -834,7 +856,7 @@ public function walkSelectExpression($selectExpression)
834856
}
835857
return $enforcedType;
836858
});
837-
} else {
859+
} elseif ($this->stringifyExpressions) {
838860
// Expressions default to Doctrine's StringType, whose
839861
// convertToPHPValue() is a no-op. So the actual type depends on
840862
// the driver and PHP version.
@@ -1108,7 +1130,21 @@ public function walkLiteral($literal)
11081130
if (floatval(intval($value)) === floatval($value)) {
11091131
$type = new ConstantIntegerType((int) $value);
11101132
} else {
1111-
$type = new ConstantFloatType((float) $value);
1133+
1134+
if ($this->em->getConnection()->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
1135+
1136+
// both PDO_mysql and mysqli hydrates 123.4 literals as string no matter the configuration (e.g. PDO::ATTR_STRINGIFY_FETCHES being false) and PHP version
1137+
// the only way to force float is to use 123.4e0 scientific notation
1138+
// https://dev.mysql.com/doc/refman/8.0/en/number-literals.html
1139+
1140+
if (strpos((string) $value, 'e') !== false || strpos((string) $value, 'E') !== false) {
1141+
$type = new ConstantFloatType((float) $value);
1142+
} else {
1143+
$type = new ConstantStringType((string) (float) $value);
1144+
}
1145+
} else {
1146+
$type = new ConstantFloatType((float) $value);
1147+
}
11121148
}
11131149

11141150
break;

src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,24 @@ class QueryBuilderGetQueryDynamicReturnTypeExtension implements DynamicMethodRet
6868
/** @var OtherMethodQueryBuilderParser */
6969
private $otherMethodQueryBuilderParser;
7070

71+
/** @var bool */
72+
private $stringifyExpressions;
73+
7174
public function __construct(
7275
ObjectMetadataResolver $objectMetadataResolver,
7376
ArgumentsProcessor $argumentsProcessor,
7477
?string $queryBuilderClass,
7578
DescriptorRegistry $descriptorRegistry,
76-
OtherMethodQueryBuilderParser $otherMethodQueryBuilderParser
79+
OtherMethodQueryBuilderParser $otherMethodQueryBuilderParser,
80+
bool $stringifyExpressions
7781
)
7882
{
7983
$this->objectMetadataResolver = $objectMetadataResolver;
8084
$this->argumentsProcessor = $argumentsProcessor;
8185
$this->queryBuilderClass = $queryBuilderClass;
8286
$this->descriptorRegistry = $descriptorRegistry;
8387
$this->otherMethodQueryBuilderParser = $otherMethodQueryBuilderParser;
88+
$this->stringifyExpressions = $stringifyExpressions;
8489
}
8590

8691
public function getClass(): string
@@ -202,7 +207,7 @@ private function getQueryType(string $dql): Type
202207

203208
try {
204209
$query = $em->createQuery($dql);
205-
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry);
210+
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->stringifyExpressions);
206211
} catch (ORMException | DBALException | CommonException $e) {
207212
return new QueryType($dql, null);
208213
} catch (AssertionError $e) {

0 commit comments

Comments
 (0)