Skip to content

Commit 661c370

Browse files
committed
Autodetect stringification behaviour
1 parent 6a271d9 commit 661c370

File tree

6 files changed

+185
-493
lines changed

6 files changed

+185
-493
lines changed

extension.neon

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ parameters:
88
objectManagerLoader: null
99
searchOtherMethodsForQueryBuilderBeginning: true
1010
queryBuilderFastAlgorithm: false
11-
stringifyExpressions: true
1211
featureToggles:
1312
skipCheckGenericClasses:
1413
- Doctrine\ODM\MongoDB\Mapping\ClassMetadata
@@ -66,7 +65,6 @@ parametersSchema:
6665
objectManagerLoader: schema(string(), nullable())
6766
searchOtherMethodsForQueryBuilderBeginning: bool()
6867
queryBuilderFastAlgorithm: bool()
69-
stringifyExpressions: bool()
7068
])
7169

7270
conditionalTags:
@@ -117,7 +115,6 @@ services:
117115
arguments:
118116
queryBuilderClass: %doctrine.queryBuilderClass%
119117
argumentsProcessor: @doctrineQueryBuilderArgumentsProcessor
120-
stringifyExpressions: %doctrine.stringifyExpressions%
121118
tags:
122119
- phpstan.broker.dynamicMethodReturnTypeExtension
123120
-
@@ -141,7 +138,6 @@ services:
141138
class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension
142139
arguments:
143140
objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
144-
stringifyExpressions: %doctrine.stringifyExpressions%
145141
tags:
146142
- phpstan.broker.dynamicMethodReturnTypeExtension
147143
-

rules.neon

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ parametersSchema:
1717
reportDynamicQueryBuilders: bool()
1818
reportUnknownTypes: bool()
1919
allowNullablePropertyForRequiredField: bool()
20-
stringifyExpressions: bool()
2120
])
2221

2322
rules:

src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Doctrine\ORM\Query;
1212
use PhpParser\Node\Expr\MethodCall;
1313
use PHPStan\Analyser\Scope;
14+
use PHPStan\Php\PhpVersion;
1415
use PHPStan\Reflection\MethodReflection;
1516
use PHPStan\Type\Constant\ConstantStringType;
1617
use PHPStan\Type\Doctrine\Query\QueryResultTypeBuilder;
@@ -36,14 +37,14 @@ final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturn
3637
/** @var DescriptorRegistry */
3738
private $descriptorRegistry;
3839

39-
/** @var bool */
40-
private $stringifyExpressions;
40+
/** @var PhpVersion */
41+
private $phpVersion;
4142

42-
public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry, bool $stringifyExpressions)
43+
public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry, PhpVersion $phpVersion)
4344
{
4445
$this->objectMetadataResolver = $objectMetadataResolver;
4546
$this->descriptorRegistry = $descriptorRegistry;
46-
$this->stringifyExpressions = $stringifyExpressions;
47+
$this->phpVersion = $phpVersion;
4748
}
4849

4950
public function getClass(): string
@@ -90,7 +91,7 @@ public function getTypeFromMethodCall(
9091

9192
try {
9293
$query = $em->createQuery($queryString);
93-
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->stringifyExpressions);
94+
QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->phpVersion);
9495
} catch (ORMException | DBALException | NewDBALException | CommonException $e) {
9596
return new QueryType($queryString, null, null);
9697
} catch (AssertionError $e) {

src/Type/Doctrine/Query/QueryResultTypeWalker.php

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

55
use BackedEnum;
6-
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
6+
use Doctrine\DBAL\Driver\Mysqli\Driver as MysqliDriver;
7+
use Doctrine\DBAL\Driver\PDO\MySQL\Driver as PdoMysqlDriver;
8+
use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PdoPgSQLDriver;
9+
use Doctrine\DBAL\Driver\PDO\SQLite\Driver as PdoSQLiteDriver;
10+
use Doctrine\DBAL\Driver\PgSQL\Driver as PgSQLDriver;
11+
use Doctrine\DBAL\Driver\SQLite3\Driver as SQLite3Driver;
712
use Doctrine\ORM\EntityManagerInterface;
813
use Doctrine\ORM\Mapping\ClassMetadata;
914
use Doctrine\ORM\Mapping\ClassMetadataInfo;
@@ -13,8 +18,13 @@
1318
use Doctrine\ORM\Query\Parser;
1419
use Doctrine\ORM\Query\ParserResult;
1520
use Doctrine\ORM\Query\SqlWalker;
21+
use PDO;
22+
use PDOException;
23+
use PHPStan\Php\PhpVersion;
1624
use PHPStan\ShouldNotHappenException;
25+
use PHPStan\TrinaryLogic;
1726
use PHPStan\Type\BooleanType;
27+
use PHPStan\Type\Constant\ConstantBooleanType;
1828
use PHPStan\Type\Constant\ConstantFloatType;
1929
use PHPStan\Type\Constant\ConstantIntegerType;
2030
use PHPStan\Type\Constant\ConstantStringType;
@@ -43,7 +53,6 @@
4353
use function get_class;
4454
use function gettype;
4555
use function intval;
46-
use function is_bool;
4756
use function is_numeric;
4857
use function is_object;
4958
use function is_string;
@@ -67,7 +76,7 @@ class QueryResultTypeWalker extends SqlWalker
6776

6877
private const HINT_DESCRIPTOR_REGISTRY = self::class . '::HINT_DESCRIPTOR_REGISTRY';
6978

70-
private const HINT_STRINGIFY_EXPRESSIONS = self::class . '::HINT_STRINGIFY_EXPRESSIONS';
79+
private const HINT_PHP_VERSION = self::class . '::HINT_PHP_VERSION';
7180

7281
/**
7382
* Counter for generating unique scalar result.
@@ -89,6 +98,9 @@ class QueryResultTypeWalker extends SqlWalker
8998
/** @var EntityManagerInterface */
9099
private $em;
91100

101+
/** @var PhpVersion */
102+
private $phpVersion;
103+
92104
/**
93105
* Map of all components/classes that appear in the DQL query.
94106
*
@@ -111,18 +123,15 @@ class QueryResultTypeWalker extends SqlWalker
111123
/** @var bool */
112124
private $hasGroupByClause;
113125

114-
/** @var bool */
115-
private $stringifyExpressions;
116-
117126
/**
118127
* @param Query<mixed> $query
119128
*/
120-
public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry, bool $stringifyExpressions): void
129+
public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry, PhpVersion $phpVersion): void
121130
{
122131
$query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, self::class);
123132
$query->setHint(self::HINT_TYPE_MAPPING, $typeBuilder);
124133
$query->setHint(self::HINT_DESCRIPTOR_REGISTRY, $descriptorRegistry);
125-
$query->setHint(self::HINT_STRINGIFY_EXPRESSIONS, $stringifyExpressions);
134+
$query->setHint(self::HINT_PHP_VERSION, $phpVersion);
126135

127136
$parser = new Parser($query);
128137
$parser->parse();
@@ -174,18 +183,18 @@ public function __construct($query, $parserResult, array $queryComponents)
174183

175184
$this->descriptorRegistry = $descriptorRegistry;
176185

177-
$stringifyExpressions = $this->query->getHint(self::HINT_STRINGIFY_EXPRESSIONS);
186+
$phpVersion = $this->query->getHint(self::HINT_PHP_VERSION);
178187

179-
if (!is_bool($stringifyExpressions)) {
188+
if (!$phpVersion instanceof PhpVersion) { // @phpstan-ignore-line ignore bc promise
180189
throw new ShouldNotHappenException(sprintf(
181190
'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)
191+
self::HINT_PHP_VERSION,
192+
PhpVersion::class,
193+
is_object($phpVersion) ? get_class($phpVersion) : gettype($phpVersion)
185194
));
186195
}
187196

188-
$this->stringifyExpressions = $stringifyExpressions;
197+
$this->phpVersion = $phpVersion;
189198

190199
parent::__construct($query, $parserResult, $queryComponents);
191200
}
@@ -856,21 +865,37 @@ public function walkSelectExpression($selectExpression)
856865
}
857866
return $enforcedType;
858867
});
859-
} elseif ($this->stringifyExpressions) {
868+
} else {
860869
// Expressions default to Doctrine's StringType, whose
861870
// convertToPHPValue() is a no-op. So the actual type depends on
862871
// the driver and PHP version.
863-
// Here we assume that the value may or may not be casted to
864-
// string by the driver.
865-
$type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type {
872+
873+
$type = TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
866874
if ($type instanceof UnionType || $type instanceof IntersectionType) {
867875
return $traverse($type);
868876
}
877+
869878
if ($type instanceof IntegerType || $type instanceof FloatType) {
870-
return TypeCombinator::union($type->toString(), $type);
879+
$stringify = $this->shouldStringifyExpressions($type);
880+
881+
if ($stringify->yes()) {
882+
return $type->toString();
883+
} elseif ($stringify->maybe()) {
884+
return TypeCombinator::union($type->toString(), $type);
885+
}
886+
887+
return $type;
871888
}
872889
if ($type instanceof BooleanType) {
873-
return TypeCombinator::union($type->toInteger()->toString(), $type);
890+
$stringify = $this->shouldStringifyExpressions($type);
891+
892+
if ($stringify->yes()) {
893+
return $type->toString();
894+
} elseif ($stringify->maybe()) {
895+
return TypeCombinator::union($type->toInteger()->toString(), $type);
896+
}
897+
898+
return $type;
874899
}
875900
return $traverse($type);
876901
});
@@ -1111,6 +1136,8 @@ public function walkInParameter($inParam)
11111136
*/
11121137
public function walkLiteral($literal)
11131138
{
1139+
$driver = $this->em->getConnection()->getDriver();
1140+
11141141
switch ($literal->type) {
11151142
case AST\Literal::STRING:
11161143
$value = $literal->value;
@@ -1119,8 +1146,12 @@ public function walkLiteral($literal)
11191146
break;
11201147

11211148
case AST\Literal::BOOLEAN:
1122-
$value = strtolower($literal->value) === 'true' ? 1 : 0;
1123-
$type = new ConstantIntegerType($value);
1149+
$value = strtolower($literal->value) === 'true';
1150+
if ($driver instanceof PdoPgSQLDriver || $driver instanceof PgSQLDriver) {
1151+
$type = new ConstantBooleanType($value);
1152+
} else {
1153+
$type = new ConstantIntegerType($value ? 1 : 0);
1154+
}
11241155
break;
11251156

11261157
case AST\Literal::NUMERIC:
@@ -1130,9 +1161,7 @@ public function walkLiteral($literal)
11301161
if (floatval(intval($value)) === floatval($value)) {
11311162
$type = new ConstantIntegerType((int) $value);
11321163
} else {
1133-
1134-
if ($this->em->getConnection()->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
1135-
1164+
if ($driver instanceof PdoMysqlDriver || $driver instanceof MysqliDriver) {
11361165
// both pdo_mysql and mysqli hydrates decimal literal (e.g. 123.4) as string no matter the configuration (e.g. PDO::ATTR_STRINGIFY_FETCHES being false) and PHP version
11371166
// the only way to force float is to use float literal with scientific notation (e.g. 123.4e0)
11381167
// https://dev.mysql.com/doc/refman/8.0/en/number-literals.html
@@ -1459,4 +1488,105 @@ private function hasAggregateFunction(AST\SelectStatement $AST): bool
14591488
return false;
14601489
}
14611490

1491+
/**
1492+
* See analysis: https://github.com/janedbal/php-database-drivers-fetch-test
1493+
*
1494+
* Notable 8.1 changes:
1495+
* - pdo_mysql: https://github.com/php/php-src/commit/c18b1aea289e8ed6edb3f6e6a135018976a034c6
1496+
* - pdo_sqlite: https://github.com/php/php-src/commit/438b025a28cda2935613af412fc13702883dd3a2
1497+
* - pdo_pgsql: https://github.com/php/php-src/commit/737195c3ae6ac53b9501cfc39cc80fd462909c82
1498+
*
1499+
* @param IntegerType|FloatType|BooleanType $type
1500+
*/
1501+
private function shouldStringifyExpressions(Type $type): TrinaryLogic
1502+
{
1503+
$driver = $this->em->getConnection()->getDriver();
1504+
$nativeConnection = $this->em->getConnection()->getNativeConnection();
1505+
1506+
if ($nativeConnection instanceof PDO) {
1507+
$stringifyFetches = $this->isPdoStringifyEnabled($nativeConnection);
1508+
1509+
if ($driver instanceof PdoMysqlDriver) {
1510+
$emulatedPrepares = $this->isPdoEmulatePreparesEnabled($nativeConnection);
1511+
1512+
if ($stringifyFetches) {
1513+
return TrinaryLogic::createYes();
1514+
}
1515+
1516+
if ($this->phpVersion->getVersionId() >= 80100) {
1517+
return TrinaryLogic::createNo(); // DECIMAL / FLOAT already decided in walkLiteral
1518+
}
1519+
1520+
if ($emulatedPrepares) {
1521+
return TrinaryLogic::createYes();
1522+
}
1523+
1524+
return TrinaryLogic::createNo();
1525+
}
1526+
1527+
if ($driver instanceof PdoSqliteDriver) {
1528+
if ($stringifyFetches) {
1529+
return TrinaryLogic::createYes();
1530+
}
1531+
1532+
if ($this->phpVersion->getVersionId() >= 80100) {
1533+
return TrinaryLogic::createNo();
1534+
}
1535+
1536+
return TrinaryLogic::createYes();
1537+
}
1538+
1539+
if ($driver instanceof PdoPgSQLDriver) {
1540+
if ($type->isBoolean()->yes()) {
1541+
if ($this->phpVersion->getVersionId() >= 80100) {
1542+
return TrinaryLogic::createFromBoolean($stringifyFetches);
1543+
}
1544+
1545+
return TrinaryLogic::createNo();
1546+
1547+
} elseif ($type->isFloat()->yes()) {
1548+
return TrinaryLogic::createYes();
1549+
1550+
} elseif ($type->isInteger()->yes()) {
1551+
return TrinaryLogic::createFromBoolean($stringifyFetches);
1552+
}
1553+
}
1554+
}
1555+
1556+
if ($driver instanceof PgSQLDriver) {
1557+
if ($type->isBoolean()->yes()) {
1558+
return TrinaryLogic::createNo();
1559+
} elseif ($type->isFloat()->yes()) {
1560+
return TrinaryLogic::createYes();
1561+
} elseif ($type->isInteger()->yes()) {
1562+
return TrinaryLogic::createNo();
1563+
}
1564+
}
1565+
1566+
if ($driver instanceof SQLite3Driver) {
1567+
return TrinaryLogic::createNo();
1568+
}
1569+
1570+
if ($driver instanceof MysqliDriver) {
1571+
return TrinaryLogic::createNo(); // DECIMAL / FLOAT already decided in walkLiteral
1572+
}
1573+
1574+
return TrinaryLogic::createMaybe();
1575+
}
1576+
1577+
private function isPdoStringifyEnabled(PDO $pdo): bool
1578+
{
1579+
// this fails for most PHP versions, see https://github.com/php/php-src/issues/12969
1580+
try {
1581+
return (bool) $pdo->getAttribute(PDO::ATTR_STRINGIFY_FETCHES);
1582+
} catch (PDOException $e) {
1583+
return false; // default
1584+
}
1585+
}
1586+
1587+
private function isPdoEmulatePreparesEnabled(PDO $pdo): bool
1588+
{
1589+
return (bool) $pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES);
1590+
}
1591+
14621592
}

0 commit comments

Comments
 (0)