diff --git a/README.md b/README.md index a93658c5..057b295d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ $query->getOneOrNullResult(Query::HYDRATE_OBJECT); // User 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. +### Expression types inferring + +Whether `MAX(e.id)` is fetched as `string` or `int` highly [depends on drivers, their setup and PHP version](https://github.com/janedbal/php-database-drivers-fetch-test). +This extension copies the logic from linked analysis, autodetects your setup and provides accurate results for `pdo_mysql`, `mysqli`, `pdo_sqlite`, `sqlite3`, `pdo_pgsql` and `pgsql`. +Any other driver will result in union with stringified version, e.g. `numeric-string|int`. + ### Problematic approaches Not every QueryBuilder can be statically analysed, here are few advices to maximize type inferring: diff --git a/phpstan.neon b/phpstan.neon index 8dfe69fa..10bf510b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -49,3 +49,11 @@ parameters: - '#^Cannot call method getWrappedResourceHandle\(\) on class\-string\|object\.$#' path: tests/Platform/QueryResultTypeWalkerFetchTypeMatrixTest.php reportUnmatched: false + - + message: '#^Call to function method_exists\(\) with Doctrine\\DBAL\\Connection and ''getNativeConnection'' will always evaluate to true\.$#' # needed for older DBAL versions + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php + - + messages: # needed for older DBAL versions (fails only on PHP 7.3) + - '#^Class Doctrine\\DBAL\\Driver\\PgSQL\\Driver not found\.$#' + - '#^Class Doctrine\\DBAL\\Driver\\SQLite3\\Driver not found\.$#' + path: src/Type/Doctrine/Query/QueryResultTypeWalker.php diff --git a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php index 4c41613c..98effde3 100644 --- a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -12,6 +12,7 @@ use Doctrine\Persistence\Mapping\MappingException; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Doctrine\Query\QueryResultTypeBuilder; @@ -37,10 +38,14 @@ final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturn /** @var DescriptorRegistry */ private $descriptorRegistry; - public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry) + /** @var PhpVersion */ + private $phpVersion; + + public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry, PhpVersion $phpVersion) { $this->objectMetadataResolver = $objectMetadataResolver; $this->descriptorRegistry = $descriptorRegistry; + $this->phpVersion = $phpVersion; } public function getClass(): string @@ -87,7 +92,7 @@ public function getTypeFromMethodCall( try { $query = $em->createQuery($queryString); - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->phpVersion); } catch (ORMException | DBALException | NewDBALException | CommonException | MappingException | \Doctrine\ORM\Exception\ORMException $e) { return new QueryType($queryString, null, null); } catch (AssertionError $e) { diff --git a/src/Type/Doctrine/DefaultDescriptorRegistry.php b/src/Type/Doctrine/DefaultDescriptorRegistry.php index 48886caa..2fc81131 100644 --- a/src/Type/Doctrine/DefaultDescriptorRegistry.php +++ b/src/Type/Doctrine/DefaultDescriptorRegistry.php @@ -36,4 +36,15 @@ public function get(string $type): DoctrineTypeDescriptor return $this->descriptors[$typeClass]; } + /** + * @throws DescriptorNotRegisteredException + */ + public function getByClassName(string $className): DoctrineTypeDescriptor + { + if (!isset($this->descriptors[$className])) { + throw new DescriptorNotRegisteredException(); + } + return $this->descriptors[$className]; + } + } diff --git a/src/Type/Doctrine/Descriptors/ArrayType.php b/src/Type/Doctrine/Descriptors/ArrayType.php index d846c115..93fd4745 100644 --- a/src/Type/Doctrine/Descriptors/ArrayType.php +++ b/src/Type/Doctrine/Descriptors/ArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\MixedType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -24,7 +25,7 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ArrayType(new MixedType(), new MixedType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/AsciiStringType.php b/src/Type/Doctrine/Descriptors/AsciiStringType.php index fbee4d12..60a6b7cf 100644 --- a/src/Type/Doctrine/Descriptors/AsciiStringType.php +++ b/src/Type/Doctrine/Descriptors/AsciiStringType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -23,7 +24,7 @@ public function getWritableToDatabaseType(): Type return new StringType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/BigIntType.php b/src/Type/Doctrine/Descriptors/BigIntType.php index 14b3ca2a..d582f177 100644 --- a/src/Type/Doctrine/Descriptors/BigIntType.php +++ b/src/Type/Doctrine/Descriptors/BigIntType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use Composer\InstalledVersions; +use Doctrine\DBAL\Driver; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\IntegerType; use PHPStan\Type\StringType; @@ -30,10 +31,10 @@ public function getWritableToPropertyType(): Type public function getWritableToDatabaseType(): Type { - return TypeCombinator::union(new StringType(), new IntegerType()); + return TypeCombinator::union(new StringType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new IntegerType(); } diff --git a/src/Type/Doctrine/Descriptors/BinaryType.php b/src/Type/Doctrine/Descriptors/BinaryType.php index 5b3c848a..b9a48268 100644 --- a/src/Type/Doctrine/Descriptors/BinaryType.php +++ b/src/Type/Doctrine/Descriptors/BinaryType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\MixedType; use PHPStan\Type\ResourceType; use PHPStan\Type\StringType; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new MixedType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/BlobType.php b/src/Type/Doctrine/Descriptors/BlobType.php index c4b89907..7ef634d4 100644 --- a/src/Type/Doctrine/Descriptors/BlobType.php +++ b/src/Type/Doctrine/Descriptors/BlobType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\MixedType; use PHPStan\Type\ResourceType; use PHPStan\Type\Type; @@ -24,7 +25,7 @@ public function getWritableToDatabaseType(): Type return new MixedType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new MixedType(); } diff --git a/src/Type/Doctrine/Descriptors/BooleanType.php b/src/Type/Doctrine/Descriptors/BooleanType.php index 955883a8..01700b61 100644 --- a/src/Type/Doctrine/Descriptors/BooleanType.php +++ b/src/Type/Doctrine/Descriptors/BooleanType.php @@ -2,6 +2,9 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; +use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PdoPgSQLDriver; +use Doctrine\DBAL\Driver\PgSQL\Driver as PgSQLDriver; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -24,12 +27,15 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\BooleanType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { + if ($driver instanceof PgSQLDriver || $driver instanceof PdoPgSQLDriver) { + return new \PHPStan\Type\BooleanType(); + } + return TypeCombinator::union( new ConstantIntegerType(0), - new ConstantIntegerType(1), - new \PHPStan\Type\BooleanType() + new ConstantIntegerType(1) ); } diff --git a/src/Type/Doctrine/Descriptors/DateImmutableType.php b/src/Type/Doctrine/Descriptors/DateImmutableType.php index 9ed1eb9e..82b4082d 100644 --- a/src/Type/Doctrine/Descriptors/DateImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateImmutableType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use DateTimeImmutable; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateIntervalType.php b/src/Type/Doctrine/Descriptors/DateIntervalType.php index f407494a..9e8e7503 100644 --- a/src/Type/Doctrine/Descriptors/DateIntervalType.php +++ b/src/Type/Doctrine/Descriptors/DateIntervalType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use DateInterval; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateInterval::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php b/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php index 4cc93155..c14461f8 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use DateTimeImmutable; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateTimeType.php b/src/Type/Doctrine/Descriptors/DateTimeType.php index cc1c2ef8..71562fae 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeType.php @@ -4,6 +4,7 @@ use DateTime; use DateTimeInterface; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -26,7 +27,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeInterface::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php b/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php index 957567a6..7c679143 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use DateTimeImmutable; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateTimeTzType.php b/src/Type/Doctrine/Descriptors/DateTimeTzType.php index da28cbf6..ffa1820b 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeTzType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeTzType.php @@ -4,6 +4,7 @@ use DateTime; use DateTimeInterface; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -26,7 +27,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeInterface::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DateType.php b/src/Type/Doctrine/Descriptors/DateType.php index 80cd6748..c15d6ce5 100644 --- a/src/Type/Doctrine/Descriptors/DateType.php +++ b/src/Type/Doctrine/Descriptors/DateType.php @@ -4,6 +4,7 @@ use DateTime; use DateTimeInterface; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -26,7 +27,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeInterface::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/DecimalType.php b/src/Type/Doctrine/Descriptors/DecimalType.php index b008ffe5..5c4f77cb 100644 --- a/src/Type/Doctrine/Descriptors/DecimalType.php +++ b/src/Type/Doctrine/Descriptors/DecimalType.php @@ -2,9 +2,13 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; +use Doctrine\DBAL\Driver\PDO\SQLite\Driver as PdoSqliteDriver; +use Doctrine\DBAL\Driver\SQLite3\Driver as Sqlite3Driver; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -27,9 +31,17 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::union(new StringType(), new FloatType(), new IntegerType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { - return TypeCombinator::union(new FloatType(), new IntegerType()); + if ($driver instanceof Sqlite3Driver || $driver instanceof PdoSqliteDriver) { + return new FloatType(); + } + + // TODO use mixed as fallback for any untested driver or some guess? + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); } } diff --git a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php index 75f56c9b..f8212d3b 100644 --- a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\Type; /** @api */ @@ -13,10 +14,29 @@ interface DoctrineTypeDescriptor */ public function getType(): string; + /** + * This is used for inferring direct column results, e.g. SELECT e.field + * It should comply with convertToPHPValue return value + */ public function getWritableToPropertyType(): Type; public function getWritableToDatabaseType(): Type; - public function getDatabaseInternalType(): Type; + /** + * This is used for inferring how database fetches column of such type + * It should return the native type without stringification that may occur on certain PHP versions or driver configuration + * + * This is not used for direct column type inferring, + * but when such column appears in expression like SELECT MAX(e.field) + * + * See: https://github.com/janedbal/php-database-drivers-fetch-test + * + * mysql sqlite pdo_pgsql pgsql + * - decimal: string float string string + * - float: float float string float + * - bigint: int int int int + * - bool: int int bool bool + */ + public function getDatabaseInternalType(Driver $driver): Type; } diff --git a/src/Type/Doctrine/Descriptors/FloatType.php b/src/Type/Doctrine/Descriptors/FloatType.php index a6fed14e..85e779f1 100644 --- a/src/Type/Doctrine/Descriptors/FloatType.php +++ b/src/Type/Doctrine/Descriptors/FloatType.php @@ -2,7 +2,12 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; +use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PdoPgSQLDriver; +use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\IntegerType; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -24,9 +29,15 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::union(new \PHPStan\Type\FloatType(), new IntegerType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { - return TypeCombinator::union(new \PHPStan\Type\FloatType(), new IntegerType()); + if ($driver instanceof PdoPgSQLDriver) { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + return new \PHPStan\Type\FloatType(); } } diff --git a/src/Type/Doctrine/Descriptors/GuidType.php b/src/Type/Doctrine/Descriptors/GuidType.php index 6e24bbf2..4a9cf620 100644 --- a/src/Type/Doctrine/Descriptors/GuidType.php +++ b/src/Type/Doctrine/Descriptors/GuidType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -23,7 +24,7 @@ public function getWritableToDatabaseType(): Type return new StringType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/IntegerType.php b/src/Type/Doctrine/Descriptors/IntegerType.php index 4ecb6e5b..e77de30e 100644 --- a/src/Type/Doctrine/Descriptors/IntegerType.php +++ b/src/Type/Doctrine/Descriptors/IntegerType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\Type; class IntegerType implements DoctrineTypeDescriptor @@ -22,7 +23,7 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\IntegerType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new \PHPStan\Type\IntegerType(); } diff --git a/src/Type/Doctrine/Descriptors/JsonArrayType.php b/src/Type/Doctrine/Descriptors/JsonArrayType.php index 939fb66a..b3d7f35e 100644 --- a/src/Type/Doctrine/Descriptors/JsonArrayType.php +++ b/src/Type/Doctrine/Descriptors/JsonArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\ArrayType; use PHPStan\Type\MixedType; use PHPStan\Type\StringType; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ArrayType(new MixedType(), new MixedType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/JsonType.php b/src/Type/Doctrine/Descriptors/JsonType.php index 9d899feb..fdd507b6 100644 --- a/src/Type/Doctrine/Descriptors/JsonType.php +++ b/src/Type/Doctrine/Descriptors/JsonType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use JsonSerializable; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; @@ -50,7 +51,7 @@ public function getWritableToDatabaseType(): Type return self::getJsonType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/ObjectType.php b/src/Type/Doctrine/Descriptors/ObjectType.php index d048632a..133756c5 100644 --- a/src/Type/Doctrine/Descriptors/ObjectType.php +++ b/src/Type/Doctrine/Descriptors/ObjectType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -24,7 +25,7 @@ public function getWritableToDatabaseType(): Type return new ObjectWithoutClassType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php index 78501f2c..61c97fa2 100644 --- a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors\Ramsey; use PHPStan\Rules\Doctrine\ORM\FakeTestingUuidType; +use Doctrine\DBAL\Driver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Doctrine\Descriptors\DoctrineTypeDescriptor; use PHPStan\Type\ObjectType; @@ -59,7 +60,7 @@ public function getWritableToDatabaseType(): Type ); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php index 283d9506..686962a7 100644 --- a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php +++ b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php @@ -2,9 +2,14 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type as DbalType; +use PHPStan\DependencyInjection\Container; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Doctrine\DefaultDescriptorRegistry; +use PHPStan\Type\Doctrine\DescriptorNotRegisteredException; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -13,19 +18,27 @@ class ReflectionDescriptor implements DoctrineTypeDescriptor { - /** @var class-string<\Doctrine\DBAL\Types\Type> */ + /** @var class-string */ private $type; /** @var ReflectionProvider */ private $reflectionProvider; + /** @var Container */ + private $container; + /** - * @param class-string<\Doctrine\DBAL\Types\Type> $type + * @param class-string $type */ - public function __construct(string $type, ReflectionProvider $reflectionProvider) + public function __construct( + string $type, + ReflectionProvider $reflectionProvider, + Container $container + ) { $this->type = $type; $this->reflectionProvider = $reflectionProvider; + $this->container = $container; } public function getType(): string @@ -55,8 +68,24 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::removeNull($type); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { + $registry = $this->container->getByType(DefaultDescriptorRegistry::class); + $parents = $this->reflectionProvider->getClass($this->type)->getParentClassesNames(); + + foreach ($parents as $dbalTypeParentClass) { + try { + // this assumes that if somebody inherits from DecimalType, + // the real database type remains decimal and we can reuse its descriptor + return $registry + ->getByClassName($dbalTypeParentClass) + ->getDatabaseInternalType($driver); + + } catch (DescriptorNotRegisteredException $e) { + continue; + } + } + return new MixedType(); } diff --git a/src/Type/Doctrine/Descriptors/SimpleArrayType.php b/src/Type/Doctrine/Descriptors/SimpleArrayType.php index 8044caca..59137441 100644 --- a/src/Type/Doctrine/Descriptors/SimpleArrayType.php +++ b/src/Type/Doctrine/Descriptors/SimpleArrayType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\ArrayType; use PHPStan\Type\IntegerType; @@ -27,7 +28,7 @@ public function getWritableToDatabaseType(): Type return new ArrayType(new MixedType(), new StringType()); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/SmallIntType.php b/src/Type/Doctrine/Descriptors/SmallIntType.php index d575a246..05c54bed 100644 --- a/src/Type/Doctrine/Descriptors/SmallIntType.php +++ b/src/Type/Doctrine/Descriptors/SmallIntType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\IntegerType; use PHPStan\Type\Type; @@ -23,7 +24,7 @@ public function getWritableToDatabaseType(): Type return new IntegerType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new IntegerType(); } diff --git a/src/Type/Doctrine/Descriptors/StringType.php b/src/Type/Doctrine/Descriptors/StringType.php index 98fa5a02..5ab4b400 100644 --- a/src/Type/Doctrine/Descriptors/StringType.php +++ b/src/Type/Doctrine/Descriptors/StringType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\Type; class StringType implements DoctrineTypeDescriptor @@ -22,7 +23,7 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\StringType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new \PHPStan\Type\StringType(); } diff --git a/src/Type/Doctrine/Descriptors/TextType.php b/src/Type/Doctrine/Descriptors/TextType.php index 9c0730aa..803f9cde 100644 --- a/src/Type/Doctrine/Descriptors/TextType.php +++ b/src/Type/Doctrine/Descriptors/TextType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; +use Doctrine\DBAL\Driver; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -23,7 +24,7 @@ public function getWritableToDatabaseType(): Type return new StringType(); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/TimeImmutableType.php b/src/Type/Doctrine/Descriptors/TimeImmutableType.php index a67c0910..ef51a1cd 100644 --- a/src/Type/Doctrine/Descriptors/TimeImmutableType.php +++ b/src/Type/Doctrine/Descriptors/TimeImmutableType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use DateTimeImmutable; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -25,7 +26,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Descriptors/TimeType.php b/src/Type/Doctrine/Descriptors/TimeType.php index c10552b0..e2040b76 100644 --- a/src/Type/Doctrine/Descriptors/TimeType.php +++ b/src/Type/Doctrine/Descriptors/TimeType.php @@ -4,6 +4,7 @@ use DateTime; use DateTimeInterface; +use Doctrine\DBAL\Driver; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -26,7 +27,7 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeInterface::class); } - public function getDatabaseInternalType(): Type + public function getDatabaseInternalType(Driver $driver): Type { return new StringType(); } diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index 55375c5f..55448e9d 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -3,7 +3,12 @@ namespace PHPStan\Type\Doctrine\Query; use BackedEnum; -use Doctrine\DBAL\Types\Types; +use Doctrine\DBAL\Driver\Mysqli\Driver as MysqliDriver; +use Doctrine\DBAL\Driver\PDO\MySQL\Driver as PdoMysqlDriver; +use Doctrine\DBAL\Driver\PDO\PgSQL\Driver as PdoPgSQLDriver; +use Doctrine\DBAL\Driver\PDO\SQLite\Driver as PdoSQLiteDriver; +use Doctrine\DBAL\Driver\PgSQL\Driver as PgSQLDriver; +use Doctrine\DBAL\Driver\SQLite3\Driver as SQLite3Driver; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; @@ -12,7 +17,12 @@ use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\ParserResult; use Doctrine\ORM\Query\SqlWalker; +use PDO; +use PDOException; +use PHPStan\Php\PhpVersion; use PHPStan\ShouldNotHappenException; +use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; @@ -41,15 +51,16 @@ use function assert; use function class_exists; use function count; -use function floatval; use function get_class; use function gettype; -use function intval; -use function is_numeric; +use function is_int; use function is_object; use function is_string; +use function method_exists; use function serialize; use function sprintf; +use function stripos; +use function strpos; use function strtolower; use function strtoupper; use function unserialize; @@ -68,6 +79,8 @@ class QueryResultTypeWalker extends SqlWalker private const HINT_DESCRIPTOR_REGISTRY = self::class . '::HINT_DESCRIPTOR_REGISTRY'; + private const HINT_PHP_VERSION = self::class . '::HINT_PHP_VERSION'; + /** * Counter for generating unique scalar result. * @@ -88,6 +101,9 @@ class QueryResultTypeWalker extends SqlWalker /** @var EntityManagerInterface */ private $em; + /** @var PhpVersion */ + private $phpVersion; + /** * Map of all components/classes that appear in the DQL query. * @@ -113,11 +129,12 @@ class QueryResultTypeWalker extends SqlWalker /** * @param Query $query */ - public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry): void + public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry, PhpVersion $phpVersion): void { $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, self::class); $query->setHint(self::HINT_TYPE_MAPPING, $typeBuilder); $query->setHint(self::HINT_DESCRIPTOR_REGISTRY, $descriptorRegistry); + $query->setHint(self::HINT_PHP_VERSION, $phpVersion); $parser = new Parser($query); $parser->parse(); @@ -169,6 +186,19 @@ public function __construct($query, $parserResult, array $queryComponents) $this->descriptorRegistry = $descriptorRegistry; + $phpVersion = $this->query->getHint(self::HINT_PHP_VERSION); + + if (!$phpVersion instanceof PhpVersion) { // @phpstan-ignore-line ignore bc promise + throw new ShouldNotHappenException(sprintf( + 'Expected the query hint %s to contain a %s, but got a %s', + self::HINT_PHP_VERSION, + PhpVersion::class, + is_object($phpVersion) ? get_class($phpVersion) : gettype($phpVersion) + )); + } + + $this->phpVersion = $phpVersion; + parent::__construct($query, $parserResult, $queryComponents); } @@ -225,6 +255,8 @@ public function walkPathExpression($pathExpr): string $dqlAlias = $pathExpr->identificationVariable; $qComp = $this->queryComponents[$dqlAlias]; assert(array_key_exists('metadata', $qComp)); + + /** @var ClassMetadata $class */ $class = $qComp['metadata']; assert($fieldName !== null); @@ -359,25 +391,50 @@ public function walkFunction($function): string { switch (true) { case $function instanceof AST\Functions\AvgFunction: + return $this->marshalType($this->inferAvgFunction($function)); + case $function instanceof AST\Functions\MaxFunction: case $function instanceof AST\Functions\MinFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // MIN(col_float) => float float string float + // MIN(col_decimal) => string float string string + // MIN(col_int) => int int int int + // MIN(col_bigint) => int int int int + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprType = $this->generalizeLiteralType($exprType, $this->hasAggregateWithoutGroupBy()); + return $this->marshalType($exprType); // retains underlying type + case $function instanceof AST\Functions\SumFunction: + return $this->marshalType($this->inferSumFunction($function)); + case $function instanceof AST\Functions\CountFunction: - return $function->getSql($this); + return $this->marshalType(new IntegerType()); // TypedExpression condition will overwrite this anyway case $function instanceof AST\Functions\AbsFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // ABS(col_float) => float float string float + // ABS(col_decimal) => string float string string + // ABS(col_int) => int int int int + // ABS(col_bigint) => int int int int + // ABS(col_string) => float float x x + $exprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->simpleArithmeticExpression)); + $exprType = $this->generalizeLiteralType($exprType, false); - $type = TypeCombinator::union( - IntegerRangeType::fromInterval(0, null), - new FloatType() - ); + // TODO invalid usages - if (TypeCombinator::containsNull($exprType)) { - $type = TypeCombinator::addNull($type); - } - - return $this->marshalType($type); + return $this->marshalType($exprType); // retains underlying type case $function instanceof AST\Functions\BitAndFunction: case $function instanceof AST\Functions\BitOrFunction: @@ -389,6 +446,8 @@ public function walkFunction($function): string $type = TypeCombinator::addNull($type); } + // TODO invalid usages + return $this->marshalType($type); case $function instanceof AST\Functions\ConcatFunction: @@ -427,10 +486,14 @@ public function walkFunction($function): string $date1ExprType = $this->unmarshalType($function->date1->dispatch($this)); $date2ExprType = $this->unmarshalType($function->date2->dispatch($this)); - $type = TypeCombinator::union( - new IntegerType(), - new FloatType() - ); + $driver = $this->em->getConnection()->getDriver(); + + if ($driver instanceof Sqlite3Driver || $driver instanceof PdoSqliteDriver) { + $type = new FloatType(); + } else { + $type = new IntegerType(); + } + if (TypeCombinator::containsNull($date1ExprType) || TypeCombinator::containsNull($date2ExprType)) { $type = TypeCombinator::addNull($type); } @@ -471,25 +534,82 @@ public function walkFunction($function): string return $this->marshalType($type); case $function instanceof AST\Functions\ModFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // MOD(col_float) => float x x x + // MOD(col_decimal) => string x x x + // MOD(col_int) => int int int int + // MOD(col_bigint) => int int int int + $firstExprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->firstSimpleArithmeticExpression)); $secondExprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->secondSimpleArithmeticExpression)); - $type = IntegerRangeType::fromInterval(0, null); + $type = $firstExprType; if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { $type = TypeCombinator::addNull($type); } - if ((new ConstantIntegerType(0))->isSuperTypeOf($secondExprType)->maybe()) { - // MOD(x, 0) returns NULL + $driver = $this->em->getConnection()->getDriver(); + $isPgSql = $driver instanceof PgSQLDriver || $driver instanceof PdoPgSQLDriver; + $mayBeZero = !(new ConstantIntegerType(0))->isSuperTypeOf($secondExprType)->no(); + + if (!$isPgSql && $mayBeZero) { // MOD(x, 0) returns NULL in non-strict platforms, fails in postgre $type = TypeCombinator::addNull($type); } - return $this->marshalType($type); + // TODO more invalid usages + + return $this->marshalType($this->generalizeLiteralType($type, false)); case $function instanceof AST\Functions\SqrtFunction: + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // SQRT(col_float) => float float string float + // SQRT(col_decimal) => float float string string + // SQRT(col_int) => float float string float + // SQRT(col_bigint) => float float string float + $exprType = $this->unmarshalType($this->walkSimpleArithmeticExpression($function->simpleArithmeticExpression)); - $type = new FloatType(); + $driver = $this->em->getConnection()->getDriver(); + + if ($driver instanceof MysqliDriver || $driver instanceof PdoMysqlDriver || $driver instanceof Sqlite3Driver || $driver instanceof PdoSqliteDriver) { + $type = new FloatType(); + + $cannotBeNegative = $exprType->isSmallerThan(new ConstantIntegerType(0))->no(); + $canBeNegative = !$cannotBeNegative; + if ($canBeNegative) { + $type = TypeCombinator::addNull($type); + } + + } elseif ($driver instanceof PdoPgSQLDriver) { + $type = new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + + } elseif ($driver instanceof PgSQLDriver) { + // numeric-string for decimal + // float for int and float + $type = TypeCombinator::union( + new FloatType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]) + ); + } else { + $type = new MixedType(); + } + if (TypeCombinator::containsNull($exprType)) { $type = TypeCombinator::addNull($type); } @@ -573,6 +693,142 @@ public function walkFunction($function): string } } + private function inferAvgFunction(AST\Functions\AvgFunction $function): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // AVG(col_float) => float float string float + // AVG(col_decimal) => string float string string + // AVG(col_int) => string float string string + // AVG(col_bigint) => string float string string + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + $nullable = TypeCombinator::containsNull($exprType) || $this->hasAggregateWithoutGroupBy(); + + $driver = $this->em->getConnection()->getDriver(); + + if ($driver instanceof Sqlite3Driver || $driver instanceof PdoSqliteDriver) { + return $this->createFloat($nullable); + } + + if ($driver instanceof PdoMysqlDriver || $driver instanceof MysqliDriver) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable); + } + + return $this->generalizeLiteralType($exprType, $nullable); + } + + if ($driver instanceof PgSQLDriver || $driver instanceof PdoPgSQLDriver) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable); + } + + return $this->generalizeLiteralType($exprType, $nullable); + } + + return new MixedType(); + } + + private function inferSumFunction(AST\Functions\SumFunction $function): Type + { + // mysql sqlite pdo_pgsql pgsql + // col_float => float float string float + // col_decimal => string float string string + // col_int => int int int int + // col_bigint => int int int int + // + // SUM(col_float) => float float string float + // SUM(col_decimal) => string float string string + // SUM(col_int) => string int int int + // SUM(col_bigint) => string int string string + + $exprType = $this->unmarshalType($function->getSql($this)); + $exprTypeNoNull = TypeCombinator::removeNull($exprType); + $nullable = TypeCombinator::containsNull($exprType) || $this->hasAggregateWithoutGroupBy(); + + $driver = $this->em->getConnection()->getDriver(); + + if ($driver instanceof Sqlite3Driver || $driver instanceof PdoSqliteDriver) { + return $this->generalizeLiteralType($exprType, $nullable); + } + + if ($driver instanceof PdoMysqlDriver || $driver instanceof MysqliDriver) { + if ($exprTypeNoNull->isInteger()->yes()) { + return $this->createNumericString($nullable); + } + + return $this->generalizeLiteralType($exprType, $nullable); + } + + if ($driver instanceof PgSQLDriver || $driver instanceof PdoPgSQLDriver) { + if ($exprTypeNoNull->isInteger()->yes()) { + return TypeCombinator::union( + $this->createInteger($nullable), + $this->createNumericString($nullable) + ); + } + + return $this->generalizeLiteralType($exprType, $nullable); + } + + return new MixedType(); + } + + private function createFloat(bool $nullable): Type + { + $float = new FloatType(); + return $nullable ? TypeCombinator::addNull($float) : $float; + } + + private function createInteger(bool $nullable): Type + { + $integer = new IntegerType(); + return $nullable ? TypeCombinator::addNull($integer) : $integer; + } + + private function createNumericString(bool $nullable): Type + { + $numericString = TypeCombinator::intersect( + new StringType(), + new AccessoryNumericStringType() + ); + + return $nullable ? TypeCombinator::addNull($numericString) : $numericString; + } + + /** + * E.g. to ensure SUM(1) is inferred as int, not 1 + */ + private function generalizeLiteralType(Type $type, bool $makeNullable): Type + { + $containsNull = TypeCombinator::containsNull($type); + $typeNoNull = TypeCombinator::removeNull($type); + + if (!$typeNoNull->isConstantScalarValue()->yes()) { + $result = $type; + + } elseif ($typeNoNull->isInteger()->yes()) { + $result = $this->createInteger($containsNull); + + } elseif ($typeNoNull->isFloat()->yes()) { + $result = $this->createFloat($containsNull); + + } elseif ($typeNoNull->isNumericString()->yes()) { + $result = $this->createNumericString($containsNull); + + } else { + $result = $type; + } + + return $makeNullable ? TypeCombinator::addNull($result) : $result; + } + /** * @param AST\OrderByClause $orderByClause */ @@ -817,43 +1073,39 @@ public function walkSelectExpression($selectExpression): string $resultAlias = $selectExpression->fieldIdentificationVariable ?? $this->scalarResultCounter++; $type = $this->unmarshalType($expr->dispatch($this)); - if (class_exists(TypedExpression::class) && $expr instanceof TypedExpression) { - $enforcedType = $this->resolveDoctrineType(Types::INTEGER); - $type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($enforcedType): Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - if ($type instanceof NullType) { - return $type; - } - if ($enforcedType->accepts($type, true)->yes()) { - return $type; - } - if ($enforcedType instanceof StringType) { - if ($type instanceof IntegerType || $type instanceof FloatType) { - return TypeCombinator::union($type->toString(), $type); - } - if ($type instanceof BooleanType) { - return TypeCombinator::union($type->toInteger()->toString(), $type); - } - } - return $enforcedType; - }); + if ($expr instanceof TypedExpression) { + $type = $this->resolveDoctrineType($expr->getReturnType()->getName(), null, TypeCombinator::containsNull($type)); // TODO test nullability } else { // Expressions default to Doctrine's StringType, whose // convertToPHPValue() is a no-op. So the actual type depends on // the driver and PHP version. - // Here we assume that the value may or may not be casted to - // string by the driver. - $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + + $type = TypeTraverser::map($type, function (Type $type, callable $traverse): Type { if ($type instanceof UnionType || $type instanceof IntersectionType) { return $traverse($type); } + if ($type instanceof IntegerType || $type instanceof FloatType) { - return TypeCombinator::union($type->toString(), $type); + $stringify = $this->shouldStringifyExpressions($type); + + if ($stringify->yes()) { + return $type->toString(); + } elseif ($stringify->maybe()) { + return TypeCombinator::union($type->toString(), $type); + } + + return $type; } if ($type instanceof BooleanType) { - return TypeCombinator::union($type->toInteger()->toString(), $type); + $stringify = $this->shouldStringifyExpressions($type); + + if ($stringify->yes()) { + return $type->toInteger()->toString(); + } elseif ($stringify->maybe()) { + return TypeCombinator::union($type->toInteger()->toString(), $type); + } + + return $type; } return $traverse($type); }); @@ -934,31 +1186,14 @@ public function walkAggregateExpression($aggExpression): string { switch (strtoupper($aggExpression->functionName)) { case 'MAX': - case 'MIN': - $type = $this->unmarshalType( - $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) - ); - - return $this->marshalType(TypeCombinator::addNull($type)); - case 'AVG': - $type = $this->unmarshalType( - $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) - ); - - $type = TypeCombinator::union($type, $type->toFloat()); - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); - - return $this->marshalType(TypeCombinator::addNull($type)); - case 'SUM': + case 'MIN': $type = $this->unmarshalType( $this->walkSimpleArithmeticExpression($aggExpression->pathExpression) ); - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); - - return $this->marshalType(TypeCombinator::addNull($type)); + return $this->marshalType($type); // nullability added in walkFunction case 'COUNT': return $this->marshalType(IntegerRangeType::fromInterval(0, null)); @@ -1106,6 +1341,8 @@ public function walkInParameter($inParam): string */ public function walkLiteral($literal): string { + $driver = $this->em->getConnection()->getDriver(); + switch ($literal->type) { case AST\Literal::STRING: $value = $literal->value; @@ -1115,20 +1352,37 @@ public function walkLiteral($literal): string case AST\Literal::BOOLEAN: $value = strtolower($literal->value) === 'true'; - $type = TypeCombinator::union( - new ConstantIntegerType($value ? 1 : 0), - new ConstantBooleanType($value) - ); + if ($driver instanceof PdoPgSQLDriver || $driver instanceof PgSQLDriver) { + $type = new ConstantBooleanType($value); + } else { + $type = new ConstantIntegerType($value ? 1 : 0); + } break; case AST\Literal::NUMERIC: $value = $literal->value; - assert(is_numeric($value)); + assert(is_int($value) || is_string($value)); // ensured in parser - if (floatval(intval($value)) === floatval($value)) { + if (is_int($value) || (strpos($value, '.') === false && strpos($value, 'e') === false)) { $type = new ConstantIntegerType((int) $value); + } else { - $type = new ConstantFloatType((float) $value); + if ($driver instanceof PdoMysqlDriver || $driver instanceof MysqliDriver) { + // 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 + // the only way to force float is to use float literal with scientific notation (e.g. 123.4e0) + // https://dev.mysql.com/doc/refman/8.0/en/number-literals.html + + if (stripos($value, 'e') !== false) { + $type = new ConstantFloatType((float) $value); + } else { + $type = new ConstantStringType((string) (float) $value); + } + } elseif ($driver instanceof PgSQLDriver || $driver instanceof PdoPgSQLDriver) { + $type = new ConstantStringType((string) (float) $value); + + } else { + $type = new ConstantFloatType((float) $value); + } } break; @@ -1261,7 +1515,13 @@ public function walkArithmeticFactor($factor): string $primary = $factor->arithmeticPrimary; $type = $this->unmarshalType($this->walkArithmeticPrimary($primary)); - $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + + if ($type instanceof ConstantIntegerType && $factor->sign === false) { + $type = new ConstantIntegerType($type->getValue() * -1); + + } elseif ($type instanceof ConstantFloatType && $factor->sign === false) { + $type = new ConstantFloatType($type->getValue() * -1); + } return $this->marshalType($type); } @@ -1378,7 +1638,7 @@ private function resolveDatabaseInternalType(string $typeName, ?string $enumType try { $type = $this->descriptorRegistry ->get($typeName) - ->getDatabaseInternalType(); + ->getDatabaseInternalType($this->em->getConnection()->getDriver()); } catch (DescriptorNotRegisteredException $e) { $type = new MixedType(); } @@ -1457,4 +1717,133 @@ private function hasAggregateFunction(AST\SelectStatement $AST): bool return false; } + /** + * See analysis: https://github.com/janedbal/php-database-drivers-fetch-test + * + * Notable 8.1 changes: + * - pdo_mysql: https://github.com/php/php-src/commit/c18b1aea289e8ed6edb3f6e6a135018976a034c6 + * - pdo_sqlite: https://github.com/php/php-src/commit/438b025a28cda2935613af412fc13702883dd3a2 + * - pdo_pgsql: https://github.com/php/php-src/commit/737195c3ae6ac53b9501cfc39cc80fd462909c82 + * + * @param IntegerType|FloatType|BooleanType $type + */ + private function shouldStringifyExpressions(Type $type): TrinaryLogic + { + $driver = $this->em->getConnection()->getDriver(); + $nativeConnection = $this->getNativeConnection(); + + if ($nativeConnection instanceof PDO) { + $stringifyFetches = $this->isPdoStringifyEnabled($nativeConnection); + + if ($driver instanceof PdoMysqlDriver) { + $emulatedPrepares = $this->isPdoEmulatePreparesEnabled($nativeConnection); + + if ($stringifyFetches) { + return TrinaryLogic::createYes(); + } + + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createNo(); // DECIMAL / FLOAT already decided in walkLiteral + } + + if ($emulatedPrepares) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + + if ($driver instanceof PdoSqliteDriver) { + if ($stringifyFetches) { + return TrinaryLogic::createYes(); + } + + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createNo(); + } + + return TrinaryLogic::createYes(); + } + + if ($driver instanceof PdoPgSQLDriver) { + if ($type->isBoolean()->yes()) { + if ($this->phpVersion->getVersionId() >= 80100) { + return TrinaryLogic::createFromBoolean($stringifyFetches); + } + + return TrinaryLogic::createNo(); + + } elseif ($type->isFloat()->yes()) { + return TrinaryLogic::createYes(); + + } elseif ($type->isInteger()->yes()) { + return TrinaryLogic::createFromBoolean($stringifyFetches); + } + } + } + + if ($driver instanceof PgSQLDriver) { + if ($type->isBoolean()->yes()) { + return TrinaryLogic::createNo(); + } elseif ($type->isFloat()->yes()) { + return TrinaryLogic::createNo(); // AVG(col_float) is not, but 0.1 is + } elseif ($type->isInteger()->yes()) { + return TrinaryLogic::createNo(); + } + } + + if ($driver instanceof SQLite3Driver) { + return TrinaryLogic::createNo(); + } + + if ($driver instanceof MysqliDriver) { + return TrinaryLogic::createNo(); // DECIMAL / FLOAT already decided in walkLiteral + } + + return TrinaryLogic::createMaybe(); + } + + private function isPdoStringifyEnabled(PDO $pdo): bool + { + // this fails for most PHP versions, see https://github.com/php/php-src/issues/12969 + // working since 8.2.15 and 8.3.2 + try { + return (bool) $pdo->getAttribute(PDO::ATTR_STRINGIFY_FETCHES); + } catch (PDOException $e) { + $selectOne = $pdo->query('SELECT 1'); + if ($selectOne === false) { + return false; // this should not happen, just return attribute default value + } + $one = $selectOne->fetchColumn(); + + // string can be returned due to old PHP used or because ATTR_STRINGIFY_FETCHES is enabled, + // but it should not matter as it behaves the same way + // (the attribute is there to maintain BC) + return is_string($one); + } + } + + private function isPdoEmulatePreparesEnabled(PDO $pdo): bool + { + return (bool) $pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES); + } + + /** + * @return object|resource|null + */ + private function getNativeConnection() + { + $connection = $this->em->getConnection(); + + if (method_exists($connection, 'getNativeConnection')) { + return $connection->getNativeConnection(); + } + + if ($connection->getWrappedConnection() instanceof PDO) { + return $connection->getWrappedConnection(); + } + + return null; + } + } diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php index bd0c26f9..b393f5b1 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php @@ -11,6 +11,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Doctrine\ORM\DynamicQueryBuilderArgumentException; use PHPStan\Type\Doctrine\ArgumentsProcessor; @@ -65,17 +66,22 @@ class QueryBuilderGetQueryDynamicReturnTypeExtension implements DynamicMethodRet /** @var DescriptorRegistry */ private $descriptorRegistry; + /** @var PhpVersion */ + private $phpVersion; + public function __construct( ObjectMetadataResolver $objectMetadataResolver, ArgumentsProcessor $argumentsProcessor, ?string $queryBuilderClass, - DescriptorRegistry $descriptorRegistry + DescriptorRegistry $descriptorRegistry, + PhpVersion $phpVersion ) { $this->objectMetadataResolver = $objectMetadataResolver; $this->argumentsProcessor = $argumentsProcessor; $this->queryBuilderClass = $queryBuilderClass; $this->descriptorRegistry = $descriptorRegistry; + $this->phpVersion = $phpVersion; } public function getClass(): string @@ -190,7 +196,7 @@ private function getQueryType(string $dql): Type try { $query = $em->createQuery($dql); - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, $this->phpVersion); } catch (ORMException | DBALException | CommonException | MappingException | \Doctrine\ORM\Exception\ORMException $e) { return new QueryType($dql, null); } catch (AssertionError $e) { diff --git a/tests/Platform/Entity/PlatformEntity.php b/tests/Platform/Entity/PlatformEntity.php new file mode 100644 index 00000000..484ad01f --- /dev/null +++ b/tests/Platform/Entity/PlatformEntity.php @@ -0,0 +1,93 @@ + [], + self::CONFIG_STRINGIFY => [ + PDO::ATTR_STRINGIFY_FETCHES => true, + ], + self::CONFIG_NO_EMULATE => [ + PDO::ATTR_EMULATE_PREPARES => false, + ], + self::CONFIG_STRINGIFY_NO_EMULATE => [ + PDO::ATTR_STRINGIFY_FETCHES => true, + PDO::ATTR_EMULATE_PREPARES => false, + ], + ]; + public static function getAdditionalConfigFiles(): array { return [ @@ -57,291 +93,1624 @@ public static function getAdditionalConfigFiles(): array } /** - * @param array $connectionParams - * @param array $expectedOnPhp80AndBelow - * @param array $expectedOnPhp81AndAbove - * @param array $connectionAttributes + * @param array $data + * @param mixed $mysqlExpectedResult + * @param mixed $sqliteExpectedResult + * @param mixed $pdoPgsqlExpectedResult + * @param mixed $pgsqlExpectedResult * * @dataProvider provideCases */ public function testFetchedTypes( - array $connectionParams, - array $expectedOnPhp80AndBelow, - array $expectedOnPhp81AndAbove, - array $connectionAttributes + array $data, + string $dqlTemplate, + int $entityKind, + Type $mysqlExpectedType, + ?Type $sqliteExpectedType, + ?Type $pdoPgsqlExpectedType, + ?Type $pgsqlExpectedType, + $mysqlExpectedResult, + $sqliteExpectedResult, + $pdoPgsqlExpectedResult, + $pgsqlExpectedResult, + callable $assertStringified + ): void + { + $dataset = (string) $this->dataName(); + $phpVersion = PHP_VERSION_ID; + + $this->performDriverTest('pdo_mysql', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $mysqlExpectedType, $mysqlExpectedResult, $assertStringified); + $this->performDriverTest('pdo_mysql', self::CONFIG_STRINGIFY, $data, $dqlTemplate, $dataset, $phpVersion, $mysqlExpectedType, $mysqlExpectedResult, $assertStringified); + $this->performDriverTest('pdo_mysql', self::CONFIG_NO_EMULATE, $data, $dqlTemplate, $dataset, $phpVersion, $mysqlExpectedType, $mysqlExpectedResult, $assertStringified); + $this->performDriverTest('pdo_mysql', self::CONFIG_STRINGIFY_NO_EMULATE, $data, $dqlTemplate, $dataset, $phpVersion, $mysqlExpectedType, $mysqlExpectedResult, $assertStringified); + $this->performDriverTest('mysqli', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $mysqlExpectedType, $mysqlExpectedResult, $assertStringified); + + $this->performDriverTest('pdo_sqlite', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $sqliteExpectedType, $sqliteExpectedResult, $assertStringified); + $this->performDriverTest('pdo_sqlite', self::CONFIG_STRINGIFY, $data, $dqlTemplate, $dataset, $phpVersion, $sqliteExpectedType, $sqliteExpectedResult, $assertStringified); + $this->performDriverTest('sqlite3', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $sqliteExpectedType, $sqliteExpectedResult, $assertStringified); + + $this->performDriverTest('pdo_pgsql', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $pdoPgsqlExpectedType, $pdoPgsqlExpectedResult, $assertStringified); + $this->performDriverTest('pdo_pgsql', self::CONFIG_STRINGIFY, $data, $dqlTemplate, $dataset, $phpVersion, $pdoPgsqlExpectedType, $pdoPgsqlExpectedResult, $assertStringified); + $this->performDriverTest('pgsql', self::CONFIG_DEFAULT, $data, $dqlTemplate, $dataset, $phpVersion, $pgsqlExpectedType, $pgsqlExpectedResult, $assertStringified); + } + + /** + * @return iterable + */ + public static function provideCases(): iterable + { + yield '- 1' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT -1 FROM %s t', + 'mysql' => new ConstantIntegerType(-1), + 'sqlite' => new ConstantIntegerType(-1), + 'pdo_pgsql' => new ConstantIntegerType(-1), + 'pgsql' => new ConstantIntegerType(-1), + 'mysqlResult' => -1, + 'sqliteResult' => -1, + 'pdoPgsqlResult' => -1, + 'pgsqlResult' => -1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield '1 ' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT 1 FROM %s t', + 'mysql' => new ConstantIntegerType(1), + 'sqlite' => new ConstantIntegerType(1), + 'pdo_pgsql' => new ConstantIntegerType(1), + 'pgsql' => new ConstantIntegerType(1), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield '2147483648 ' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT 2147483648 FROM %s t', + 'mysql' => new ConstantIntegerType(2147483648), + 'sqlite' => new ConstantIntegerType(2147483648), + 'pdo_pgsql' => new ConstantIntegerType(2147483648), + 'pgsql' => new ConstantIntegerType(2147483648), + 'mysqlResult' => 2147483648, + 'sqliteResult' => 2147483648, + 'pdoPgsqlResult' => 2147483648, + 'pgsqlResult' => 2147483648, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield '0.1' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT 0.1 FROM %s t', + 'mysql' => new ConstantStringType('0.1'), + 'sqlite' => new ConstantFloatType(0.1), + 'pdo_pgsql' => new ConstantStringType('0.1'), + 'pgsql' => new ConstantStringType('0.1'), + 'mysqlResult' => '0.1', + 'sqliteResult' => 0.1, + 'pdoPgsqlResult' => '0.1', + 'pgsqlResult' => '0.1', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield '0.125e0' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT 0.125e0 FROM %s t', + 'mysql' => new ConstantFloatType(0.125), + 'sqlite' => new ConstantFloatType(0.125), + 'pdo_pgsql' => new ConstantStringType('0.125'), + 'pgsql' => new ConstantStringType('0.125'), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => '0.125', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield "''" => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT \'\' FROM %s t', + 'mysql' => new ConstantStringType(''), + 'sqlite' => new ConstantStringType(''), + 'pdo_pgsql' => new ConstantStringType(''), + 'pgsql' => new ConstantStringType(''), + 'mysqlResult' => '', + 'sqliteResult' => '', + 'pdoPgsqlResult' => '', + 'pgsqlResult' => '', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield '(TRUE)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT (TRUE) FROM %s t', + 'mysql' => new ConstantIntegerType(1), + 'sqlite' => new ConstantIntegerType(1), + 'pdo_pgsql' => new ConstantBooleanType(true), + 'pgsql' => new ConstantBooleanType(true), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => true, + 'pgsqlResult' => true, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultBooleanStringification($driver, $php, $configName); + }, + ]; + + yield '(FALSE)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT (FALSE) FROM %s t', + 'mysql' => new ConstantIntegerType(0), + 'sqlite' => new ConstantIntegerType(0), + 'pdo_pgsql' => new ConstantBooleanType(false), + 'pgsql' => new ConstantBooleanType(false), + 'mysqlResult' => 0, + 'sqliteResult' => 0, + 'pdoPgsqlResult' => false, + 'pgsqlResult' => false, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultBooleanStringification($driver, $php, $configName); + }, + ]; + + yield 't.col_bool' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT t.col_bool FROM %s t', + 'mysql' => self::bool(), + 'sqlite' => self::bool(), + 'pdo_pgsql' => self::bool(), + 'pgsql' => self::bool(), + 'mysqlResult' => true, + 'sqliteResult' => true, + 'pdoPgsqlResult' => true, + 'pgsqlResult' => true, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + yield 't.col_bool_nullable' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT t.col_bool_nullable FROM %s t', + 'mysql' => self::boolOrNull(), + 'sqlite' => self::boolOrNull(), + 'pdo_pgsql' => self::boolOrNull(), + 'pgsql' => self::boolOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + yield 'COALESCE(t.col_bool)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COALESCE(t.col_bool, t.col_bool) FROM %s t', + 'mysql' => self::boolAsInt(), + 'sqlite' => self::boolAsInt(), + 'pdo_pgsql' => self::bool(), + 'pgsql' => self::bool(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => true, + 'pgsqlResult' => true, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultBooleanStringification($driver, $php, $configName); + }, + ]; + + yield 't.col_float' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT t.col_float FROM %s t', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::float(), + 'pgsql' => self::float(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => 0.125, + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + yield 'AVG(t.col_float)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'select' => 'SELECT AVG(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::float(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_float_nullable) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_float_nullable) FROM %s t GROUP BY t.col_int', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_decimal) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '0.10000', + 'sqliteResult' => 0.1, + 'pdoPgsqlResult' => '0.10000000000000000000', + 'pgsqlResult' => '0.10000000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_int) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '9.0000', + 'sqliteResult' => 9.0, + 'pdoPgsqlResult' => '9.0000000000000000', + 'pgsqlResult' => '9.0000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(1) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '1.0000', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.00000000000000000000', + 'pgsqlResult' => '1.00000000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(1) FROM %s t GROUP BY t.col_int', + 'mysql' => self::numericString(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::numericString(), + 'mysqlResult' => '1.0000', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.00000000000000000000', + 'pgsqlResult' => '1.00000000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(1.0) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '1.00000', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.00000000000000000000', + 'pgsqlResult' => '1.00000000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'AVG(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT AVG(t.col_bigint) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '2147483648.0000', + 'sqliteResult' => 2147483648.0, + 'pdoPgsqlResult' => '2147483648.00000000', + 'pgsqlResult' => '2147483648.00000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_float)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'select' => 'SELECT SUM(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::float(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(t.col_decimal) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '0.1', + 'sqliteResult' => 0.1, + 'pdoPgsqlResult' => '0.1', + 'pgsqlResult' => '0.1', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(t.col_int) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'mysqlResult' => '9', + 'sqliteResult' => 9, + 'pdoPgsqlResult' => 9, + 'pgsqlResult' => 9, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(1) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'mysqlResult' => '1', + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(1) FROM %s t GROUP BY t.col_int', + 'mysql' => self::numericString(), + 'sqlite' => self::int(), + 'pdo_pgsql' => TypeCombinator::union(self::numericString(), self::int()), + 'pgsql' => TypeCombinator::union(self::numericString(), self::int()), + 'mysqlResult' => '1', + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(1.0) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '1.0', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.0', + 'pgsqlResult' => '1.0', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SUM(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT SUM(t.col_bigint) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'pgsql' => TypeCombinator::union(self::numericStringOrNull(), self::intOrNull()), + 'mysqlResult' => '2147483648', + 'sqliteResult' => 2147483648, + 'pdoPgsqlResult' => '2147483648', + 'pgsqlResult' => '2147483648', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_float)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_float) + no data' => [ + 'data' => self::dataNone(), + 'select' => 'SELECT MAX(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::floatOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_float) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(t.col_float) FROM %s t GROUP BY t.col_int', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::float(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(t.col_decimal) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '0.1', + 'sqliteResult' => 0.1, + 'pdoPgsqlResult' => '0.1', + 'pgsqlResult' => '0.1', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(t.col_int) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => 9, + 'sqliteResult' => 9, + 'pdoPgsqlResult' => 9, + 'pgsqlResult' => 9, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(1) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(1) + GROUP BY' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(1) FROM %s t GROUP BY t.col_int', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(1.0) FROM %s t', + 'mysql' => self::numericStringOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => self::numericStringOrNull(), + 'mysqlResult' => '1.0', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.0', + 'pgsqlResult' => '1.0', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MAX(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MAX(t.col_bigint) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => 2147483648, + 'sqliteResult' => 2147483648, + 'pdoPgsqlResult' => 2147483648, + 'pgsqlResult' => 2147483648, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(t.col_float)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(t.col_float) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::float(), + 'mysqlResult' => 0.125, + 'sqliteResult' => 0.125, + 'pdoPgsqlResult' => '0.125', + 'pgsqlResult' => 0.125, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(t.col_decimal)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(t.col_decimal) FROM %s t', + 'mysql' => self::numericString(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::numericString(), + 'mysqlResult' => '0.1', + 'sqliteResult' => 0.1, + 'pdoPgsqlResult' => '0.1', + 'pgsqlResult' => '0.1', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(t.col_int) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 9, + 'sqliteResult' => 9, + 'pdoPgsqlResult' => 9, + 'pgsqlResult' => 9, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(t.col_int_nullable) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(-1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(-1) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(1) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(1.0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(1.0) FROM %s t', + 'mysql' => self::numericString(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => self::numericString(), + 'mysqlResult' => '1.0', + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.0', + 'pgsqlResult' => '1.0', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'ABS(t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT ABS(t.col_bigint) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 2147483648, + 'sqliteResult' => 2147483648, + 'pdoPgsqlResult' => 2147483648, + 'pgsqlResult' => 2147483648, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(t.col_int, 0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(t.col_int, 0) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => null, + 'pgsql' => null, + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(t.col_int, 1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(t.col_int, 1) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 0, + 'sqliteResult' => 0, + 'pdoPgsqlResult' => 0, + 'pgsqlResult' => 0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(t.col_int, t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(t.col_int, t.col_int) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 0, + 'sqliteResult' => 0, + 'pdoPgsqlResult' => 0, + 'pgsqlResult' => 0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(t.col_int, t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(t.col_int, t.col_int_nullable) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(10, 7)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(10, 7) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 3, + 'sqliteResult' => 3, + 'pdoPgsqlResult' => 3, + 'pgsqlResult' => 3, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(10, -7)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(10, -7) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 3, + 'sqliteResult' => 3, + 'pdoPgsqlResult' => 3, + 'pgsqlResult' => 3, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'MOD(t.col_bigint, t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT MOD(t.col_bigint, t.col_bigint) FROM %s t', + 'mysql' => self::intOrNull(), + 'sqlite' => self::intOrNull(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 0, + 'sqliteResult' => 0, + 'pdoPgsqlResult' => 0, + 'pgsqlResult' => 0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'BIT_AND(t.col_bigint, t.col_bigint)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT BIT_AND(t.col_bigint, t.col_bigint) FROM %s t', + 'mysql' => self::intNonNegative(), + 'sqlite' => self::intNonNegative(), + 'pdo_pgsql' => self::intNonNegative(), + 'pgsql' => self::intNonNegative(), + 'mysqlResult' => 2147483648, + 'sqliteResult' => 2147483648, + 'pdoPgsqlResult' => 2147483648, + 'pgsqlResult' => 2147483648, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'BIT_AND(t.col_int, t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT BIT_AND(t.col_int, t.col_int) FROM %s t', + 'mysql' => self::intNonNegative(), + 'sqlite' => self::intNonNegative(), + 'pdo_pgsql' => self::intNonNegative(), + 'pgsql' => self::intNonNegative(), + 'mysqlResult' => 9, + 'sqliteResult' => 9, + 'pdoPgsqlResult' => 9, + 'pgsqlResult' => 9, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'BIT_AND(t.col_int, t.col_int_nullable)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT BIT_AND(t.col_int, t.col_int_nullable) FROM %s t', + 'mysql' => self::intNonNegativeOrNull(), + 'sqlite' => self::intNonNegativeOrNull(), + 'pdo_pgsql' => self::intNonNegativeOrNull(), + 'pgsql' => self::intNonNegativeOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'BIT_AND(1, 0)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT BIT_AND(1, 0) FROM %s t', + 'mysql' => self::intNonNegative(), + 'sqlite' => self::intNonNegative(), + 'pdo_pgsql' => self::intNonNegative(), + 'pgsql' => self::intNonNegative(), + 'mysqlResult' => 0, + 'sqliteResult' => 0, + 'pdoPgsqlResult' => 0, + 'pgsqlResult' => 0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'DATE_DIFF(CURRENT_DATE(), CURRENT_DATE())' => [ + 'data' => self::dataDefault(), + 'select' => "SELECT DATE_DIFF('2024-01-01 12:00', '2024-01-01 11:00') FROM %s t", + 'mysql' => self::int(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 0, + 'sqliteResult' => 0.0, + 'pdoPgsqlResult' => 0, + 'pgsqlResult' => 0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'DATE_DIFF(CURRENT_DATE(), t.col_string_nullable)' => [ + 'data' => self::dataDefault(), + 'select' => "SELECT DATE_DIFF('2024-01-01 12:00', t.col_string_nullable) FROM %s t", + 'mysql' => self::intOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::intOrNull(), + 'pgsql' => self::intOrNull(), + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(t.col_float)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(t.col_float) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => 1.0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(t.col_decimal)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(t.col_decimal) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.000000000000000', + 'pgsqlResult' => '1.000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(t.col_int)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(t.col_int) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysqlResult' => 3.0, + 'sqliteResult' => 3.0, + 'pdoPgsqlResult' => '3', + 'pgsqlResult' => 3.0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(t.col_int_nullable)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(t.col_int_nullable) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => PHP_VERSION_ID >= 80100 ? null : self::floatOrNull(), // fails in UDF since PHP 8.1: sqrt(): Passing null to parameter #1 ($num) of type float is deprecated + 'pdo_pgsql' => self::numericStringOrNull(), + 'pgsql' => TypeCombinator::union(self::floatOrNull(), self::numericStringOrNull()), + 'mysqlResult' => null, + 'sqliteResult' => 0.0, // caused by UDF wired through PHP's sqrt() which returns 0.0 for null + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(-1)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(-1) FROM %s t', + 'mysql' => self::floatOrNull(), + 'sqlite' => self::floatOrNull(), + 'pdo_pgsql' => null, // failure: cannot take square root of a negative number + 'pgsql' => null, // failure: cannot take square root of a negative number + 'mysqlResult' => null, + 'sqliteResult' => null, + 'pdoPgsqlResult' => null, + 'pgsqlResult' => null, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(1)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(1) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1', + 'pgsqlResult' => 1.0, + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + yield 'SQRT(1.0)' => [ + 'data' => self::dataSqrt(), + 'select' => 'SELECT SQRT(1.0) FROM %s t', + 'mysql' => self::float(), + 'sqlite' => self::float(), + 'pdo_pgsql' => self::numericString(), + 'pgsql' => TypeCombinator::union(self::float(), self::numericString()), + 'mysqlResult' => 1.0, + 'sqliteResult' => 1.0, + 'pdoPgsqlResult' => '1.000000000000000', + 'pgsqlResult' => '1.000000000000000', + 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + return self::defaultStringification($driver, $php, $configName); + }, + ]; + + // yield 'SQRT(t.col_bigint)' => [ + // 'data' => self::dataSqrt(), + // 'select' => 'SELECT SQRT(t.col_bigint) FROM %s t', + // // 'mysql' => self::floatOrNull(), + // 'sqlite' => null, // sqlite3 returns 300000.0, but pdo_sqlite returns null + // 'pdo_pgsql' => self::numericString(), + // 'pgsql' => TypeCombinator::union(self::floatOrNull(), self::numericString()), + // 'mysqlResult' => 300000.0, + // 'sqliteResult' => null, + // 'pdoPgsqlResult' => '300000.0', + // 'pgsqlResult' => '300000.0', + // 'shouldStringify' => static function (Driver $driver, int $php, string $configName): bool { + // return self::defaultStringification($driver, $php, $configName); + // }, + // ]; + + yield 'COUNT(t)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COUNT(t) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + yield 'COUNT(t.col_int)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COUNT(t.col_int) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + yield 'COUNT(1)' => [ + 'data' => self::dataDefault(), + 'select' => 'SELECT COUNT(1) FROM %s t', + 'mysql' => self::int(), + 'sqlite' => self::int(), + 'pdo_pgsql' => self::int(), + 'pgsql' => self::int(), + 'mysqlResult' => 1, + 'sqliteResult' => 1, + 'pdoPgsqlResult' => 1, + 'pgsqlResult' => 1, + 'shouldStringify' => static function (): bool { + return false; + }, + ]; + + // TODO all from previous dataset + // TODO test nullable fields & data + // TODO bigint & decimal edgecases + // TODO mixed column + // TODO dbal/orm versions + // TODO invalid calls (failing in postgre) + // TODO custom TypedExpression + } + + /** + * @param mixed $expectedFirstResult + * @param array $data + */ + private function performDriverTest( + string $driver, + string $configName, + array $data, + string $dqlTemplate, + string $dataset, + int $phpVersion, + ?Type $expectedInferredType, + $expectedFirstResult, + callable $assertStringified ): void { - $phpVersion = PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION; + $connectionParams = ['driver' => $driver] + $this->getConnectionParamsForDriver($driver); + $dql = sprintf($dqlTemplate, PlatformEntity::class); + + $query = $this->getQuery($dql, $data, $connectionParams, self::CONNECTION_CONFIGS[$configName]); + $sql = $query->getSQL(); + + self::assertIsString($sql); try { - $connection = DriverManager::getConnection($connectionParams + [ - 'user' => 'root', - 'password' => 'secret', - 'dbname' => 'foo', - ]); - - $nativeConnection = $this->getNativeConnection($connection); - $this->setupAttributes($nativeConnection, $connectionAttributes); - - $config = new Configuration(); - $config->setProxyNamespace('PHPstan\Doctrine\OrmMatrixProxies'); - $config->setProxyDir('/tmp/doctrine'); - $config->setAutoGenerateProxyClasses(false); - $config->setSecondLevelCacheEnabled(false); - $config->setMetadataCache(new ArrayCachePool()); - $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader(), [__DIR__ . '/MatrixEntity'])); - $entityManager = new EntityManager($connection, $config); - - } catch (DbalException $e) { - if (strpos($e->getMessage(), 'Doctrine currently supports only the following drivers') !== false) { - self::markTestSkipped($e->getMessage()); // older doctrine versions, needed for old PHP versions + $result = $query->getSingleResult(); + } catch (Throwable $e) { + if ($expectedInferredType === null) { + return; } throw $e; } + if ($expectedInferredType === null) { + self::fail(sprintf( + "Expected failure, but none occurred\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql + )); + } + + $realResultType = ConstantTypeHelper::getTypeFromValue($result); + $inferredType = $this->getInferredType($query); + + $driverInstance = $query->getEntityManager()->getConnection()->getDriver(); + + $stringified = $assertStringified($driverInstance, $phpVersion, $configName); + if ($stringified) { + $expectedInferredType = $this->stringifyType($expectedInferredType); + } + + $this->assertRealResultMatchesExpected($result, $expectedFirstResult, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $stringified); + $this->assertRealResultMatchesInferred($result, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $inferredType, $realResultType); + $this->assertInferredResultMatchesExpected($result, $driver, $configName, $dql, $sql, $dataset, $phpVersion, $inferredType, $expectedInferredType); + } + + /** + * @param array $data + * @param array $connectionParams + * @param array $connectionAttributes + * @return Query $query + */ + private function getQuery( + string $dqlTemplate, + array $data, + array $connectionParams, + array $connectionAttributes + ): Query + { + $connection = DriverManager::getConnection($connectionParams + [ + 'user' => 'root', + 'password' => 'secret', + 'dbname' => 'foo', + ]); + + $nativeConnection = $this->getNativeConnection($connection); + $this->setupAttributes($nativeConnection, $connectionAttributes); + + $config = new Configuration(); + $config->setProxyNamespace('PHPstan\Doctrine\OrmMatrixProxies'); + $config->setProxyDir('/tmp/doctrine'); + $config->setAutoGenerateProxyClasses(false); + $config->setSecondLevelCacheEnabled(false); + $config->setMetadataCache(new ArrayCachePool()); + $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader(), [__DIR__ . '/Entity'])); + $entityManager = new EntityManager($connection, $config); + $schemaTool = new SchemaTool($entityManager); $classes = $entityManager->getMetadataFactory()->getAllMetadata(); $schemaTool->dropSchema($classes); $schemaTool->createSchema($classes); - $entity = new TestEntity(); - $entity->col_bool = true; - $entity->col_float = 0.125; - $entity->col_decimal = '0.1'; - $entity->col_int = 9; - $entity->col_bigint = '2147483648'; - $entity->col_string = 'foobar'; + foreach ($data as $rowData) { + $entity = new PlatformEntity(); + foreach ($rowData as $column => $value) { + $entity->$column = $value; // @phpstan-ignore-line Intentionally dynamic + } + $entityManager->persist($entity); + } - $entityManager->persist($entity); $entityManager->flush(); - $columnsQueryTemplate = 'SELECT %s FROM %s t GROUP BY t.col_int, t.col_float, t.col_decimal, t.col_bigint, t.col_bool, t.col_string'; + $dql = sprintf($dqlTemplate, PlatformEntity::class); - $expected = $phpVersion >= 81 - ? $expectedOnPhp81AndAbove - : $expectedOnPhp80AndBelow; - - foreach ($expected as $select => $expectedType) { - if ($expectedType === null) { - continue; // e.g. no such function - } - $dql = sprintf($columnsQueryTemplate, $select, TestEntity::class); + return $entityManager->createQuery($dql); + } - $query = $entityManager->createQuery($dql); - $result = $query->getSingleResult(); + /** + * @param Query $query + */ + private function getInferredType(Query $query): Type + { + $typeBuilder = new QueryResultTypeBuilder(); + $phpVersion = new PhpVersion(PHP_VERSION_ID); // @phpstan-ignore-line ctor not in bc promise + QueryResultTypeWalker::walk($query, $typeBuilder, self::getContainer()->getByType(DescriptorRegistry::class), $phpVersion); - $typeBuilder = new QueryResultTypeBuilder(); - QueryResultTypeWalker::walk($query, $typeBuilder, self::getContainer()->getByType(DescriptorRegistry::class)); + return $typeBuilder->getResultType(); + } - $inferredPhpStanType = $typeBuilder->getResultType(); - $realRowPhpStanType = ConstantTypeHelper::getTypeFromValue($result); + /** + * @param mixed $realResult + * @param mixed $expectedFirstResult + */ + private function assertRealResultMatchesExpected( + $realResult, + $expectedFirstResult, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + bool $stringified + ): void + { + $humanReadablePhpVersion = $this->getHumanReadablePhpVersion($phpVersion); - $firstResult = reset($result); - $resultType = gettype($firstResult); - $resultExported = var_export($firstResult, true); + $firstResult = reset($realResult); + $realFirstResult = var_export($firstResult, true); + $expectedFirstResultExported = var_export($expectedFirstResult, true); - self::assertTrue( - $inferredPhpStanType->accepts($realRowPhpStanType, true)->yes(), - sprintf( - "Result of 'SELECT %s' for '%s' and PHP %s was inferred as %s, but the real result was %s", - $select, - $this->dataName(), - $phpVersion, - $inferredPhpStanType->describe(VerbosityLevel::precise()), - $realRowPhpStanType->describe(VerbosityLevel::precise()) - ) - ); + $is = $stringified + ? new IsEqual($expectedFirstResult) // loose comparison for stringified + : new IsIdentical($expectedFirstResult); - self::assertThat( + if ($stringified && $firstResult !== null) { + self::assertIsString( $firstResult, - new IsType($expectedType), sprintf( - "Result of 'SELECT %s' for '%s' and PHP %s is expected to be %s, but %s returned (%s).", - $select, - $this->dataName(), - $phpVersion, - $expectedType, - $resultType, - $resultExported + "Stringified result returned non-string\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nPHP: %s\nReal first item: %s\n", + $driver, + $configName, + $dataset, + $dql, + $humanReadablePhpVersion, + $realFirstResult ) ); } + + self::assertThat( + $firstResult, + $is, + sprintf( + "Mismatch between expected result and fetched result\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first item: %s\nExpected first item: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $humanReadablePhpVersion, + $realFirstResult, + $expectedFirstResultExported + ) + ); } /** - * @return iterable + * @param mixed $realResult */ - public function provideCases(): iterable + private function assertRealResultMatchesInferred( + $realResult, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + Type $inferredType, + Type $realType + ): void { - // Preserve space-driven formatting for better readability - // phpcs:disable Squiz.WhiteSpace.OperatorSpacing.SpacingBefore - // phpcs:disable Squiz.WhiteSpace.OperatorSpacing.SpacingAfter - - // Notes: - // - Any direct column fetch uses the type declared in entity, but when passed to a function, the driver decides the type - - $testData = [ // mysql, sqlite, pdo_pgsql, pgsql, stringified, stringifiedOldPostgre - // bool-ish - '(TRUE)' => ['int', 'int', 'bool', 'bool', 'string', 'bool'], - 't.col_bool' => ['bool', 'bool', 'bool', 'bool', 'bool', 'bool'], - 'COALESCE(t.col_bool, TRUE)' => ['int', 'int', 'bool', 'bool', 'string', 'bool'], - - // float-ish - 't.col_float' => ['float', 'float', 'float', 'float', 'float', 'float'], - 'AVG(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'SUM(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'MIN(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'MAX(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'SQRT(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'ABS(t.col_float)' => ['float', 'float', 'string', 'float', 'string', 'string'], - - // decimal-ish - 't.col_decimal' => ['string', 'string', 'string', 'string', 'string', 'string'], - '0.1' => ['string', 'float', 'string', 'string', 'string', 'string'], - '0.125e0' => ['float', 'float', 'string', 'string', 'string', 'string'], - 'AVG(t.col_decimal)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'AVG(t.col_int)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'AVG(t.col_bigint)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'SUM(t.col_decimal)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'MIN(t.col_decimal)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'MAX(t.col_decimal)' => ['string', 'float', 'string', 'string', 'string', 'string'], - 'SQRT(t.col_decimal)' => ['float', 'float', 'string', 'string', 'string', 'string'], - 'SQRT(t.col_int)' => ['float', 'float', 'string', 'float', 'string', 'string'], - 'SQRT(t.col_bigint)' => ['float', null, 'string', 'float', null, null], // sqlite3 returns float, but pdo_sqlite returns NULL - 'ABS(t.col_decimal)' => ['string', 'float', 'string', 'string', 'string', 'string'], - - // int-ish - '1' => ['int', 'int', 'int', 'int', 'string', 'string'], - '2147483648' => ['int', 'int', 'int', 'int', 'string', 'string'], - 't.col_int' => ['int', 'int', 'int', 'int', 'int', 'int'], - 't.col_bigint' => ['string', 'string', 'string', 'string', 'string', 'string'], - 'SUM(t.col_int)' => ['string', 'int', 'int', 'int', 'string', 'string'], - 'SUM(t.col_bigint)' => ['string', 'int', 'string', 'string', 'string', 'string'], - "LENGTH('')" => ['int', 'int', 'int', 'int', 'int', 'int'], - 'COUNT(t)' => ['int', 'int', 'int', 'int', 'int', 'int'], - 'COUNT(1)' => ['int', 'int', 'int', 'int', 'int', 'int'], - 'COUNT(t.col_int)' => ['int', 'int', 'int', 'int', 'int', 'int'], - 'MIN(t.col_int)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'MIN(t.col_bigint)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'MAX(t.col_int)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'MAX(t.col_bigint)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'MOD(t.col_int, 2)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'MOD(t.col_bigint, 2)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'ABS(t.col_int)' => ['int', 'int', 'int', 'int', 'string', 'string'], - 'ABS(t.col_bigint)' => ['int', 'int', 'int', 'int', 'string', 'string'], - - // string - 't.col_string' => ['string', 'string', 'string', 'string', 'string', 'string'], - 'LOWER(t.col_string)' => ['string', 'string', 'string', 'string', 'string', 'string'], - 'UPPER(t.col_string)' => ['string', 'string', 'string', 'string', 'string', 'string'], - 'TRIM(t.col_string)' => ['string', 'string', 'string', 'string', 'string', 'string'], - ]; - - $selects = array_keys($testData); - - $nativeMysql = array_combine($selects, array_column($testData, 0)); - $nativeSqlite = array_combine($selects, array_column($testData, 1)); - $nativePdoPg = array_combine($selects, array_column($testData, 2)); - $nativePg = array_combine($selects, array_column($testData, 3)); - - $stringified = array_combine($selects, array_column($testData, 4)); - $stringifiedOldPostgre = array_combine($selects, array_column($testData, 5)); - - yield 'sqlite3' => [ - 'connection' => ['driver' => 'sqlite3', 'memory' => true], - 'php80-' => $nativeSqlite, - 'php81+' => $nativeSqlite, - 'setup' => [], - ]; - - yield 'pdo_sqlite, no stringify' => [ - 'connection' => ['driver' => 'pdo_sqlite', 'memory' => true], - 'php80-' => $stringified, - 'php81+' => $nativeSqlite, - 'setup' => [], - ]; - - yield 'pdo_sqlite, stringify' => [ - 'connection' => ['driver' => 'pdo_sqlite', 'memory' => true], - 'php80-' => $stringified, - 'php81+' => $stringified, - 'setup' => [PDO::ATTR_STRINGIFY_FETCHES => true], - ]; - - yield 'mysqli, no native numbers' => [ - 'connection' => ['driver' => 'mysqli', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $nativeMysql, - 'php81+' => $nativeMysql, - 'setup' => [ - // This has no effect when using prepared statements (which is what doctrine/dbal uses) - // - prepared statements => always native types - // - non-prepared statements => stringified by default, can be changed by MYSQLI_OPT_INT_AND_FLOAT_NATIVE = true - // documented here: https://www.php.net/manual/en/mysqli.quickstart.prepared-statements.php#example-4303 - MYSQLI_OPT_INT_AND_FLOAT_NATIVE => false, - ], - ]; + $firstResult = reset($realResult); + $realFirstResult = var_export($firstResult, true); - yield 'mysqli, native numbers' => [ - 'connection' => ['driver' => 'mysqli', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $nativeMysql, - 'php81+' => $nativeMysql, - 'setup' => [MYSQLI_OPT_INT_AND_FLOAT_NATIVE => true], - ]; + self::assertTrue( + $inferredType->accepts($realType, true)->yes(), + sprintf( + "Mismatch between inferred type and fetched result\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first result: %s\nInferred type: %s\nReal type: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $this->getHumanReadablePhpVersion($phpVersion), + $realFirstResult, + $inferredType->describe(VerbosityLevel::precise()), + $realType->describe(VerbosityLevel::precise()) + ) + ); + } - yield 'pdo_mysql, stringify, no emulate' => [ - 'connection' => ['driver' => 'pdo_mysql', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $stringified, - 'php81+' => $stringified, - 'setup' => [ - PDO::ATTR_EMULATE_PREPARES => false, - PDO::ATTR_STRINGIFY_FETCHES => true, - ], - ]; + /** + * @param mixed $result + */ + private function assertInferredResultMatchesExpected( + $result, + string $driver, + string $configName, + string $dql, + string $sql, + string $dataset, + int $phpVersion, + Type $inferredType, + Type $expectedFirstItemType + ): void + { + $firstResult = reset($result); + $realFirstResult = var_export($firstResult, true); - yield 'pdo_mysql, no stringify, no emulate' => [ - 'connection' => ['driver' => 'pdo_mysql', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $nativeMysql, - 'php81+' => $nativeMysql, - 'setup' => [PDO::ATTR_EMULATE_PREPARES => false], - ]; + self::assertTrue($inferredType->isConstantArray()->yes()); + $inferredFirstItemType = $inferredType->getFirstIterableValueType(); - yield 'pdo_mysql, no stringify, emulate' => [ - 'connection' => ['driver' => 'pdo_mysql', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $stringified, - 'php81+' => $nativeMysql, - 'setup' => [], // defaults - ]; + self::assertTrue( + $inferredFirstItemType->equals($expectedFirstItemType), + sprintf( + "Mismatch between inferred result and expected type\n\nDriver: %s\nConfig: %s\nDataset: %s\nDQL: %s\nSQL: %s\nPHP: %s\nReal first result: %s\nFirst item inferred as: %s\nFirst item expected type: %s\n", + $driver, + $configName, + $dataset, + $dql, + $sql, + $this->getHumanReadablePhpVersion($phpVersion), + $realFirstResult, + $inferredFirstItemType->describe(VerbosityLevel::precise()), + $expectedFirstItemType->describe(VerbosityLevel::precise()) + ) + ); + } + + /** + * @return array + */ + private function getConnectionParamsForDriver(string $driver): array + { + switch ($driver) { + case 'pdo_mysql': + case 'mysqli': + return ['host' => getenv('MYSQL_HOST')]; + case 'pdo_pgsql': + case 'pgsql': + return ['host' => getenv('PGSQL_HOST')]; + case 'pdo_sqlite': + case 'sqlite3': + return ['memory' => true]; + default: + throw new LogicException('Unknown driver: ' . $driver); + } + } - yield 'pdo_mysql, stringify, emulate' => [ - 'connection' => ['driver' => 'pdo_mysql', 'host' => getenv('MYSQL_HOST')], - 'php80-' => $stringified, - 'php81+' => $stringified, - 'setup' => [ - PDO::ATTR_STRINGIFY_FETCHES => true, + private static function bool(): Type + { + return new BooleanType(); + } + + private static function boolOrNull(): Type + { + return TypeCombinator::addNull(new BooleanType()); + } + + private static function boolAsInt(): Type + { + return TypeCombinator::union( + new ConstantIntegerType(0), + new ConstantIntegerType(1) + ); + } + + private static function numericString(): Type + { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + + private static function numericStringOrNull(): Type + { + return TypeCombinator::addNull(new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ])); + } + + private static function int(): Type + { + return new IntegerType(); + } + + private static function intNonNegative(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + private static function intNonNegativeOrNull(): Type + { + return TypeCombinator::addNull(IntegerRangeType::fromInterval(0, null)); + } + + private static function intOrNull(): Type + { + return TypeCombinator::addNull(new IntegerType()); + } + + private static function float(): Type + { + return new FloatType(); + } + + private static function floatOrNull(): Type + { + return TypeCombinator::addNull(new FloatType()); + } + + /** + * @return array> + */ + public static function dataNone(): array + { + return []; + } + + /** + * @return array> + */ + public static function dataDefault(): array + { + return [ + [ + 'id' => '1', + 'col_bool' => true, + 'col_bool_nullable' => null, + 'col_float' => 0.125, + 'col_float_nullable' => null, + 'col_decimal' => '0.1', + 'col_decimal_nullable' => null, + 'col_int' => 9, + 'col_int_nullable' => null, + 'col_bigint' => '2147483648', + 'col_bigint_nullable' => null, + 'col_string' => 'foobar', + 'col_string_nullable' => null, ], ]; + } - yield 'pdo_pgsql, stringify' => [ - 'connection' => ['driver' => 'pdo_pgsql', 'host' => getenv('PGSQL_HOST')], - 'php80-' => $stringifiedOldPostgre, - 'php81+' => $stringified, - 'setup' => [PDO::ATTR_STRINGIFY_FETCHES => true], + /** + * @return array> + */ + public static function dataSqrt(): array + { + return [ + [ + 'id' => '1', + 'col_bool' => true, + 'col_bool_nullable' => null, + 'col_float' => 1.0, + 'col_float_nullable' => null, + 'col_decimal' => '1.0', + 'col_decimal_nullable' => null, + 'col_int' => 9, + 'col_int_nullable' => null, + 'col_bigint' => '90000000000', + 'col_bigint_nullable' => null, + 'col_string' => 'foobar', + 'col_string_nullable' => null, + ], ]; + } - yield 'pdo_pgsql, no stringify' => [ - 'connection' => ['driver' => 'pdo_pgsql', 'host' => getenv('PGSQL_HOST')], - 'php80-' => $nativePdoPg, - 'php81+' => $nativePdoPg, - 'setup' => [], - ]; + private function stringifyType(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } - yield 'pgsql' => [ - 'connection' => ['driver' => 'pgsql', 'host' => getenv('PGSQL_HOST')], - 'php80-' => $nativePg, - 'php81+' => $nativePg, - 'setup' => [], - ]; + if ($type instanceof IntegerType || $type instanceof FloatType) { + return $type->toString(); + } + + if ($type instanceof BooleanType) { + return $type->toInteger()->toString(); + } + + return $traverse($type); + }); } /** @@ -408,4 +1777,37 @@ private function getNativeConnection(Connection $connection) throw new LogicException('Unable to get native connection'); } + private static function defaultStringification(Driver $driver, int $php, string $configName): bool + { + if ($configName === self::CONFIG_DEFAULT) { + return $php < 80100 && ($driver instanceof PdoMysqlDriver || $driver instanceof PdoSqliteDriver); + } + + if ($configName === self::CONFIG_STRINGIFY || $configName === self::CONFIG_STRINGIFY_NO_EMULATE) { + return $driver instanceof PdoPgSQLDriver + || $driver instanceof PdoMysqlDriver + || $driver instanceof PdoSqliteDriver; + } + + if ($configName === self::CONFIG_NO_EMULATE) { + return false; + } + + throw new LogicException('Unknown config name: ' . $configName); + } + + private static function defaultBooleanStringification(Driver $driver, int $php, string $configName): bool + { + if ($php < 80100 && $driver instanceof PdoPgSQLDriver) { + return false; // pdo_pgsql does not stringify booleans even with ATTR_STRINGIFY_FETCHES prior to PHP 8.1 + } + + return self::defaultStringification($driver, $php, $configName); + } + + private function getHumanReadablePhpVersion(int $phpVersion): string + { + return floor($phpVersion / 10000) . '.' . floor(($phpVersion % 10000) / 100); + } + } diff --git a/tests/Platform/README.md b/tests/Platform/README.md index b9c07d6a..fb2867e7 100644 --- a/tests/Platform/README.md +++ b/tests/Platform/README.md @@ -7,12 +7,13 @@ Set current working directory to project root. # Init services & dependencies - `printf "UID=$(id -u)\nGID=$(id -g)" > .env` - `docker-compose -f tests/Platform/docker/docker-compose.yml up -d` -- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 composer install` # Test behaviour with old stringification +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php80 composer update` - `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php80 php -d memory_limit=1G vendor/bin/phpunit --group=platform` # Test behaviour with new stringification +- `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 composer update` - `docker-compose -f tests/Platform/docker/docker-compose.yml run --rm php81 php -d memory_limit=1G vendor/bin/phpunit --group=platform` ``` diff --git a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php index 57561ef4..46d455a4 100644 --- a/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php +++ b/tests/Rules/Doctrine/ORM/EntityColumnRuleTest.php @@ -76,10 +76,10 @@ protected function getRule(): Rule new StringType(), new SimpleArrayType(), new UuidTypeDescriptor(FakeTestingUuidType::class), - new ReflectionDescriptor(CarbonImmutableType::class, $this->createBroker()), - new ReflectionDescriptor(CarbonType::class, $this->createBroker()), - new ReflectionDescriptor(CustomType::class, $this->createBroker()), - new ReflectionDescriptor(CustomNumericType::class, $this->createBroker()), + new ReflectionDescriptor(CarbonImmutableType::class, $this->createBroker(), self::getContainer()), + new ReflectionDescriptor(CarbonType::class, $this->createBroker(), self::getContainer()), + new ReflectionDescriptor(CustomType::class, $this->createBroker(), self::getContainer()), + new ReflectionDescriptor(CustomNumericType::class, $this->createBroker(), self::getContainer()), ]), $this->createReflectionProvider(), true, diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php index f2f349ed..c1f64592 100644 --- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -11,6 +11,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Query\AST\TypedExpression; use Doctrine\ORM\Tools\SchemaTool; +use PHPStan\Php\PhpVersion; use PHPStan\Testing\PHPStanTestCase; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\BooleanType; @@ -207,7 +208,7 @@ public function test(Type $expectedType, string $dql, ?string $expectedException $this->expectDeprecationMessage($expectedDeprecationMessage); } - QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry, self::getContainer()->getByType(PhpVersion::class)); $type = $typeBuilder->getResultType(); diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Many.php b/tests/Type/Doctrine/data/QueryResult/Entities/Many.php index fffc9a73..919d7f45 100644 --- a/tests/Type/Doctrine/data/QueryResult/Entities/Many.php +++ b/tests/Type/Doctrine/data/QueryResult/Entities/Many.php @@ -23,6 +23,13 @@ class Many */ public $id; + /** + * @Column(type="boolean") + * + * @var bool + */ + public $boolColumn; + /** * @Column(type="integer") *