diff --git a/extension.neon b/extension.neon index 27abded4..59b5a75f 100644 --- a/extension.neon +++ b/extension.neon @@ -15,6 +15,8 @@ parameters: - Doctrine\ORM\Mapping\ClassMetadata - Doctrine\ORM\Mapping\ClassMetadataInfo - Doctrine\Persistence\Mapping\ClassMetadata + - Doctrine\ORM\AbstractQuery + - Doctrine\ORM\Query stubFiles: - stubs/Criteria.stub - stubs/DocumentManager.stub @@ -38,6 +40,7 @@ parameters: - stubs/ORM/AbstractQuery.stub - stubs/ORM/Mapping/ClassMetadata.stub - stubs/ORM/Mapping/ClassMetadataInfo.stub + - stubs/ORM/Query.stub - stubs/Persistence/Mapping/ClassMetadata.stub - stubs/ServiceDocumentRepository.stub @@ -120,6 +123,16 @@ services: class: PHPStan\Type\Doctrine\Query\QueryGetDqlDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension + arguments: + objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: PHPStan\Type\Doctrine\Query\QueryResultDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: PHPStan\Type\Doctrine\QueryBuilder\Expr\ExpressionBuilderDynamicReturnTypeExtension arguments: diff --git a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php new file mode 100644 index 00000000..75d0a54e --- /dev/null +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -0,0 +1,101 @@ + on EntityManagerInterface::createQuery() + */ +final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension +{ + + /** @var ObjectMetadataResolver */ + private $objectMetadataResolver; + + /** @var DescriptorRegistry */ + private $descriptorRegistry; + + public function __construct(ObjectMetadataResolver $objectMetadataResolver, DescriptorRegistry $descriptorRegistry) + { + $this->objectMetadataResolver = $objectMetadataResolver; + $this->descriptorRegistry = $descriptorRegistry; + } + + public function getClass(): string + { + return EntityManagerInterface::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'createQuery'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type + { + $queryStringArgIndex = 0; + $args = $methodCall->getArgs(); + + if (!isset($args[$queryStringArgIndex])) { + return new GenericObjectType( + Query::class, + [new MixedType()] + ); + } + + $argType = $scope->getType($args[$queryStringArgIndex]->value); + + return TypeTraverser::map($argType, function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type instanceof ConstantStringType) { + $queryString = $type->getValue(); + + $em = $this->objectMetadataResolver->getObjectManager(); + if (!$em instanceof EntityManagerInterface) { + return new QueryType($queryString, null); + } + + $typeBuilder = new QueryResultTypeBuilder(); + + try { + $query = $em->createQuery($queryString); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + } catch (ORMException | DBALException | CommonException $e) { + return new QueryType($queryString, null); + } + + return new QueryType($queryString, $typeBuilder->getResultType()); + } + return new GenericObjectType( + Query::class, + [new MixedType()] + ); + }); + } + +} diff --git a/src/Type/Doctrine/Descriptors/ArrayType.php b/src/Type/Doctrine/Descriptors/ArrayType.php index 5639fd16..4e16ad75 100644 --- a/src/Type/Doctrine/Descriptors/ArrayType.php +++ b/src/Type/Doctrine/Descriptors/ArrayType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ArrayType(new MixedType(), new MixedType()); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/BigIntType.php b/src/Type/Doctrine/Descriptors/BigIntType.php index 0c3b437a..f4ae1d5c 100644 --- a/src/Type/Doctrine/Descriptors/BigIntType.php +++ b/src/Type/Doctrine/Descriptors/BigIntType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::union(new \PHPStan\Type\StringType(), new \PHPStan\Type\IntegerType()); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\IntegerType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/BinaryType.php b/src/Type/Doctrine/Descriptors/BinaryType.php index 72b8be47..5b3c848a 100644 --- a/src/Type/Doctrine/Descriptors/BinaryType.php +++ b/src/Type/Doctrine/Descriptors/BinaryType.php @@ -4,6 +4,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\ResourceType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; class BinaryType implements DoctrineTypeDescriptor @@ -24,4 +25,9 @@ public function getWritableToDatabaseType(): Type return new MixedType(); } + public function getDatabaseInternalType(): Type + { + return new StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/BlobType.php b/src/Type/Doctrine/Descriptors/BlobType.php index b7f509f2..c4b89907 100644 --- a/src/Type/Doctrine/Descriptors/BlobType.php +++ b/src/Type/Doctrine/Descriptors/BlobType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new MixedType(); } + public function getDatabaseInternalType(): Type + { + return new MixedType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/BooleanType.php b/src/Type/Doctrine/Descriptors/BooleanType.php index 44810d3e..7e061a6c 100644 --- a/src/Type/Doctrine/Descriptors/BooleanType.php +++ b/src/Type/Doctrine/Descriptors/BooleanType.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Doctrine\Descriptors; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; class BooleanType implements DoctrineTypeDescriptor { @@ -22,4 +23,12 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\BooleanType(); } + public function getDatabaseInternalType(): Type + { + return TypeCombinator::union( + new \PHPStan\Type\Constant\ConstantIntegerType(0), + new \PHPStan\Type\Constant\ConstantIntegerType(1) + ); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateImmutableType.php b/src/Type/Doctrine/Descriptors/DateImmutableType.php index 4dd436f6..20ad269c 100644 --- a/src/Type/Doctrine/Descriptors/DateImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateImmutableType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateIntervalType.php b/src/Type/Doctrine/Descriptors/DateIntervalType.php index 21ba57a2..ccc0e7a4 100644 --- a/src/Type/Doctrine/Descriptors/DateIntervalType.php +++ b/src/Type/Doctrine/Descriptors/DateIntervalType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateInterval::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php b/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php index 45c7e353..ab200299 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeImmutableType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateTimeType.php b/src/Type/Doctrine/Descriptors/DateTimeType.php index 69d11e7b..94d6c11b 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ObjectType(DateTimeInterface::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php b/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php index 2061c892..dec13b71 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeTzImmutableType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateTimeTzType.php b/src/Type/Doctrine/Descriptors/DateTimeTzType.php index 0f5689dd..74046446 100644 --- a/src/Type/Doctrine/Descriptors/DateTimeTzType.php +++ b/src/Type/Doctrine/Descriptors/DateTimeTzType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ObjectType(DateTimeInterface::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DateType.php b/src/Type/Doctrine/Descriptors/DateType.php index cb63a3eb..d3fd56b5 100644 --- a/src/Type/Doctrine/Descriptors/DateType.php +++ b/src/Type/Doctrine/Descriptors/DateType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ObjectType(DateTimeInterface::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/DecimalType.php b/src/Type/Doctrine/Descriptors/DecimalType.php index fcdc2657..1f2ee27c 100644 --- a/src/Type/Doctrine/Descriptors/DecimalType.php +++ b/src/Type/Doctrine/Descriptors/DecimalType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::union(new \PHPStan\Type\StringType(), new \PHPStan\Type\FloatType(), new \PHPStan\Type\IntegerType()); } + public function getDatabaseInternalType(): Type + { + return TypeCombinator::union(new \PHPStan\Type\FloatType(), new \PHPStan\Type\IntegerType()); + } + } diff --git a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php index 629db019..f4d85081 100644 --- a/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/DoctrineTypeDescriptor.php @@ -16,4 +16,6 @@ public function getWritableToPropertyType(): Type; public function getWritableToDatabaseType(): Type; + public function getDatabaseInternalType(): Type; + } diff --git a/src/Type/Doctrine/Descriptors/FloatType.php b/src/Type/Doctrine/Descriptors/FloatType.php index 0b4813dc..1898eb06 100644 --- a/src/Type/Doctrine/Descriptors/FloatType.php +++ b/src/Type/Doctrine/Descriptors/FloatType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::union(new \PHPStan\Type\FloatType(), new \PHPStan\Type\IntegerType()); } + public function getDatabaseInternalType(): Type + { + return TypeCombinator::union(new \PHPStan\Type\FloatType(), new \PHPStan\Type\IntegerType()); + } + } diff --git a/src/Type/Doctrine/Descriptors/GuidType.php b/src/Type/Doctrine/Descriptors/GuidType.php index 9d3d83cb..6e24bbf2 100644 --- a/src/Type/Doctrine/Descriptors/GuidType.php +++ b/src/Type/Doctrine/Descriptors/GuidType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return new StringType(); } + public function getDatabaseInternalType(): Type + { + return new StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/IntegerType.php b/src/Type/Doctrine/Descriptors/IntegerType.php index 9ccaa28b..4ecb6e5b 100644 --- a/src/Type/Doctrine/Descriptors/IntegerType.php +++ b/src/Type/Doctrine/Descriptors/IntegerType.php @@ -22,4 +22,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\IntegerType(); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\IntegerType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/JsonArrayType.php b/src/Type/Doctrine/Descriptors/JsonArrayType.php index 601bd96a..9f9a3962 100644 --- a/src/Type/Doctrine/Descriptors/JsonArrayType.php +++ b/src/Type/Doctrine/Descriptors/JsonArrayType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ArrayType(new MixedType(), new MixedType()); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/JsonType.php b/src/Type/Doctrine/Descriptors/JsonType.php index fcd15a1c..6d4e995b 100644 --- a/src/Type/Doctrine/Descriptors/JsonType.php +++ b/src/Type/Doctrine/Descriptors/JsonType.php @@ -49,4 +49,9 @@ public function getWritableToDatabaseType(): Type return self::getJsonType(); } + public function getDatabaseInternalType(): Type + { + return new StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/ObjectType.php b/src/Type/Doctrine/Descriptors/ObjectType.php index d6e7e767..8a9d2888 100644 --- a/src/Type/Doctrine/Descriptors/ObjectType.php +++ b/src/Type/Doctrine/Descriptors/ObjectType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return new ObjectWithoutClassType(); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php index f422e9c0..76433348 100644 --- a/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php +++ b/src/Type/Doctrine/Descriptors/Ramsey/UuidTypeDescriptor.php @@ -54,4 +54,9 @@ public function getWritableToDatabaseType(): Type ); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php index c3e97ca5..d81bab30 100644 --- a/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php +++ b/src/Type/Doctrine/Descriptors/ReflectionDescriptor.php @@ -45,4 +45,9 @@ public function getWritableToDatabaseType(): Type return TypeCombinator::removeNull($type); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\MixedType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/SimpleArrayType.php b/src/Type/Doctrine/Descriptors/SimpleArrayType.php index 71654cad..35fbf90a 100644 --- a/src/Type/Doctrine/Descriptors/SimpleArrayType.php +++ b/src/Type/Doctrine/Descriptors/SimpleArrayType.php @@ -23,4 +23,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ArrayType(new MixedType(), new MixedType()); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/SmallIntType.php b/src/Type/Doctrine/Descriptors/SmallIntType.php index e041ac78..f3fb5d3e 100644 --- a/src/Type/Doctrine/Descriptors/SmallIntType.php +++ b/src/Type/Doctrine/Descriptors/SmallIntType.php @@ -22,4 +22,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\IntegerType(); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\IntegerType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/StringType.php b/src/Type/Doctrine/Descriptors/StringType.php index 2b99c254..98fa5a02 100644 --- a/src/Type/Doctrine/Descriptors/StringType.php +++ b/src/Type/Doctrine/Descriptors/StringType.php @@ -22,4 +22,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\StringType(); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/TextType.php b/src/Type/Doctrine/Descriptors/TextType.php index a3fd63c0..ffab4896 100644 --- a/src/Type/Doctrine/Descriptors/TextType.php +++ b/src/Type/Doctrine/Descriptors/TextType.php @@ -22,4 +22,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\StringType(); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/TimeImmutableType.php b/src/Type/Doctrine/Descriptors/TimeImmutableType.php index e20b4b51..d04a4b76 100644 --- a/src/Type/Doctrine/Descriptors/TimeImmutableType.php +++ b/src/Type/Doctrine/Descriptors/TimeImmutableType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new ObjectType(DateTimeImmutable::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Descriptors/TimeType.php b/src/Type/Doctrine/Descriptors/TimeType.php index 5944fefe..15eac7b2 100644 --- a/src/Type/Doctrine/Descriptors/TimeType.php +++ b/src/Type/Doctrine/Descriptors/TimeType.php @@ -24,4 +24,9 @@ public function getWritableToDatabaseType(): Type return new \PHPStan\Type\ObjectType(DateTimeInterface::class); } + public function getDatabaseInternalType(): Type + { + return new \PHPStan\Type\StringType(); + } + } diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php new file mode 100644 index 00000000..d7c93824 --- /dev/null +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -0,0 +1,139 @@ + 0, + 'execute' => 1, + 'executeIgnoreQueryCache' => 1, + 'executeUsingQueryCache' => 1, + 'getOneOrNullResult' => 0, + 'getSingleResult' => 0, + ]; + + public function getClass(): string + { + return AbstractQuery::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]); + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type + { + $methodName = $methodReflection->getName(); + + if (!isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) { + throw new ShouldNotHappenException(); + } + + $argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName]; + $args = $methodCall->getArgs(); + + if (isset($args[$argIndex])) { + $hydrationMode = $scope->getType($args[$argIndex]->value); + } else { + $parametersAcceptor = ParametersAcceptorSelector::selectSingle( + $methodReflection->getVariants() + ); + $parameter = $parametersAcceptor->getParameters()[$argIndex]; + $hydrationMode = $parameter->getDefaultValue() ?? new NullType(); + } + + $queryType = $scope->getType($methodCall->var); + $queryResultType = $this->getQueryResultType($queryType); + + return $this->getMethodReturnTypeForHydrationMode( + $methodReflection, + $hydrationMode, + $queryResultType + ); + } + + private function getQueryResultType(Type $queryType): Type + { + if (!$queryType instanceof GenericObjectType) { + return new MixedType(); + } + + $types = $queryType->getTypes(); + + return $types[0] ?? new MixedType(); + } + + private function getMethodReturnTypeForHydrationMode( + MethodReflection $methodReflection, + Type $hydrationMode, + Type $queryResultType + ): Type + { + if ($queryResultType instanceof VoidType) { + // A void query result type indicates an UPDATE or DELETE query. + // In this case all methods return the number of affected rows. + return new IntegerType(); + } + + if (!$this->isObjectHydrationMode($hydrationMode)) { + // We support only HYDRATE_OBJECT. For other hydration modes, we + // return the declared return type of the method. + return $this->originalReturnType($methodReflection); + } + + switch ($methodReflection->getName()) { + case 'getSingleResult': + return $queryResultType; + case 'getOneOrNullResult': + return TypeCombinator::addNull($queryResultType); + default: + return new ArrayType( + new MixedType(), + $queryResultType + ); + } + } + + private function isObjectHydrationMode(Type $type): bool + { + if (!$type instanceof ConstantIntegerType) { + return false; + } + + return $type->getValue() === AbstractQuery::HYDRATE_OBJECT; + } + + private function originalReturnType(MethodReflection $methodReflection): Type + { + $parametersAcceptor = ParametersAcceptorSelector::selectSingle( + $methodReflection->getVariants() + ); + + return $parametersAcceptor->getReturnType(); + } + +} diff --git a/src/Type/Doctrine/Query/QueryResultTypeBuilder.php b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php new file mode 100644 index 00000000..edb732dc --- /dev/null +++ b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php @@ -0,0 +1,230 @@ + + */ + private $entities = []; + + /** + * Map from selected entity alias to result alias + * + * Example: "hello" is a result alias in "SELECT e AS hello FROM Entity e" + * + * @var array + */ + private $entityResultAliases = []; + + /** + * Map from selected scalar result alias to scalar type + * + * @var array + */ + private $scalars = []; + + /** + * Map from selected NEW objcet result alias to NEW object type + * + * @var array + */ + private $newObjects = []; + + public function setSelectQuery(): void + { + $this->selectQuery = true; + } + + public function isSelectQuery(): bool + { + return $this->selectQuery; + } + + public function addEntity(string $entityAlias, Type $type, ?string $resultAlias): void + { + $this->entities[$entityAlias] = $type; + if ($resultAlias === null) { + return; + } + + $this->entityResultAliases[$entityAlias] = $resultAlias; + $this->isShape = true; + } + + /** + * @return array + */ + public function getEntities(): array + { + return $this->entities; + } + + /** + * @param array-key $alias + */ + public function addScalar($alias, Type $type): void + { + $this->scalars[$alias] = $type; + $this->isShape = true; + } + + /** + * @return array + */ + public function getScalars(): array + { + return $this->scalars; + } + + /** + * @param array-key $alias + */ + public function addNewObject($alias, Type $type): void + { + $this->newObjects[$alias] = $type; + if (count($this->newObjects) <= 1) { + return; + } + + $this->isShape = true; + } + + /** + * @return array + */ + public function getNewObjects(): array + { + return $this->newObjects; + } + + public function getResultType(): Type + { + // There are a few special cases here, depending on what is selected: + // + // - Just one entity: + // - Without alias: Result is the entity + // - With an alias: array{alias: entity} + // + // - One NEW object, with any entities: + // - Result is the NEW object (entities are ignored, alias is ignored) + // + // - NEW objects and/or scalars: + // - Result is an array shape with one element per NEW object and + // scalar. Keys are the aliases or an incremental numeric index + // (scalars start at 1, NEW objects are 0). NEW objects can shadow + // scalars. + // + // - Multiple arbitrarily joint entities: + // - Without aliases: Result is an alternation of the entities, + // like Entity1|Entity2|EntityN + // - With aliases: Result is an alternation of array shapes, + // like array{alias: Entity1}|array{alias: Entity2}|... + // + // - One or more entities plus scalars and NEW objects: + // - Result is an alternation of intersections of the shapes described + // in "Multiple arbitrarily joint entities" and "NEW objects and/or + // scalars". Entities without a alias are at offset 0 in the shape. + + // We use Void for non-select queries. This is used as a marker by the + // DynamicReturnTypeExtension for Query::getResult() and variants. + if (!$this->selectQuery) { + return new VoidType(); + } + + // If there is a single NEW object and no scalars, the result is the + // NEW object. This ignores any entity. + // https://github.com/doctrine/orm/blob/v2.7.3/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php#L566-L570 + if (count($this->newObjects) === 1 && count($this->scalars) === 0) { + foreach ($this->newObjects as $newObjects) { + return $newObjects; + } + } + + if (count($this->entities) === 0) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $this->addNonEntitiesToShapeResult($builder); + return $builder->getArray(); + } + + $alternatives = []; + $lastEntityAlias = array_key_last($this->entities); + + foreach ($this->entities as $entityAlias => $entityType) { + if (!$this->isShape) { + $alternatives[] = $entityType; + + continue; + } + + $resultAlias = $this->entityResultAliases[$entityAlias] ?? 0; + $offsetType = $this->resolveOffsetType($resultAlias); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $builder->setOffsetValueType($offsetType, $entityType); + + if ($entityAlias === $lastEntityAlias) { + $this->addNonEntitiesToShapeResult($builder); + } + + $alternatives[] = $builder->getArray(); + } + + return TypeCombinator::union(...$alternatives); + } + + private function addNonEntitiesToShapeResult(ConstantArrayTypeBuilder $builder): void + { + foreach ($this->scalars as $alias => $scalarType) { + $offsetType = $this->resolveOffsetType($alias); + $builder->setOffsetValueType($offsetType, $scalarType); + } + + foreach ($this->newObjects as $alias => $newObjectType) { + $offsetType = $this->resolveOffsetType($alias); + $builder->setOffsetValueType($offsetType, $newObjectType); + } + } + + /** + * @param array-key $alias + */ + private function resolveOffsetType($alias): Type + { + if (is_int($alias)) { + return new ConstantIntegerType($alias); + } + + return new ConstantStringType($alias); + } + +} diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php new file mode 100644 index 00000000..4ca750ec --- /dev/null +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -0,0 +1,1321 @@ +, + * parent: mixed, + * relation: ?array{ + * orderBy: array, + * indexBy: ?string, + * fieldName: string, + * targetEntity: string, + * sourceEntity: string, + * isOwningSide: bool, + * mappedBy: string, + * type: int, + * }, + * map: mixed, + * nestingLevel: int, + * token: mixed, + * } + */ +class QueryResultTypeWalker extends SqlWalker +{ + + private const HINT_TYPE_MAPPING = __CLASS__ . '::HINT_TYPE_MAPPING'; + + private const HINT_DESCRIPTOR_REGISTRY = __CLASS__ . '::HINT_DESCRIPTOR_REGISTRY'; + + /** + * Counter for generating unique scalar result. + * + * @var int + */ + private $scalarResultCounter = 1; + + /** + * Counter for generating indexes. + * + * @var int + */ + private $newObjectCounter = 0; + + /** @var Query */ + private $query; + + /** @var EntityManagerInterface */ + private $em; + + /** + * Map of all components/classes that appear in the DQL query. + * + * @var array $queryComponents + */ + private $queryComponents; + + /** @var array */ + private $nullableQueryComponents; + + /** @var QueryResultTypeBuilder */ + private $typeBuilder; + + /** @var DescriptorRegistry */ + private $descriptorRegistry; + + /** @var bool */ + private $isAggregated; + + /** + * @param Query $query + */ + public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry): void + { + $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, self::class); + $query->setHint(self::HINT_TYPE_MAPPING, $typeBuilder); + $query->setHint(self::HINT_DESCRIPTOR_REGISTRY, $descriptorRegistry); + + $parser = new Parser($query); + $parser->parse(); + } + + /** + * {@inheritDoc} + * + * @param Query $query + * @param ParserResult $parserResult + * @param array $queryComponents + */ + public function __construct($query, $parserResult, array $queryComponents) + { + $this->query = $query; + $this->em = $query->getEntityManager(); + $this->queryComponents = $queryComponents; + $this->nullableQueryComponents = []; + $this->isAggregated = false; + + // The object is instantiated by Doctrine\ORM\Query\Parser, so receiving + // dependencies through the constructor is not an option. Instead, we + // receive the dependencies via query hints. + + $typeBuilder = $this->query->getHint(self::HINT_TYPE_MAPPING); + + if (!$typeBuilder instanceof QueryResultTypeBuilder) { + throw new ShouldNotHappenException(sprintf( + 'Expected the query hint %s to contain a %s, but got a %s', + self::HINT_TYPE_MAPPING, + QueryResultTypeBuilder::class, + is_object($typeBuilder) ? get_class($typeBuilder) : gettype($typeBuilder) + )); + } + + $this->typeBuilder = $typeBuilder; + + $descriptorRegistry = $this->query->getHint(self::HINT_DESCRIPTOR_REGISTRY); + + if (!$descriptorRegistry instanceof DescriptorRegistry) { + throw new ShouldNotHappenException(sprintf( + 'Expected the query hint %s to contain a %s, but got a %s', + self::HINT_DESCRIPTOR_REGISTRY, + DescriptorRegistry::class, + is_object($descriptorRegistry) ? get_class($descriptorRegistry) : gettype($descriptorRegistry) + )); + } + + $this->descriptorRegistry = $descriptorRegistry; + + parent::__construct($query, $parserResult, $queryComponents); + } + + /** + * {@inheritdoc} + */ + public function walkSelectStatement(AST\SelectStatement $AST) + { + $this->typeBuilder->setSelectQuery(); + $this->isAggregated = $this->isAggregated($AST); + + $this->walkFromClause($AST->fromClause); + + foreach ($AST->selectClause->selectExpressions as $selectExpression) { + assert($selectExpression instanceof AST\Node); + + $selectExpression->dispatch($this); + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function walkUpdateStatement(AST\UpdateStatement $AST) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkDeleteStatement(AST\DeleteStatement $AST) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkEntityIdentificationVariable($identVariable) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkIdentificationVariable($identificationVariable, $fieldName = null) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkPathExpression($pathExpr) + { + assert($pathExpr instanceof AST\PathExpression); + + $fieldName = $pathExpr->field; + $dqlAlias = $pathExpr->identificationVariable; + $qComp = $this->queryComponents[$dqlAlias]; + $class = $qComp['metadata']; + + assert($fieldName !== null); + + switch ($pathExpr->type) { + case AST\PathExpression::TYPE_STATE_FIELD: + $typeName = $class->getTypeOfField($fieldName); + + assert(is_string($typeName)); + + $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->isAggregated; + + $fieldType = $this->resolveDatabaseInternalType($typeName, $nullable); + + return $this->marshalType($fieldType); + + case AST\PathExpression::TYPE_SINGLE_VALUED_ASSOCIATION: + if (isset($class->associationMappings[$fieldName]['inherited'])) { + assert(is_string($class->associationMappings[$fieldName]['inherited'])); + $class = $this->em->getClassMetadata($class->associationMappings[$fieldName]['inherited']); + } + + $assoc = $class->associationMappings[$fieldName]; + + assert(is_array($assoc['joinColumns'])); + + if (!((bool) $assoc['isOwningSide']) || count($assoc['joinColumns']) !== 1) { + throw new ShouldNotHappenException(); + } + + $joinColumn = $assoc['joinColumns'][0]; + assert(is_array($joinColumn)); + assert(is_string($assoc['targetEntity'])); + + $targetClass = $this->em->getClassMetadata($assoc['targetEntity']); + $identifierFieldNames = $targetClass->getIdentifierFieldNames(); + + if (count($identifierFieldNames) !== 1) { + throw new ShouldNotHappenException(); + } + + $targetFieldName = $identifierFieldNames[0]; + $typeName = $targetClass->getTypeOfField($targetFieldName); + + assert(is_string($typeName)); + + $nullable = ((bool) ($joinColumn['nullable'] ?? true)) || $this->isAggregated; + + $fieldType = $this->resolveDatabaseInternalType($typeName, $nullable); + + return $this->marshalType($fieldType); + + default: + throw new ShouldNotHappenException(); + } + } + + /** + * {@inheritdoc} + */ + public function walkSelectClause($selectClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkFromClause($fromClause) + { + foreach ($fromClause->identificationVariableDeclarations as $identificationVariableDecl) { + assert($identificationVariableDecl instanceof AST\Node); + + $identificationVariableDecl->dispatch($this); + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function walkIdentificationVariableDeclaration($identificationVariableDecl) + { + foreach ($identificationVariableDecl->joins as $join) { + assert($join instanceof AST\Node); + + $join->dispatch($this); + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function walkIndexBy($indexBy): void + { + } + + /** + * {@inheritdoc} + */ + public function walkRangeVariableDeclaration($rangeVariableDeclaration) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkJoinAssociationDeclaration($joinAssociationDeclaration, $joinType = AST\Join::JOIN_TYPE_INNER, $condExpr = null) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkFunction($function) + { + switch (true) { + case ($function instanceof AST\Functions\AvgFunction): + case ($function instanceof AST\Functions\MaxFunction): + case ($function instanceof AST\Functions\MinFunction): + case ($function instanceof AST\Functions\SumFunction): + case ($function instanceof AST\Functions\CountFunction): + return $function->getSql($this); + + case ($function instanceof AST\Functions\AbsFunction): + $exprType = $this->unmarshalType($function->simpleArithmeticExpression->dispatch($this)); + + $type = TypeCombinator::union( + IntegerRangeType::fromInterval(0, null), + new FloatType() + ); + + if (TypeCombinator::containsNull($exprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\BitAndFunction): + case ($function instanceof AST\Functions\BitOrFunction): + $firstExprType = $this->unmarshalType($function->firstArithmetic->dispatch($this)); + $secondExprType = $this->unmarshalType($function->secondArithmetic->dispatch($this)); + + $type = IntegerRangeType::fromInterval(0, null); + if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\ConcatFunction): + $hasNull = false; + + foreach ($function->concatExpressions as $expr) { + $type = $this->unmarshalType($expr->dispatch($this)); + $hasNull = $hasNull || TypeCombinator::containsNull($type); + } + + $type = new StringType(); + if ($hasNull) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\CurrentDateFunction): + case ($function instanceof AST\Functions\CurrentTimeFunction): + case ($function instanceof AST\Functions\CurrentTimestampFunction): + return $this->marshalType(new StringType()); + + case ($function instanceof AST\Functions\DateAddFunction): + case ($function instanceof AST\Functions\DateSubFunction): + $dateExprType = $this->unmarshalType($function->firstDateExpression->dispatch($this)); + $intervalExprType = $this->unmarshalType($function->intervalExpression->dispatch($this)); + + $type = new StringType(); + if (TypeCombinator::containsNull($dateExprType) || TypeCombinator::containsNull($intervalExprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\DateDiffFunction): + $date1ExprType = $this->unmarshalType($function->date1->dispatch($this)); + $date2ExprType = $this->unmarshalType($function->date2->dispatch($this)); + + $type = TypeCombinator::union( + new IntegerType(), + new FloatType() + ); + if (TypeCombinator::containsNull($date1ExprType) || TypeCombinator::containsNull($date2ExprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\LengthFunction): + $stringPrimaryType = $this->unmarshalType($function->stringPrimary->dispatch($this)); + + $type = IntegerRangeType::fromInterval(0, null); + if (TypeCombinator::containsNull($stringPrimaryType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\LocateFunction): + $firstExprType = $this->unmarshalType($function->firstStringPrimary->dispatch($this)); + $secondExprType = $this->unmarshalType($function->secondStringPrimary->dispatch($this)); + + $type = IntegerRangeType::fromInterval(0, null); + if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\LowerFunction): + case ($function instanceof AST\Functions\TrimFunction): + case ($function instanceof AST\Functions\UpperFunction): + $stringPrimaryType = $this->unmarshalType($function->stringPrimary->dispatch($this)); + + $type = new StringType(); + if (TypeCombinator::containsNull($stringPrimaryType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\ModFunction): + $firstExprType = $this->unmarshalType($function->firstSimpleArithmeticExpression->dispatch($this)); + $secondExprType = $this->unmarshalType($function->secondSimpleArithmeticExpression->dispatch($this)); + + $type = IntegerRangeType::fromInterval(0, null); + if (TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + $type = TypeCombinator::addNull($type); + } + + if ((new ConstantIntegerType(0))->isSuperTypeOf($secondExprType)->maybe()) { + // MOD(x, 0) returns NULL + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\SqrtFunction): + $exprType = $this->unmarshalType($function->simpleArithmeticExpression->dispatch($this)); + + $type = new FloatType(); + if (TypeCombinator::containsNull($exprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + case ($function instanceof AST\Functions\SubstringFunction): + $stringType = $this->unmarshalType($function->stringPrimary->dispatch($this)); + $firstExprType = $this->unmarshalType($function->firstSimpleArithmeticExpression->dispatch($this)); + + if ($function->secondSimpleArithmeticExpression !== null) { + $secondExprType = $this->unmarshalType($function->secondSimpleArithmeticExpression->dispatch($this)); + } else { + $secondExprType = new IntegerType(); + } + + $type = new StringType(); + if (TypeCombinator::containsNull($stringType) || TypeCombinator::containsNull($firstExprType) || TypeCombinator::containsNull($secondExprType)) { + $type = TypeCombinator::addNull($type); + } + + return $this->marshalType($type); + + default: + return $this->marshalType(new MixedType()); + } + } + + /** + * {@inheritdoc} + */ + public function walkOrderByClause($orderByClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkOrderByItem($orderByItem) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkHavingClause($havingClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkJoin($join) + { + $joinType = $join->joinType; + $joinDeclaration = $join->joinAssociationDeclaration; + + switch (true) { + case ($joinDeclaration instanceof AST\RangeVariableDeclaration): + $dqlAlias = $joinDeclaration->aliasIdentificationVariable; + + $this->nullableQueryComponents[$dqlAlias] = $joinType === AST\Join::JOIN_TYPE_LEFT || $joinType === AST\Join::JOIN_TYPE_LEFTOUTER; + + break; + case ($joinDeclaration instanceof AST\JoinAssociationDeclaration): + $dqlAlias = $joinDeclaration->aliasIdentificationVariable; + + $this->nullableQueryComponents[$dqlAlias] = $joinType === AST\Join::JOIN_TYPE_LEFT || $joinType === AST\Join::JOIN_TYPE_LEFTOUTER; + + break; + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function walkCoalesceExpression($coalesceExpression) + { + $expressionTypes = []; + $allTypesContainNull = true; + + foreach ($coalesceExpression->scalarExpressions as $expression) { + if (!$expression instanceof AST\Node) { + $expressionTypes[] = new MixedType(); + continue; + } + + $type = $this->unmarshalType($expression->dispatch($this)); + $allTypesContainNull = $allTypesContainNull && TypeCombinator::containsNull($type); + + $expressionTypes[] = $type; + } + + $type = TypeCombinator::union(...$expressionTypes); + + if (!$allTypesContainNull) { + $type = TypeCombinator::removeNull($type); + } + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkNullIfExpression($nullIfExpression) + { + $firstExpression = $nullIfExpression->firstExpression; + + if (!$firstExpression instanceof AST\Node) { + return $this->marshalType(new MixedType()); + } + + $firstType = $this->unmarshalType($firstExpression->dispatch($this)); + + // NULLIF() returns the first expression or NULL + $type = TypeCombinator::addNull($firstType); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkGeneralCaseExpression(AST\GeneralCaseExpression $generalCaseExpression) + { + $whenClauses = $generalCaseExpression->whenClauses; + $elseScalarExpression = $generalCaseExpression->elseScalarExpression; + $types = []; + + foreach ($whenClauses as $clause) { + if (!$clause instanceof AST\WhenClause) { + $types[] = new MixedType(); + continue; + } + + $thenScalarExpression = $clause->thenScalarExpression; + if (!$thenScalarExpression instanceof AST\Node) { + $types[] = new MixedType(); + continue; + } + + $types[] = $this->unmarshalType( + $thenScalarExpression->dispatch($this) + ); + } + + if ($elseScalarExpression instanceof AST\Node) { + $types[] = $this->unmarshalType( + $elseScalarExpression->dispatch($this) + ); + } + + $type = TypeCombinator::union(...$types); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkSimpleCaseExpression($simpleCaseExpression) + { + $whenClauses = $simpleCaseExpression->simpleWhenClauses; + $elseScalarExpression = $simpleCaseExpression->elseScalarExpression; + $types = []; + + foreach ($whenClauses as $clause) { + if (!$clause instanceof AST\SimpleWhenClause) { + $types[] = new MixedType(); + continue; + } + + $thenScalarExpression = $clause->thenScalarExpression; + if (!$thenScalarExpression instanceof AST\Node) { + $types[] = new MixedType(); + continue; + } + + $types[] = $this->unmarshalType( + $thenScalarExpression->dispatch($this) + ); + } + + if ($elseScalarExpression instanceof AST\Node) { + $types[] = $this->unmarshalType( + $elseScalarExpression->dispatch($this) + ); + } + + $type = TypeCombinator::union(...$types); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkSelectExpression($selectExpression) + { + $expr = $selectExpression->expression; + $hidden = $selectExpression->hiddenAliasResultVariable; + + if ($hidden) { + return ''; + } + + if (is_string($expr)) { + $dqlAlias = $expr; + $queryComp = $this->queryComponents[$dqlAlias]; + $class = $queryComp['metadata']; + $resultAlias = $selectExpression->fieldIdentificationVariable ?? $dqlAlias; + + if ($queryComp['parent'] !== null) { + return ''; + } + + $type = new ObjectType($class->name); + + if ($this->isQueryComponentNullable($dqlAlias) || $this->isAggregated) { + $type = TypeCombinator::addNull($type); + } + + $this->typeBuilder->addEntity($resultAlias, $type, $selectExpression->fieldIdentificationVariable); + + return ''; + } + + if ($expr instanceof AST\PathExpression) { + assert($expr->type === AST\PathExpression::TYPE_STATE_FIELD); + + $fieldName = $expr->field; + + assert($fieldName !== null); + + $resultAlias = $selectExpression->fieldIdentificationVariable ?? $fieldName; + + $dqlAlias = $expr->identificationVariable; + $qComp = $this->queryComponents[$dqlAlias]; + $class = $qComp['metadata']; + + $typeName = $class->getTypeOfField($fieldName); + + assert(is_string($typeName)); + + $nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->isAggregated; + + $type = $this->resolveDoctrineType($typeName, $nullable); + + $this->typeBuilder->addScalar($resultAlias, $type); + + return ''; + } + + if ($expr instanceof AST\NewObjectExpression) { + $resultAlias = $selectExpression->fieldIdentificationVariable ?? $this->newObjectCounter++; + + $type = $this->unmarshalType($this->walkNewObject($expr)); + $this->typeBuilder->addNewObject($resultAlias, $type); + + return ''; + } + + if ($expr instanceof AST\Node) { + $resultAlias = $selectExpression->fieldIdentificationVariable ?? $this->scalarResultCounter++; + $type = $this->unmarshalType($expr->dispatch($this)); + + if (class_exists(TypedExpression::class) && $expr instanceof TypedExpression) { + $enforcedType = $this->resolveDoctrineType($expr->getReturnType()->getName()); + $type = TypeTraverser::map($type, 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; + }); + } 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, 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); + } + if ($type instanceof BooleanType) { + return TypeCombinator::union($type->toInteger()->toString(), $type); + } + return $traverse($type); + }); + } + + $this->typeBuilder->addScalar($resultAlias, $type); + + return ''; + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function walkQuantifiedExpression($qExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkSubselect($subselect) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkSubselectFromClause($subselectFromClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkSimpleSelectClause($simpleSelectClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkParenthesisExpression(AST\ParenthesisExpression $parenthesisExpression) + { + return $parenthesisExpression->expression->dispatch($this); + } + + /** + * {@inheritdoc} + */ + public function walkNewObject($newObjectExpression, $newObjectResultAlias = null) + { + foreach ($newObjectExpression->args as $e) { + $this->scalarResultCounter++; + } + + $type = new ObjectType($newObjectExpression->className); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkSimpleSelectExpression($simpleSelectExpression) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkAggregateExpression($aggExpression) + { + switch ($aggExpression->functionName) { + case 'MAX': + case 'MIN': + case 'AVG': + case 'SUM': + $type = $this->unmarshalType( + $aggExpression->pathExpression->dispatch($this) + ); + + return $this->marshalType(TypeCombinator::addNull($type)); + + case 'COUNT': + return $this->marshalType(IntegerRangeType::fromInterval(0, null)); + + default: + return $this->marshalType(new MixedType()); + } + } + + /** + * {@inheritdoc} + */ + public function walkGroupByClause($groupByClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkGroupByItem($groupByItem) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkDeleteClause(AST\DeleteClause $deleteClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkUpdateClause($updateClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkUpdateItem($updateItem) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkWhereClause($whereClause) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkConditionalExpression($condExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkConditionalTerm($condTerm) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkConditionalFactor($factor) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkConditionalPrimary($primary) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkExistsExpression($existsExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkCollectionMemberExpression($collMemberExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkEmptyCollectionComparisonExpression($emptyCollCompExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkNullComparisonExpression($nullCompExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkInExpression($inExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkInstanceOfExpression($instanceOfExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkInParameter($inParam) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkLiteral($literal) + { + if (!$literal instanceof AST\Literal) { + return $this->marshalType(new MixedType()); + } + + switch ($literal->type) { + case AST\Literal::STRING: + $value = $literal->value; + assert(is_string($value)); + $type = new ConstantStringType($value); + break; + + case AST\Literal::BOOLEAN: + $value = $literal->value === 'true' ? 1 : 0; + $type = new ConstantIntegerType($value); + break; + + case AST\Literal::NUMERIC: + $value = $literal->value; + assert(is_numeric($value)); + + if (floatval(intval($value)) === floatval($value)) { + $type = new ConstantIntegerType((int) $value); + } else { + $type = new ConstantFloatType((float) $value); + } + + break; + + default: + $type = new MixedType(); + break; + } + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkBetweenExpression($betweenExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkLikeExpression($likeExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkStateFieldPathExpression($stateFieldPathExpression) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkComparisonExpression($compExpr) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkInputParameter($inputParam) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkArithmeticExpression($arithmeticExpr) + { + if ($arithmeticExpr->simpleArithmeticExpression !== null) { + return $arithmeticExpr->simpleArithmeticExpression->dispatch($this); + } + + if ($arithmeticExpr->subselect !== null) { + return $arithmeticExpr->subselect->dispatch($this); + } + + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkSimpleArithmeticExpression($simpleArithmeticExpr) + { + $types = []; + + foreach ($simpleArithmeticExpr->arithmeticTerms as $term) { + if (!$term instanceof AST\Node) { + // Skip '+' or '-' + continue; + } + $type = $this->unmarshalType($this->walkArithmeticPrimary($term)); + $types[] = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + } + + $type = TypeCombinator::union(...$types); + $type = $this->toNumericOrNull($type); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkArithmeticTerm($term) + { + if (!$term instanceof AST\ArithmeticTerm) { + return $this->marshalType(new MixedType()); + } + + $types = []; + + foreach ($term->arithmeticFactors as $factor) { + if (!$factor instanceof AST\Node) { + // Skip '*' or '/' + continue; + } + $type = $this->unmarshalType($this->walkArithmeticPrimary($factor)); + $types[] = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + } + + $type = TypeCombinator::union(...$types); + $type = $this->toNumericOrNull($type); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkArithmeticFactor($factor) + { + if (!$factor instanceof AST\ArithmeticFactor) { + return $this->marshalType(new MixedType()); + } + + $primary = $factor->arithmeticPrimary; + + $type = $this->unmarshalType($this->walkArithmeticPrimary($primary)); + $type = TypeUtils::generalizeType($type, GeneralizePrecision::lessSpecific()); + + return $this->marshalType($type); + } + + /** + * {@inheritdoc} + */ + public function walkArithmeticPrimary($primary) + { + // ResultVariable (TODO) + if (is_string($primary)) { + return $this->marshalType(new MixedType()); + } + + if ($primary instanceof AST\Node) { + return $primary->dispatch($this); + } + + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkStringPrimary($stringPrimary) + { + return $this->marshalType(new MixedType()); + } + + /** + * {@inheritdoc} + */ + public function walkResultVariable($resultVariable) + { + return $this->marshalType(new MixedType()); + } + + private function unmarshalType(string $marshalledType): Type + { + $type = unserialize($marshalledType); + + assert($type instanceof Type); + + return $type; + } + + private function marshalType(Type $type): string + { + // TreeWalker methods are supposed to return string, so we need to + // marshal the types in strings + return serialize($type); + } + + private function isQueryComponentNullable(string $dqlAlias): bool + { + return $this->nullableQueryComponents[$dqlAlias] ?? false; + } + + private function resolveDoctrineType(string $typeName, bool $nullable = false): Type + { + try { + $type = $this->descriptorRegistry + ->get($typeName) + ->getWritableToPropertyType(); + } catch (DescriptorNotRegisteredException $e) { + $type = new MixedType(); + } + + if ($nullable) { + $type = TypeCombinator::addNull($type); + } + + return $type; + } + + private function resolveDatabaseInternalType(string $typeName, bool $nullable = false): Type + { + try { + $type = $this->descriptorRegistry + ->get($typeName) + ->getDatabaseInternalType(); + } catch (DescriptorNotRegisteredException $e) { + $type = new MixedType(); + } + + if ($nullable) { + $type = TypeCombinator::addNull($type); + } + + return $type; + } + + private function toNumericOrNull(Type $type): Type + { + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + if ($type instanceof NullType || $type instanceof IntegerType) { + return $type; + } + if ($type instanceof BooleanType) { + return $type->toInteger(); + } + return TypeCombinator::union( + $type->toFloat(), + $type->toInteger() + ); + }); + } + + private function isAggregated(AST\SelectStatement $AST): bool + { + if ($AST->groupByClause !== null) { + return true; + } + + foreach ($AST->selectClause->selectExpressions as $selectExpression) { + if (!$selectExpression instanceof AST\SelectExpression) { + continue; + } + + $expression = $selectExpression->expression; + + switch (true) { + case ($expression instanceof AST\Functions\AvgFunction): + case ($expression instanceof AST\Functions\CountFunction): + case ($expression instanceof AST\Functions\MaxFunction): + case ($expression instanceof AST\Functions\MinFunction): + case ($expression instanceof AST\Functions\SumFunction): + case ($expression instanceof AST\AggregateExpression): + return true; + default: + break; + } + } + + return false; + } + +} diff --git a/src/Type/Doctrine/Query/QueryType.php b/src/Type/Doctrine/Query/QueryType.php index 6cea7504..63766acc 100644 --- a/src/Type/Doctrine/Query/QueryType.php +++ b/src/Type/Doctrine/Query/QueryType.php @@ -3,19 +3,21 @@ namespace PHPStan\Type\Doctrine\Query; use PHPStan\TrinaryLogic; -use PHPStan\Type\ObjectType; +use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; /** @api */ -class QueryType extends ObjectType +class QueryType extends GenericObjectType { /** @var string */ private $dql; - public function __construct(string $dql) + public function __construct(string $dql, ?Type $resultType = null) { - parent::__construct('Doctrine\ORM\Query'); + $resultType = $resultType ?? new MixedType(); + parent::__construct('Doctrine\ORM\Query', [$resultType]); $this->dql = $dql; } diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php index 062db476..9cfc035f 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php @@ -2,6 +2,10 @@ namespace PHPStan\Type\Doctrine\QueryBuilder; +use Doctrine\Common\CommonException; +use Doctrine\DBAL\DBALException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\ORMException; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; @@ -9,8 +13,11 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\Doctrine\ORM\DynamicQueryBuilderArgumentException; use PHPStan\Type\Doctrine\ArgumentsProcessor; +use PHPStan\Type\Doctrine\DescriptorRegistry; use PHPStan\Type\Doctrine\DoctrineTypeUtils; use PHPStan\Type\Doctrine\ObjectMetadataResolver; +use PHPStan\Type\Doctrine\Query\QueryResultTypeBuilder; +use PHPStan\Type\Doctrine\Query\QueryResultTypeWalker; use PHPStan\Type\Doctrine\Query\QueryType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -30,15 +37,20 @@ class QueryBuilderGetQueryDynamicReturnTypeExtension implements \PHPStan\Type\Dy /** @var string|null */ private $queryBuilderClass; + /** @var DescriptorRegistry */ + private $descriptorRegistry; + public function __construct( ObjectMetadataResolver $objectMetadataResolver, ArgumentsProcessor $argumentsProcessor, - ?string $queryBuilderClass + ?string $queryBuilderClass, + DescriptorRegistry $descriptorRegistry ) { $this->objectMetadataResolver = $objectMetadataResolver; $this->argumentsProcessor = $argumentsProcessor; $this->queryBuilderClass = $queryBuilderClass; + $this->descriptorRegistry = $descriptorRegistry; } public function getClass(): string @@ -121,10 +133,29 @@ public function getTypeFromMethodCall( $queryBuilder->{$methodName}(...$args); } - $resultTypes[] = new QueryType($queryBuilder->getDQL()); + $resultTypes[] = $this->getQueryType($queryBuilder->getDQL()); } return TypeCombinator::union(...$resultTypes); } + private function getQueryType(string $dql): Type + { + $em = $this->objectMetadataResolver->getObjectManager(); + if (!$em instanceof EntityManagerInterface) { + return new QueryType($dql, null); + } + + $typeBuilder = new QueryResultTypeBuilder(); + + try { + $query = $em->createQuery($dql); + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + } catch (ORMException | DBALException | CommonException $e) { + return new QueryType($dql, null); + } + + return new QueryType($dql, $typeBuilder->getResultType()); + } + } diff --git a/stubs/ORM/AbstractQuery.stub b/stubs/ORM/AbstractQuery.stub index 0556ca5c..d9171fe4 100644 --- a/stubs/ORM/AbstractQuery.stub +++ b/stubs/ORM/AbstractQuery.stub @@ -4,6 +4,9 @@ namespace Doctrine\ORM; use Doctrine\Common\Collections\ArrayCollection; +/** + * @template TResult The type of results returned by this query in HYDRATE_OBJECT mode + */ abstract class AbstractQuery { diff --git a/stubs/ORM/Query.stub b/stubs/ORM/Query.stub new file mode 100644 index 00000000..d3610bb7 --- /dev/null +++ b/stubs/ORM/Query.stub @@ -0,0 +1,12 @@ + + */ +final class Query extends AbstractQuery +{ +} diff --git a/stubs/ORM/QueryBuilder.stub b/stubs/ORM/QueryBuilder.stub index d6f98b0d..6e4d93f6 100644 --- a/stubs/ORM/QueryBuilder.stub +++ b/stubs/ORM/QueryBuilder.stub @@ -14,4 +14,10 @@ class QueryBuilder } + /** + * @return Query + */ + public function getQuery() + { + } } diff --git a/tests/Type/Doctrine/CreateQueryDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/CreateQueryDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..7edc9039 --- /dev/null +++ b/tests/Type/Doctrine/CreateQueryDynamicReturnTypeExtensionTest.php @@ -0,0 +1,37 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/QueryResult/createQuery.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param string $assertType + * @param string $file + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/QueryResult/config.neon']; + } + +} diff --git a/tests/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..b98d71ca --- /dev/null +++ b/tests/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtensionTest.php @@ -0,0 +1,37 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/../data/QueryResult/queryResult.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param string $assertType + * @param string $file + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../data/QueryResult/config.neon']; + } + +} diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php new file mode 100644 index 00000000..bc59f0d9 --- /dev/null +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php @@ -0,0 +1,1332 @@ +getMetadataFactory()->getAllMetadata(); + $schemaTool->createSchema($classes); + + $dataOne = [ + 'intColumn' => [1, 2], + 'stringColumn' => ['A', 'B'], + 'stringNullColumn' => ['A', null], + ]; + + $dataMany = [ + 'intColumn' => [1, 2], + 'stringColumn' => ['A', 'B'], + 'stringNullColumn' => ['A', null], + ]; + + $dataJoinedInheritance = [ + 'parentColumn' => [1, 2], + 'parentNullColumn' => [1, null], + 'childColumn' => [1, 2], + 'childNullColumn' => [1, null], + ]; + + $dataSingleTableInheritance = [ + 'parentColumn' => [1, 2], + 'parentNullColumn' => [1, null], + 'childNullColumn' => [1, null], + ]; + + $id = 1; + + foreach (self::combinations($dataOne) as $combination) { + [$intColumn, $stringColumn, $stringNullColumn] = $combination; + $one = new One(); + $one->id = (string) $id++; + $one->intColumn = $intColumn; + $one->stringColumn = $stringColumn; + $one->stringNullColumn = $stringNullColumn; + $embedded = new Embedded(); + $embedded->intColumn = $intColumn; + $embedded->stringColumn = $stringColumn; + $embedded->stringNullColumn = $stringNullColumn; + $nestedEmbedded = new NestedEmbedded(); + $nestedEmbedded->intColumn = $intColumn; + $nestedEmbedded->stringColumn = $stringColumn; + $nestedEmbedded->stringNullColumn = $stringNullColumn; + $embedded->nestedEmbedded = $nestedEmbedded; + $one->embedded = $embedded; + $one->manies = new ArrayCollection(); + + foreach (self::combinations($dataMany) as $combinationMany) { + [$intColumnMany, $stringColumnMany, $stringNullColumnMany] = $combination; + $many = new Many(); + $many->id = (string) $id++; + $many->intColumn = $intColumnMany; + $many->stringColumn = $stringColumnMany; + $many->stringNullColumn = $stringNullColumnMany; + $many->datetimeColumn = new \DateTime('2001-01-01 00:00:00'); + $many->datetimeImmutableColumn = new \DateTimeImmutable('2001-01-01 00:00:00'); + $many->one = $one; + $one->manies->add($many); + $em->persist($many); + } + + $em->persist($one); + } + + foreach (self::combinations($dataJoinedInheritance) as $combination) { + [$parentColumn, $parentNullColumn, $childColumn, $childNullColumn] = $combination; + $child = new JoinedChild(); + $child->id = (string) $id++; + $child->parentColumn = $parentColumn; + $child->parentNullColumn = $parentNullColumn; + $child->childColumn = $childColumn; + $child->childNullColumn = $childNullColumn; + $em->persist($child); + } + + foreach (self::combinations($dataSingleTableInheritance) as $combination) { + [$parentColumn, $parentNullColumn, $childNullColumn] = $combination; + $child = new SingleTableChild(); + $child->id = (string) $id++; + $child->parentColumn = $parentColumn; + $child->parentNullColumn = $parentNullColumn; + $child->childNullColumn = $childNullColumn; + $em->persist($child); + } + + $em->flush(); + } + + public static function tearDownAfterClass(): void + { + self::$em->clear(); + } + + public function setUp(): void + { + $this->descriptorRegistry = self::getContainer()->getByType(DescriptorRegistry::class); + } + + /** @dataProvider getTestData */ + public function test(Type $expectedType, string $dql, ?string $expectedExceptionMessage = null): void + { + $em = self::$em; + + $query = $em->createQuery($dql); + + $typeBuilder = new QueryResultTypeBuilder(); + + QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); + + $type = $typeBuilder->getResultType(); + + self::assertSame( + $expectedType->describe(VerbosityLevel::precise()), + $type->describe(VerbosityLevel::precise()) + ); + + // Double-check our expectations + + $query = $em->createQuery($dql); + + if ($expectedExceptionMessage !== null) { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage($expectedExceptionMessage); + } + + $result = $query->getResult(); + self::assertGreaterThan(0, count($result)); + + foreach ($result as $row) { + $rowType = ConstantTypeHelper::getTypeFromValue($row); + self::assertTrue( + $type->accepts($rowType, true)->yes(), + sprintf( + "%s\nshould accept\n%s", + $type->describe(VerbosityLevel::precise()), + $rowType->describe(VerbosityLevel::precise()) + ) + ); + } + } + + /** + * @return array + */ + public function getTestData(): array + { + return [ + 'just root entity' => [ + new ObjectType(One::class), + ' + SELECT o + FROM QueryResult\Entities\One o + ', + ], + 'just root entity as alias' => [ + $this->constantArray([ + [new ConstantStringType('one'), new ObjectType(One::class)], + ]), + ' + SELECT o AS one + FROM QueryResult\Entities\One o + ', + ], + 'arbitrary left join, not selected' => [ + new ObjectType(Many::class), + ' + SELECT m + FROM QueryResult\Entities\Many m + LEFT JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + // The result of arbitrary joins with multiple selected entities + // is an alternation of all selected entities + 'arbitrary left join, selected' => [ + TypeCombinator::union( + new ObjectType(Many::class), + TypeCombinator::addNull(new ObjectType(One::class)) + ), + ' + SELECT m, o + FROM QueryResult\Entities\Many m + LEFT JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'arbitrary inner join, selected' => [ + TypeCombinator::union( + new ObjectType(Many::class), + new ObjectType(One::class) + ), + ' + SELECT m, o + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'arbitrary left join, selected, some as alias' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantStringType('many'), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantIntegerType(0), TypeCombinator::addNull(new ObjectType(One::class))], + ]) + ), + ' + SELECT m AS many, o + FROM QueryResult\Entities\Many m + LEFT JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'arbitrary left join, selected as alias' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantStringType('many'), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantStringType('one'), TypeCombinator::addNull(new ObjectType(One::class))], + ]) + ), + ' + SELECT m AS many, o AS one + FROM QueryResult\Entities\Many m + LEFT JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'arbitrary inner join, selected as alias' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantStringType('many'), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantStringType('one'), new ObjectType(One::class)], + ]) + ), + ' + SELECT m AS many, o AS one + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + // In arbitrary joins all non-entity results are returned only with + // the last declared entity (in FROM/JOIN order) + 'arbitrary inner join selected, and scalars' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(One::class)], + [new ConstantStringType('id'), new StringType()], + [new ConstantStringType('intColumn'), new IntegerType()], + ]) + ), + ' + SELECT m, o, m.id, o.intColumn + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'arbitrary inner join selected, and scalars (selection order variation)' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(One::class)], + [new ConstantStringType('id'), new StringType()], + [new ConstantStringType('intColumn'), new IntegerType()], + ]) + ), + ' + SELECT o, m2, m, m.id, o.intColumn + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + JOIN QueryResult\Entities\Many m2 + WITH IDENTITY(m2.one) = o.id + ', + ], + 'arbitrary inner join selected as alias, and scalars' => [ + TypeCombinator::union( + $this->constantArray([ + [new ConstantStringType('many'), new ObjectType(Many::class)], + ]), + $this->constantArray([ + [new ConstantStringType('one'), new ObjectType(One::class)], + [new ConstantStringType('id'), new StringType()], + [new ConstantStringType('intColumn'), new IntegerType()], + ]) + ), + ' + SELECT m AS many, o AS one, m.id, o.intColumn + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'join' => [ + new ObjectType(Many::class), + ' + SELECT m + FROM QueryResult\Entities\Many m + JOIN m.one o + ', + ], + 'fetch-join' => [ + new ObjectType(Many::class), + ' + SELECT m, o + FROM QueryResult\Entities\Many m + JOIN m.one o + ', + ], + 'scalar' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), new IntegerType()], + [new ConstantStringType('stringColumn'), new StringType()], + [new ConstantStringType('stringNullColumn'), TypeCombinator::addNull(new StringType())], + [new ConstantStringType('datetimeColumn'), new ObjectType(\DateTime::class)], + [new ConstantStringType('datetimeImmutableColumn'), new ObjectType(\DateTimeImmutable::class)], + ]), + ' + SELECT m.intColumn, m.stringColumn, m.stringNullColumn, + m.datetimeColumn, m.datetimeImmutableColumn + FROM QueryResult\Entities\Many m + ', + ], + 'scalar with alias' => [ + $this->constantArray([ + [new ConstantStringType('i'), new IntegerType()], + [new ConstantStringType('s'), new StringType()], + [new ConstantStringType('sn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT m.intColumn AS i, m.stringColumn AS s, m.stringNullColumn AS sn + FROM QueryResult\Entities\Many m + ', + ], + 'scalar from join' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), new IntegerType()], + [new ConstantStringType('stringNullColumn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT o.intColumn, o.stringNullColumn + FROM QueryResult\Entities\Many m + JOIN m.one o + ', + ], + 'scalar from left join' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), TypeCombinator::addNull(new IntegerType())], + [new ConstantStringType('stringNullColumn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT o.intColumn, o.stringNullColumn + FROM QueryResult\Entities\Many m + LEFT JOIN m.one o + ', + ], + 'scalar from arbitrary join' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), new IntegerType()], + [new ConstantStringType('stringNullColumn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT o.intColumn, o.stringNullColumn + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'scalar from arbitrary left join' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), TypeCombinator::addNull(new IntegerType())], + [new ConstantStringType('stringNullColumn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT o.intColumn, o.stringNullColumn + FROM QueryResult\Entities\Many m + LEFT JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'just root entity and scalars' => [ + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(One::class)], + [new ConstantStringType('id'), new StringType()], + ]), + ' + SELECT o, o.id + FROM QueryResult\Entities\One o + ', + ], + 'hidden' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), new IntegerType()], + ]), + ' + SELECT m.intColumn, m.stringColumn AS HIDDEN sc + FROM QueryResult\Entities\Many m + ', + ], + 'sub query are not support yet' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new MixedType()], + ]), + ' + SELECT (SELECT m.intColumn FROM QueryResult\Entities\Many m) + FROM QueryResult\Entities\Many m2 + ', + ], + 'aggregate' => [ + $this->constantArray([ + [ + new ConstantStringType('many'), + TypeCombinator::addNull(new ObjectType(Many::class)), + ], + [ + new ConstantIntegerType(1), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantIntegerType(2), + TypeCombinator::addNull(new StringType()), + ], + [ + new ConstantIntegerType(3), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + [ + new ConstantIntegerType(4), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantIntegerType(5), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + [ + new ConstantIntegerType(6), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantIntegerType(7), + $this->intStringified(), + ], + ]), + ' + SELECT m AS many, + MAX(m.intColumn), MAX(m.stringNullColumn), COUNT(m.stringNullColumn), + MAX(o.intColumn), COUNT(o.stringNullColumn), + MAX(m.intColumn+1), + COALESCE(MAX(m.intColumn), 0) + FROM QueryResult\Entities\Many m + LEFT JOIN m.one o + ', + ], + 'aggregate with group by' => [ + $this->constantArray([ + [ + new ConstantStringType('intColumn'), + TypeCombinator::addNull(new IntegerType()), + ], + [ + new ConstantStringType('max'), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantStringType('arithmetic'), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantStringType('coalesce'), + TypeCombinator::addNull($this->intStringified()), + ], + [ + new ConstantStringType('count'), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + ]), + ' + SELECT m.intColumn, + MAX(m.intColumn) AS max, + m.intColumn+1 AS arithmetic, + COALESCE(m.intColumn, m.intColumn) AS coalesce, + COUNT(m.intColumn) AS count + FROM QueryResult\Entities\Many m + GROUP BY m.intColumn + ', + ], + 'literal' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + TypeCombinator::union( + new ConstantStringType('1'), + new ConstantIntegerType(1) + ), + ], + [new ConstantIntegerType(2), new ConstantStringType('hello')], + ]), + ' + SELECT 1, \'hello\' + FROM QueryResult\Entities\Many m + ', + ], + 'nullif' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + TypeCombinator::union( + new ConstantIntegerType(1), + new ConstantStringType('1'), + new NullType() + ), + ], + ]), + ' + SELECT NULLIF(true, m.id) + FROM QueryResult\Entities\Many m + ', + ], + 'coalesce' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + TypeCombinator::union( + new StringType(), + new IntegerType() + ), + ], + [ + new ConstantIntegerType(2), + TypeCombinator::union( + new StringType(), + new NullType() + ), + ], + [ + new ConstantIntegerType(3), + $this->intStringified(), + ], + ]), + ' + SELECT COALESCE(m.stringNullColumn, m.intColumn, false), + COALESCE(m.stringNullColumn, m.stringNullColumn), + COALESCE(NULLIF(m.intColumn, 1), 0) + FROM QueryResult\Entities\Many m + ', + ], + 'general case' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + TypeCombinator::union( + new StringType(), + new ConstantIntegerType(0) + ), + ], + ]), + ' + SELECT CASE + WHEN m.intColumn < 10 THEN m.stringColumn + WHEN m.intColumn < 20 THEN \'b\' + ELSE false + END + FROM QueryResult\Entities\Many m + ', + ], + 'simple case' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + TypeCombinator::union( + new StringType(), + new ConstantIntegerType(0) + ), + ], + ]), + ' + SELECT CASE m.intColumn + WHEN 10 THEN m.stringColumn + WHEN 20 THEN \'b\' + ELSE false + END + FROM QueryResult\Entities\Many m + ', + ], + 'new' => [ + new ObjectType(ManyId::class), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) + FROM QueryResult\Entities\Many m + ', + ], + 'news' => [ + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(ManyId::class)], + [new ConstantIntegerType(1), new ObjectType(OneId::class)], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id), + NEW QueryResult\Entities\OneId(m.id) + FROM QueryResult\Entities\Many m + ', + ], + // Alias on NEW is ignored when there is only no scalars and a + // single NEW + 'new as alias' => [ + new ObjectType(ManyId::class), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id + FROM QueryResult\Entities\Many m + ', + ], + 'news as alias' => [ + $this->constantArray([ + [new ConstantStringType('id'), new ObjectType(ManyId::class)], + [new ConstantStringType('id2'), new ObjectType(OneId::class)], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + NEW QueryResult\Entities\OneId(m.id) as id2 + FROM QueryResult\Entities\Many m + ', + ], + 'new and scalars' => [ + $this->constantArray([ + [ + new ConstantStringType('intColumn'), + new IntegerType(), + ], + [ + new ConstantStringType('id'), + new ObjectType(ManyId::class), + ], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + m.intColumn + FROM QueryResult\Entities\Many m + ', + ], + 'new and entity' => [ + new ObjectType(ManyId::class), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + m + FROM QueryResult\Entities\Many m + ', + ], + 'news and entity' => [ + $this->constantArray([ + [new ConstantIntegerType(0), new ObjectType(Many::class)], + [new ConstantStringType('id'), new ObjectType(ManyId::class)], + [new ConstantStringType('id2'), new ObjectType(OneId::class)], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + NEW QueryResult\Entities\OneId(m.id) as id2, + m + FROM QueryResult\Entities\Many m + ', + ], + 'new, scalars, and entity' => [ + $this->constantArray([ + [ + new ConstantIntegerType(0), + new ObjectType(ManyId::class), + ], + [ + new ConstantStringType('intColumn'), + new IntegerType(), + ], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id), + m.intColumn, + m + FROM QueryResult\Entities\Many m + ', + ], + 'new as alias, scalars, and entity' => [ + $this->constantArray([ + [ + new ConstantIntegerType(0), + new ObjectType(Many::class), + ], + [ + new ConstantStringType('intColumn'), + new IntegerType(), + ], + [ + new ConstantStringType('id'), + new ObjectType(ManyId::class), + ], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + m.intColumn, + m + FROM QueryResult\Entities\Many m + ', + ], + 'new as alias, scalars, and entity as alias' => [ + $this->constantArray([ + [ + new ConstantStringType('many'), + new ObjectType(Many::class), + ], + [ + new ConstantStringType('intColumn'), + new IntegerType(), + ], + [ + new ConstantStringType('id'), + new ObjectType(ManyId::class), + ], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id) AS id, + m.intColumn, + m AS many + FROM QueryResult\Entities\Many m + ', + ], + 'news, scalars, and entities as alias' => [ + TypeCombinator::union( + $this->constantArray([ + [ + new ConstantStringType('many'), + new ObjectType(Many::class), + ], + ]), + $this->constantArray([ + [ + new ConstantStringType('one'), + new ObjectType(One::class), + ], + [ + new ConstantIntegerType(2), + TypeCombinator::union( + new ConstantIntegerType(1), + new ConstantStringType('1') + ), + ], + [ + new ConstantStringType('intColumn'), + new IntegerType(), + ], + [ + new ConstantIntegerType(0), + new ObjectType(ManyId::class), + ], + [ + new ConstantIntegerType(1), + new ObjectType(OneId::class), + ], + ]) + ), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id), + COALESCE(1,1), + NEW QueryResult\Entities\OneId(m.id), + m.intColumn, + m AS many, + o AS one + FROM QueryResult\Entities\Many m + JOIN QueryResult\Entities\One o + WITH o.id = IDENTITY(m.one) + ', + ], + 'news shadown scalars' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new ObjectType(OneId::class)], + [new ConstantIntegerType(0), new ObjectType(ManyId::class)], + ]), + ' + SELECT NULLIF(m.intColumn, 1), + NEW QueryResult\Entities\ManyId(m.id), + NEW QueryResult\Entities\OneId(m.id) + FROM QueryResult\Entities\Many m + ', + ], + 'new arguments affect scalar counter' => [ + $this->constantArray([ + [new ConstantIntegerType(5), TypeCombinator::addNull($this->intStringified())], + [new ConstantIntegerType(0), new ObjectType(ManyId::class)], + [new ConstantIntegerType(1), new ObjectType(OneId::class)], + ]), + ' + SELECT NEW QueryResult\Entities\ManyId(m.id), + NEW QueryResult\Entities\OneId(m.id, m.id, m.id), + NULLIF(m.intColumn, 1) + FROM QueryResult\Entities\Many m + ', + ], + 'arithmetic' => [ + $this->constantArray([ + [new ConstantStringType('intColumn'), new IntegerType()], + [new ConstantIntegerType(1), $this->intStringified()], + [new ConstantIntegerType(2), $this->intStringified()], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->intStringified())], + [new ConstantIntegerType(4), $this->intStringified()], + [new ConstantIntegerType(5), $this->intStringified()], + [new ConstantIntegerType(6), $this->numericStringified()], + [new ConstantIntegerType(7), $this->numericStringified()], + ]), + ' + SELECT m.intColumn, + +1, + 1+1, + 1+nullif(1,1), + m.intColumn*2+m.intColumn+3, + (1+1), + \'foo\' + \'bar\', + \'foo\' * \'bar\' + FROM QueryResult\Entities\Many m + ', + ], + 'abs function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->unumericStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->unumericStringified())], + [new ConstantIntegerType(3), $this->unumericStringified()], + [new ConstantIntegerType(4), TypeCombinator::union($this->unumericStringified())], + ]), + ' + SELECT ABS(m.intColumn), + ABS(NULLIF(m.intColumn, 1)), + ABS(1), + ABS(\'foo\') + FROM QueryResult\Entities\Many m + ', + ], + 'bit_and function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->uintStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(3), $this->uintStringified()], + ]), + ' + SELECT BIT_AND(m.intColumn, 1), + BIT_AND(m.intColumn, NULLIF(1,1)), + BIT_AND(1, 2) + FROM QueryResult\Entities\Many m + ', + ], + 'bit_or function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->uintStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(3), $this->uintStringified()], + ]), + ' + SELECT BIT_OR(m.intColumn, 1), + BIT_OR(m.intColumn, NULLIF(1,1)), + BIT_OR(1, 2) + FROM QueryResult\Entities\Many m + ', + ], + 'concat function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), new StringType()], + ]), + ' + SELECT CONCAT(m.stringColumn, m.stringColumn), + CONCAT(m.stringColumn, m.stringNullColumn), + CONCAT(m.stringColumn, m.stringColumn, m.stringNullColumn), + CONCAT(\'foo\', \'bar\') + FROM QueryResult\Entities\Many m + ', + ], + 'current_ functions' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), new StringType()], + [new ConstantIntegerType(3), new StringType()], + ]), + ' + SELECT CURRENT_DATE(), + CURRENT_TIME(), + CURRENT_TIMESTAMP() + FROM QueryResult\Entities\Many m + ', + ], + 'date_add function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), new StringType()], + ]), + ' + SELECT DATE_ADD(m.datetimeColumn, m.intColumn, \'day\'), + DATE_ADD(m.stringNullColumn, m.intColumn, \'day\'), + DATE_ADD(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), + DATE_ADD(\'2020-01-01\', 7, \'day\') + FROM QueryResult\Entities\Many m + ', + ], + 'date_sub function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), new StringType()], + ]), + ' + SELECT DATE_SUB(m.datetimeColumn, m.intColumn, \'day\'), + DATE_SUB(m.stringNullColumn, m.intColumn, \'day\'), + DATE_SUB(m.datetimeColumn, NULLIF(m.intColumn, 1), \'day\'), + DATE_SUB(\'2020-01-01\', 7, \'day\') + FROM QueryResult\Entities\Many m + ', + ], + 'date_diff function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->numericStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->numericStringified())], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->numericStringified())], + [new ConstantIntegerType(4), $this->numericStringified()], + ]), + ' + SELECT DATE_DIFF(m.datetimeColumn, m.datetimeColumn), + DATE_DIFF(m.stringNullColumn, m.datetimeColumn), + DATE_DIFF(m.datetimeColumn, m.stringNullColumn), + DATE_DIFF(\'2020-01-01\', \'2019-01-01\') + FROM QueryResult\Entities\Many m + ', + ], + 'sqrt function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->floatStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->floatStringified())], + [new ConstantIntegerType(3), $this->floatStringified()], + ]), + ' + SELECT SQRT(m.intColumn), + SQRT(NULLIF(m.intColumn, 1)), + SQRT(1) + FROM QueryResult\Entities\Many m + ', + ], + 'length function' => [ + $this->constantArray([ + [ + new ConstantIntegerType(1), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + [ + new ConstantIntegerType(2), + TypeCombinator::addNull( + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified() + ), + ], + [ + new ConstantIntegerType(3), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + ]), + ' + SELECT LENGTH(m.stringColumn), + LENGTH(m.stringNullColumn), + LENGTH(\'foo\') + FROM QueryResult\Entities\Many m + ', + ], + 'locate function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->uintStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(4), $this->uintStringified()], + ]), + ' + SELECT LOCATE(m.stringColumn, m.stringColumn, 0), + LOCATE(m.stringNullColumn, m.stringColumn, 0), + LOCATE(m.stringColumn, m.stringNullColumn, 0), + LOCATE(\'f\', \'foo\', 0) + FROM QueryResult\Entities\Many m + ', + ], + 'lower function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), new StringType()], + ]), + ' + SELECT LOWER(m.stringColumn), + LOWER(m.stringNullColumn), + LOWER(\'foo\') + FROM QueryResult\Entities\Many m + ', + ], + 'mod function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->uintStringified()], + [new ConstantIntegerType(2), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(3), TypeCombinator::addNull($this->uintStringified())], + [new ConstantIntegerType(4), $this->uintStringified()], + ]), + ' + SELECT MOD(m.intColumn, 1), + MOD(10, m.intColumn), + MOD(NULLIF(m.intColumn, 10), 2), + MOD(10, 4) + FROM QueryResult\Entities\Many m + ', + ], + 'mod function error' => [ + $this->constantArray([ + [new ConstantIntegerType(1), TypeCombinator::addNull($this->uintStringified())], + ]), + ' + SELECT MOD(10, NULLIF(m.intColumn, m.intColumn)) + FROM QueryResult\Entities\Many m + ', + 'Modulo by zero', + ], + 'substring function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(4), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(5), new StringType()], + ]), + ' + SELECT SUBSTRING(m.stringColumn, m.intColumn, m.intColumn), + SUBSTRING(m.stringNullColumn, m.intColumn, m.intColumn), + SUBSTRING(m.stringColumn, NULLIF(m.intColumn, 1), m.intColumn), + SUBSTRING(m.stringColumn, m.intColumn, NULLIF(m.intColumn, 1)), + SUBSTRING(\'foo\', 1, 2) + FROM QueryResult\Entities\Many m + ', + ], + 'trim function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), new StringType()], + ]), + ' + SELECT TRIM(LEADING \' \' FROM m.stringColumn), + TRIM(LEADING \' \' FROM m.stringNullColumn), + TRIM(LEADING \' \' FROM \'foo\') + FROM QueryResult\Entities\Many m + ', + ], + 'upper function' => [ + $this->constantArray([ + [new ConstantIntegerType(1), new StringType()], + [new ConstantIntegerType(2), TypeCombinator::addNull(new StringType())], + [new ConstantIntegerType(3), new StringType()], + ]), + ' + SELECT UPPER(m.stringColumn), + UPPER(m.stringNullColumn), + UPPER(\'foo\') + FROM QueryResult\Entities\Many m + ', + ], + 'select nullable association' => [ + $this->constantArray([ + [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + ]), + ' + SELECT DISTINCT(m.oneNull) + FROM QueryResult\Entities\Many m + ', + ], + 'select non null association' => [ + $this->constantArray([ + [new ConstantIntegerType(1), $this->numericStringOrInt()], + ]), + ' + SELECT DISTINCT(m.one) + FROM QueryResult\Entities\Many m + ', + ], + 'select default nullability association' => [ + $this->constantArray([ + [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + ]), + ' + SELECT DISTINCT(m.oneDefaultNullability) + FROM QueryResult\Entities\Many m + ', + ], + 'select non null association in aggregated query' => [ + $this->constantArray([ + [new ConstantIntegerType(1), TypeCombinator::addNull($this->numericStringOrInt())], + [ + new ConstantIntegerType(2), + $this->hasTypedExpressions() + ? $this->uint() + : $this->uintStringified(), + ], + ]), + ' + SELECT DISTINCT(m.one), COUNT(m.one) + FROM QueryResult\Entities\Many m + ', + ], + 'joined inheritance' => [ + $this->constantArray([ + [new ConstantStringType('parentColumn'), new IntegerType()], + [new ConstantStringType('childColumn'), new IntegerType()], + ]), + ' + SELECT c.parentColumn, c.childColumn + FROM QueryResult\Entities\JoinedChild c + ', + ], + 'single table inheritance' => [ + $this->constantArray([ + [new ConstantStringType('parentColumn'), new IntegerType()], + [new ConstantStringType('childNullColumn'), TypeCombinator::addNull(new IntegerType())], + ]), + ' + SELECT c.parentColumn, c.childNullColumn + FROM QueryResult\Entities\SingleTableChild c + ', + ], + 'embedded' => [ + $this->constantArray([ + [new ConstantStringType('embedded.intColumn'), new IntegerType()], + [new ConstantStringType('embedded.stringNullColumn'), TypeCombinator::addNull(new StringType())], + [new ConstantStringType('embedded.nestedEmbedded.intColumn'), new IntegerType()], + [new ConstantStringType('embedded.nestedEmbedded.stringNullColumn'), TypeCombinator::addNull(new StringType())], + ]), + ' + SELECT o.embedded.intColumn, + o.embedded.stringNullColumn, + o.embedded.nestedEmbedded.intColumn, + o.embedded.nestedEmbedded.stringNullColumn + FROM QueryResult\Entities\One o + ', + ], + ]; + } + + /** + * @param array $elements + */ + private function constantArray(array $elements): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($elements as [$offsetType, $valueType]) { + $builder->setOffsetValueType($offsetType, $valueType); + } + + return $builder->getArray(); + } + + private function numericStringOrInt(): Type + { + return new UnionType([ + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]), + ]); + } + + private function numericString(): Type + { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + + private function uint(): Type + { + return IntegerRangeType::fromInterval(0, null); + } + + private function intStringified(): Type + { + return TypeCombinator::union( + new IntegerType(), + $this->numericString() + ); + } + private function uintStringified(): Type + { + return TypeCombinator::union( + $this->uint(), + $this->numericString() + ); + } + + private function floatStringified(): Type + { + return TypeCombinator::union( + new FloatType(), + $this->numericString() + ); + } + + private function numericStringified(): Type + { + return TypeCombinator::union( + new FloatType(), + new IntegerType(), + $this->numericString() + ); + } + + private function unumericStringified(): Type + { + return TypeCombinator::union( + new FloatType(), + IntegerRangeType::fromInterval(0, null), + $this->numericString() + ); + } + + private function hasTypedExpressions(): bool + { + return class_exists(TypedExpression::class); + } + + /** + * @param array $arrays + * + * @return iterable + */ + private static function combinations(array $arrays): iterable + { + if ($arrays === []) { + yield []; + return; + } + + $head = array_shift($arrays); + + foreach ($head as $elem) { + foreach (self::combinations($arrays) as $combination) { + yield array_merge([$elem], $combination); + } + } + } + +} diff --git a/tests/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtensionTest.php b/tests/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..05c9d079 --- /dev/null +++ b/tests/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtensionTest.php @@ -0,0 +1,37 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/../data/QueryResult/queryBuilderGetQuery.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param string $assertType + * @param string $file + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../data/QueryResult/config.neon']; + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Embedded.php b/tests/Type/Doctrine/data/QueryResult/Entities/Embedded.php new file mode 100644 index 00000000..4903c6ef --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/Embedded.php @@ -0,0 +1,47 @@ +id = $id; + } +} diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/NestedEmbedded.php b/tests/Type/Doctrine/data/QueryResult/Entities/NestedEmbedded.php new file mode 100644 index 00000000..1202fe00 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/NestedEmbedded.php @@ -0,0 +1,39 @@ + + */ + public $manies; + + /** + * @ORMEmbedded(class="QueryResult\Entities\Embedded") + * + * @var Embedded + */ + public $embedded; +} diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/OneId.php b/tests/Type/Doctrine/data/QueryResult/Entities/OneId.php new file mode 100644 index 00000000..92660f1c --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/OneId.php @@ -0,0 +1,15 @@ +id = $id; + } +} diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/SingleTableChild.php b/tests/Type/Doctrine/data/QueryResult/Entities/SingleTableChild.php new file mode 100644 index 00000000..31db3699 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/SingleTableChild.php @@ -0,0 +1,24 @@ +createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType('Doctrine\ORM\Query', $query); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType('Doctrine\ORM\Query', $query); + } + + public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(EntityManagerInterface $em, string $dql): void + { + $query = $em->createQuery($dql); + + assertType('Doctrine\ORM\Query', $query); + } + + public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em, string $dql): void + { + $query = $em->createQuery('invalid'); + + assertType('Doctrine\ORM\Query', $query); + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager.php b/tests/Type/Doctrine/data/QueryResult/entity-manager.php new file mode 100644 index 00000000..6a6a4025 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/entity-manager.php @@ -0,0 +1,31 @@ +setProxyDir(__DIR__); +$config->setProxyNamespace('PHPstan\Doctrine\OrmProxies'); +$config->setMetadataCacheImpl(new DoctrineProvider(new ArrayAdapter())); + +$metadataDriver = new MappingDriverChain(); +$metadataDriver->addDriver(new AnnotationDriver( + new AnnotationReader(), + [__DIR__ . '/Entities'] +), 'QueryResult\Entities\\'); + +$config->setMetadataDriverImpl($metadataDriver); + +return EntityManager::create( + [ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ], + $config +); diff --git a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php new file mode 100644 index 00000000..0cd65f58 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php @@ -0,0 +1,47 @@ +createQueryBuilder() + ->select('m') + ->from(Many::class, 'm') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + + $query = $em->createQueryBuilder() + ->select(['m.intColumn', 'm.stringNullColumn']) + ->from(Many::class, 'm') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + } + + public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(QueryBuilder $builder): void + { + $query = $builder->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + } + + public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em): void + { + $query = $em->createQueryBuilder() + ->select('invalid') + ->from(Many::class, 'm') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/queryResult.php b/tests/Type/Doctrine/data/QueryResult/queryResult.php new file mode 100644 index 00000000..064bd7ce --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/queryResult.php @@ -0,0 +1,213 @@ +createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType('Doctrine\ORM\Query', $query); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType('Doctrine\ORM\Query', $query); + + } + + /** + * Test that we properly infer the return type of Query methods with implicit hydration mode + * + * - getResult() has a default hydration mode of HYDRATE_OBJECT, so we are able to infer the return type + * - Other methods have a default hydration mode of null and fallback on AbstractQuery::getHydrationMode(), so we can not assume the hydration mode and can not infer the return type + */ + public function testReturnTypeOfQueryMethodsWithImplicitHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'array', + $query->getResult() + ); + assertType( + 'mixed', + $query->execute() + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache() + ); + assertType( + 'mixed', + $query->executeUsingQueryCache() + ); + assertType( + 'mixed', + $query->getSingleResult() + ); + assertType( + 'mixed', + $query->getOneOrNullResult() + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_OBJECT + * + * We are able to infer the return type in most cases here + */ + public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'array', + $query->getResult(AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->execute(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'QueryResult\Entities\Many', + $query->getSingleResult(AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'QueryResult\Entities\Many|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT) + ); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'array', + $query->getResult(AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->execute(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null}', + $query->getSingleResult(AbstractQuery::HYDRATE_OBJECT) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null}|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT) + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode that is not HYDRATE_OBJECT + * + * We are never able to infer the return type here + */ + public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY) + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode that is not a constant value + * + * We are never able to infer the return type here + * + * @param int AbstractQuery::HYDRATE_* + */ + public function testReturnTypeOfQueryMethodsWithExplicitNonConstantHydrationMode(EntityManagerInterface $em, int $hydrationMode): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult($hydrationMode) + ); + assertType( + 'mixed', + $query->execute(null, $hydrationMode) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, $hydrationMode) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, $hydrationMode) + ); + assertType( + 'mixed', + $query->getSingleResult($hydrationMode) + ); + assertType( + 'mixed', + $query->getOneOrNullResult($hydrationMode) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 22e480dd..f5f81c8c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,5 @@