33namespace PHPStan \Type \Doctrine \Query ;
44
55use 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 ;
712use Doctrine \ORM \EntityManagerInterface ;
813use Doctrine \ORM \Mapping \ClassMetadata ;
914use Doctrine \ORM \Mapping \ClassMetadataInfo ;
1318use Doctrine \ORM \Query \Parser ;
1419use Doctrine \ORM \Query \ParserResult ;
1520use Doctrine \ORM \Query \SqlWalker ;
21+ use PDO ;
22+ use PDOException ;
23+ use PHPStan \Php \PhpVersion ;
1624use PHPStan \ShouldNotHappenException ;
25+ use PHPStan \TrinaryLogic ;
1726use PHPStan \Type \BooleanType ;
27+ use PHPStan \Type \Constant \ConstantBooleanType ;
1828use PHPStan \Type \Constant \ConstantFloatType ;
1929use PHPStan \Type \Constant \ConstantIntegerType ;
2030use PHPStan \Type \Constant \ConstantStringType ;
4353use function get_class ;
4454use function gettype ;
4555use function intval ;
46- use function is_bool ;
4756use function is_numeric ;
4857use function is_object ;
4958use 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