diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php index 8bdc9239..f0633a93 100644 --- a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -10,14 +10,22 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Doctrine\ObjectMetadataResolver; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\IntegerType; use PHPStan\Type\IterableType; +use PHPStan\Type\MixedType; use PHPStan\Type\NullType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\TypeUtils; +use PHPStan\Type\TypeWithClassName; use PHPStan\Type\VoidType; +use function count; final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -32,6 +40,23 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn 'getSingleResult' => 0, ]; + private const METHOD_HYDRATION_MODE = [ + 'getArrayResult' => AbstractQuery::HYDRATE_ARRAY, + 'getScalarResult' => AbstractQuery::HYDRATE_SCALAR, + 'getSingleColumnResult' => AbstractQuery::HYDRATE_SCALAR_COLUMN, + 'getSingleScalarResult' => AbstractQuery::HYDRATE_SINGLE_SCALAR, + ]; + + /** @var ObjectMetadataResolver */ + private $objectMetadataResolver; + + public function __construct( + ObjectMetadataResolver $objectMetadataResolver + ) + { + $this->objectMetadataResolver = $objectMetadataResolver; + } + public function getClass(): string { return AbstractQuery::class; @@ -39,7 +64,8 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]); + return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]) + || isset(self::METHOD_HYDRATION_MODE[$methodReflection->getName()]); } public function getTypeFromMethodCall( @@ -50,21 +76,23 @@ public function getTypeFromMethodCall( { $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(self::METHOD_HYDRATION_MODE[$methodName])) { + $hydrationMode = new ConstantIntegerType(self::METHOD_HYDRATION_MODE[$methodName]); + } elseif (isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) { + $argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName]; + $args = $methodCall->getArgs(); - if (isset($args[$argIndex])) { - $hydrationMode = $scope->getType($args[$argIndex]->value); + 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(); + } } else { - $parametersAcceptor = ParametersAcceptorSelector::selectSingle( - $methodReflection->getVariants() - ); - $parameter = $parametersAcceptor->getParameters()[$argIndex]; - $hydrationMode = $parameter->getDefaultValue() ?? new NullType(); + throw new ShouldNotHappenException(); } $queryType = $scope->getType($methodCall->var); @@ -98,9 +126,35 @@ private function getMethodReturnTypeForHydrationMode( return null; } - if (!$this->isObjectHydrationMode($hydrationMode)) { - // We support only HYDRATE_OBJECT. For other hydration modes, we - // return the declared return type of the method. + if (!$hydrationMode instanceof ConstantIntegerType) { + return null; + } + + $singleResult = false; + switch ($hydrationMode->getValue()) { + case AbstractQuery::HYDRATE_OBJECT: + break; + case AbstractQuery::HYDRATE_ARRAY: + $queryResultType = $this->getArrayHydratedReturnType($queryResultType); + break; + case AbstractQuery::HYDRATE_SCALAR: + $queryResultType = $this->getScalarHydratedReturnType($queryResultType); + break; + case AbstractQuery::HYDRATE_SINGLE_SCALAR: + $singleResult = true; + $queryResultType = $this->getSingleScalarHydratedReturnType($queryResultType); + break; + case AbstractQuery::HYDRATE_SIMPLEOBJECT: + $queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType); + break; + case AbstractQuery::HYDRATE_SCALAR_COLUMN: + $queryResultType = $this->getScalarColumnHydratedReturnType($queryResultType); + break; + default: + return null; + } + + if ($queryResultType === null) { return null; } @@ -108,13 +162,22 @@ private function getMethodReturnTypeForHydrationMode( case 'getSingleResult': return $queryResultType; case 'getOneOrNullResult': - return TypeCombinator::addNull($queryResultType); + $nullableQueryResultType = TypeCombinator::addNull($queryResultType); + if ($queryResultType instanceof BenevolentUnionType) { + $nullableQueryResultType = TypeUtils::toBenevolentUnion($nullableQueryResultType); + } + + return $nullableQueryResultType; case 'toIterable': return new IterableType( $queryKeyType->isNull()->yes() ? new IntegerType() : $queryKeyType, $queryResultType ); default: + if ($singleResult) { + return $queryResultType; + } + if ($queryKeyType->isNull()->yes()) { return AccessoryArrayListType::intersectWith(new ArrayType( new IntegerType(), @@ -128,13 +191,127 @@ private function getMethodReturnTypeForHydrationMode( } } - private function isObjectHydrationMode(Type $type): bool + /** + * When we're array-hydrating object, we're not sure of the shape of the array. + * We could return `new ArrayTyp(new MixedType(), new MixedType())` + * but the lack of precision in the array keys/values would give false positive. + * + * @see https://github.com/phpstan/phpstan-doctrine/pull/412#issuecomment-1497092934 + */ + private function getArrayHydratedReturnType(Type $queryResultType): ?Type { - if (!$type instanceof ConstantIntegerType) { - return false; + $objectManager = $this->objectMetadataResolver->getObjectManager(); + + $mixedFound = false; + $queryResultType = TypeTraverser::map( + $queryResultType, + static function (Type $type, callable $traverse) use ($objectManager, &$mixedFound): Type { + $isObject = (new ObjectWithoutClassType())->isSuperTypeOf($type); + if ($isObject->no()) { + return $traverse($type); + } + if ( + $isObject->maybe() + || !$type instanceof TypeWithClassName + || $objectManager === null + ) { + $mixedFound = true; + + return new MixedType(); + } + + /** @var class-string $className */ + $className = $type->getClassName(); + if (!$objectManager->getMetadataFactory()->hasMetadataFor($className)) { + return $traverse($type); + } + + $mixedFound = true; + + return new MixedType(); + } + ); + + return $mixedFound ? null : $queryResultType; + } + + /** + * When we're scalar-hydrating object, we're not sure of the shape of the array. + * We could return `new ArrayTyp(new MixedType(), new MixedType())` + * but the lack of precision in the array keys/values would give false positive. + * + * @see https://github.com/phpstan/phpstan-doctrine/pull/453#issuecomment-1895415544 + */ + private function getScalarHydratedReturnType(Type $queryResultType): ?Type + { + if (!$queryResultType->isArray()->yes()) { + return null; + } + + foreach ($queryResultType->getArrays() as $arrayType) { + $itemType = $arrayType->getItemType(); + + if ( + !(new ObjectWithoutClassType())->isSuperTypeOf($itemType)->no() + || !$itemType->isArray()->no() + ) { + // We could return `new ArrayTyp(new MixedType(), new MixedType())` + // but the lack of precision in the array keys/values would give false positive + // @see https://github.com/phpstan/phpstan-doctrine/pull/453#issuecomment-1895415544 + return null; + } + } + + return $queryResultType; + } + + private function getSimpleObjectHydratedReturnType(Type $queryResultType): ?Type + { + if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) { + return $queryResultType; + } + + return null; + } + + private function getSingleScalarHydratedReturnType(Type $queryResultType): ?Type + { + $queryResultType = $this->getScalarHydratedReturnType($queryResultType); + if ($queryResultType === null || !$queryResultType->isConstantArray()->yes()) { + return null; + } + + $types = []; + foreach ($queryResultType->getConstantArrays() as $constantArrayType) { + $values = $constantArrayType->getValueTypes(); + if (count($values) !== 1) { + return null; + } + + $types[] = $constantArrayType->getFirstIterableValueType(); + } + + return TypeCombinator::union(...$types); + } + + private function getScalarColumnHydratedReturnType(Type $queryResultType): ?Type + { + $queryResultType = $this->getScalarHydratedReturnType($queryResultType); + if ($queryResultType === null || !$queryResultType->isConstantArray()->yes()) { + return null; + } + + $types = []; + foreach ($queryResultType->getConstantArrays() as $constantArrayType) { + $values = $constantArrayType->getValueTypes(); + if (count($values) !== 1) { + return null; + } + + $types[] = $constantArrayType->getFirstIterableValueType(); } - return $type->getValue() === AbstractQuery::HYDRATE_OBJECT; + return TypeCombinator::union(...$types); } } diff --git a/tests/Type/Doctrine/data/QueryResult/queryResult.php b/tests/Type/Doctrine/data/QueryResult/queryResult.php index 02469e46..8721d898 100644 --- a/tests/Type/Doctrine/data/QueryResult/queryResult.php +++ b/tests/Type/Doctrine/data/QueryResult/queryResult.php @@ -144,11 +144,11 @@ public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(Enti } /** - * Test that we properly infer the return type of Query methods with explicit hydration mode that is not HYDRATE_OBJECT + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_ARRAY * - * We are never able to infer the return type here + * We can infer the return type by changing every object by an array */ - public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(EntityManagerInterface $em): void + public function testReturnTypeOfQueryMethodsWithExplicitArrayHydrationMode(EntityManagerInterface $em): void { $query = $em->createQuery(' SELECT m @@ -167,6 +167,11 @@ public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(E 'mixed', $query->execute(null, AbstractQuery::HYDRATE_ARRAY) ); + + assertType( + 'array', + $query->getArrayResult() + ); assertType( 'mixed', $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_ARRAY) @@ -183,6 +188,384 @@ public function testReturnTypeOfQueryMethodsWithExplicitNonObjectHydrationMode(E 'mixed', $query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY) ); + + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn, m.datetimeColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'list', + $query->getResult(AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'list', + $query->getArrayResult() + ); + assertType( + 'list', + $query->execute(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'list', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'list', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}', + $query->getSingleResult(AbstractQuery::HYDRATE_ARRAY) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY) + ); + } + + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_SCALAR + */ + public function testReturnTypeOfQueryMethodsWithExplicitScalarHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'array', + $query->getScalarResult() + ); + assertType( + 'iterable', + $query->toIterable([], AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR) + ); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'list', + $query->getResult(AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'list', + $query->getScalarResult() + ); + assertType( + 'list', + $query->execute(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'list', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'list', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null}', + $query->getSingleResult(AbstractQuery::HYDRATE_SCALAR) + ); + assertType( + 'array{intColumn: int, stringNullColumn: string|null}|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR) + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_SCALAR + */ + public function testReturnTypeOfQueryMethodsWithExplicitSingleScalarHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'bool|float|int|string|null', + $query->getSingleScalarResult() + ); + assertType( + 'iterable', + $query->toIterable([], AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + + $query = $em->createQuery(' + SELECT m.intColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'int', + $query->getResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int', + $query->getSingleScalarResult() + ); + assertType( + 'int', + $query->execute(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int', + $query->getSingleResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + + $query = $em->createQuery(' + SELECT COUNT(m.id) + FROM QueryResult\Entities\Many m + '); + + assertType( + 'int<0, max>', + $query->getResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int<0, max>', + $query->getSingleScalarResult() + ); + assertType( + 'int<0, max>', + $query->execute(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int<0, max>', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int<0, max>', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int<0, max>', + $query->getSingleResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + assertType( + 'int<0, max>|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR) + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_SIMPLEOBJECT + * + * We are never able to infer the return type here + */ + public function testReturnTypeOfQueryMethodsWithExplicitSimpleObjectHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'list', + $query->getResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'iterable', + $query->toIterable([], AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->execute(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'list', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'QueryResult\Entities\Many', + $query->getSingleResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'QueryResult\Entities\Many|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + + $query = $em->createQuery(' + SELECT m.intColumn, m.stringNullColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SIMPLEOBJECT) + ); + } + + /** + * Test that we properly infer the return type of Query methods with explicit hydration mode of HYDRATE_SCALAR_COLUMN + * + * We are never able to infer the return type here + */ + public function testReturnTypeOfQueryMethodsWithExplicitScalarColumnHydrationMode(EntityManagerInterface $em): void + { + $query = $em->createQuery(' + SELECT m + FROM QueryResult\Entities\Many m + '); + + assertType( + 'mixed', + $query->getResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'array', + $query->getSingleColumnResult() + ); + assertType( + 'iterable', + $query->toIterable([], AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'mixed', + $query->execute(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'mixed', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'mixed', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'mixed', + $query->getSingleResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'mixed', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + + $query = $em->createQuery(' + SELECT m.intColumn + FROM QueryResult\Entities\Many m + '); + + assertType( + 'list', + $query->getResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'list', + $query->getSingleColumnResult() + ); + assertType( + 'list', + $query->execute(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'list', + $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'list', + $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'int', + $query->getSingleResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); + assertType( + 'int|null', + $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR_COLUMN) + ); } /**