diff --git a/extension.neon b/extension.neon index fe686a66..81469b91 100644 --- a/extension.neon +++ b/extension.neon @@ -91,6 +91,8 @@ services: - class: PHPStan\Doctrine\Driver\DriverDetector + - + class: PHPStan\Type\Doctrine\HydrationModeReturnTypeResolver - class: PHPStan\Reflection\Doctrine\DoctrineSelectableClassReflectionExtension - diff --git a/src/Type/Doctrine/HydrationModeReturnTypeResolver.php b/src/Type/Doctrine/HydrationModeReturnTypeResolver.php new file mode 100644 index 00000000..301731f5 --- /dev/null +++ b/src/Type/Doctrine/HydrationModeReturnTypeResolver.php @@ -0,0 +1,143 @@ +isSuperTypeOf($queryResultType); + + if ($isVoidType->yes()) { + // 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 ($isVoidType->maybe()) { + // We can't be sure what the query type is, so we return the + // declared return type of the method. + return null; + } + + switch ($hydrationMode) { + case AbstractQuery::HYDRATE_OBJECT: + break; + case AbstractQuery::HYDRATE_ARRAY: + $queryResultType = $this->getArrayHydratedReturnType($queryResultType, $objectManager); + break; + case AbstractQuery::HYDRATE_SIMPLEOBJECT: + $queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType); + break; + default: + return null; + } + + if ($queryResultType === null) { + return null; + } + + switch ($methodName) { + case 'getSingleResult': + return $queryResultType; + case 'getOneOrNullResult': + $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 ($queryKeyType->isNull()->yes()) { + return AccessoryArrayListType::intersectWith(new ArrayType( + new IntegerType(), + $queryResultType + )); + } + return new ArrayType( + $queryKeyType, + $queryResultType + ); + } + } + + /** + * 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, ?ObjectManager $objectManager): ?Type + { + $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; + } + + private function getSimpleObjectHydratedReturnType(Type $queryResultType): ?Type + { + if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) { + return $queryResultType; + } + + return null; + } + +} diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php index 54345e7f..818c3869 100644 --- a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -8,23 +8,12 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; 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\HydrationModeReturnTypeResolver; 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; final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -46,11 +35,16 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn /** @var ObjectMetadataResolver */ private $objectMetadataResolver; + /** @var HydrationModeReturnTypeResolver */ + private $hydrationModeReturnTypeResolver; + public function __construct( - ObjectMetadataResolver $objectMetadataResolver + ObjectMetadataResolver $objectMetadataResolver, + HydrationModeReturnTypeResolver $hydrationModeReturnTypeResolver ) { $this->objectMetadataResolver = $objectMetadataResolver; + $this->hydrationModeReturnTypeResolver = $hydrationModeReturnTypeResolver; } public function getClass(): string @@ -93,136 +87,17 @@ public function getTypeFromMethodCall( $queryType = $scope->getType($methodCall->var); - return $this->getMethodReturnTypeForHydrationMode( - $methodReflection, - $hydrationMode, - $queryType->getTemplateType(AbstractQuery::class, 'TKey'), - $queryType->getTemplateType(AbstractQuery::class, 'TResult') - ); - } - - private function getMethodReturnTypeForHydrationMode( - MethodReflection $methodReflection, - Type $hydrationMode, - Type $queryKeyType, - Type $queryResultType - ): ?Type - { - $isVoidType = (new VoidType())->isSuperTypeOf($queryResultType); - - if ($isVoidType->yes()) { - // 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 ($isVoidType->maybe()) { - // We can't be sure what the query type is, so we return the - // declared return type of the method. - return null; - } - if (!$hydrationMode instanceof ConstantIntegerType) { return null; } - switch ($hydrationMode->getValue()) { - case AbstractQuery::HYDRATE_OBJECT: - break; - case AbstractQuery::HYDRATE_ARRAY: - $queryResultType = $this->getArrayHydratedReturnType($queryResultType); - break; - case AbstractQuery::HYDRATE_SIMPLEOBJECT: - $queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType); - break; - default: - return null; - } - - if ($queryResultType === null) { - return null; - } - - switch ($methodReflection->getName()) { - case 'getSingleResult': - return $queryResultType; - case 'getOneOrNullResult': - $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 ($queryKeyType->isNull()->yes()) { - return AccessoryArrayListType::intersectWith(new ArrayType( - new IntegerType(), - $queryResultType - )); - } - return new ArrayType( - $queryKeyType, - $queryResultType - ); - } - } - - /** - * 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 - { - $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 $this->hydrationModeReturnTypeResolver->getMethodReturnTypeForHydrationMode( + $methodReflection->getName(), + $hydrationMode->getValue(), + $queryType->getTemplateType(AbstractQuery::class, 'TKey'), + $queryType->getTemplateType(AbstractQuery::class, 'TResult'), + $this->objectMetadataResolver->getObjectManager() ); - - return $mixedFound ? null : $queryResultType; - } - - private function getSimpleObjectHydratedReturnType(Type $queryResultType): ?Type - { - if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) { - return $queryResultType; - } - - return null; } } diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php new file mode 100644 index 00000000..de3bf93b --- /dev/null +++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerHydrationModeTest.php @@ -0,0 +1,484 @@ +getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($classes); + $schemaTool->createSchema($classes); + + $simple = new Simple(); + $simple->id = '1'; + $simple->intColumn = 1; + $simple->floatColumn = 0.1; + $simple->decimalColumn = '1.1'; + $simple->stringColumn = 'foobar'; + $simple->stringNullColumn = null; + + $entityManager->persist($simple); + $entityManager->flush(); + + $query = $entityManager->createQuery($dql); + + $typeBuilder = new QueryResultTypeBuilder(); + + QueryResultTypeWalker::walk( + $query, + $typeBuilder, + self::getContainer()->getByType(DescriptorRegistry::class), + self::getContainer()->getByType(PhpVersion::class), + self::getContainer()->getByType(DriverDetector::class) + ); + + $resolver = self::getContainer()->getByType(HydrationModeReturnTypeResolver::class); + + $type = $resolver->getMethodReturnTypeForHydrationMode( + $methodName, + $this->getRealHydrationMode($methodName, $hydrationMode), + $typeBuilder->getIndexType(), + $typeBuilder->getResultType(), + $entityManager + ) ?? new MixedType(); + + self::assertSame( + $expectedType->describe(VerbosityLevel::precise()), + $type->describe(VerbosityLevel::precise()) + ); + + $query = $entityManager->createQuery($dql); + $result = $this->getQueryResult($query, $methodName, $hydrationMode); + + $resultType = ConstantTypeHelper::getTypeFromValue($result); + self::assertTrue( + $type->accepts($resultType, true)->yes(), + sprintf( + "The inferred type\n%s\nshould accept actual type\n%s", + $type->describe(VerbosityLevel::precise()), + $resultType->describe(VerbosityLevel::precise()) + ) + ); + } + + /** + * @return iterable + */ + public static function getTestData(): iterable + { + AccessoryArrayListType::setListTypeEnabled(true); + + yield 'getResult(object), full entity' => [ + self::list(new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'getResult(simple_object), full entity' => [ + self::list(new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SIMPLEOBJECT, + ]; + + yield 'getResult(array), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_ARRAY, + ]; + + yield 'getResult(array), fields' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString()], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_ARRAY, + ]; + + yield 'getResult(array), expressions' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_ARRAY, + ]; + + yield 'getResult(object), fields' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString()], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'getResult(object), expressions' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), full entity' => [ + new IterableType(new IntegerType(), new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), fields' => [ + new IterableType(new IntegerType(), self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString()], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(object), expressions' => [ + new IterableType(new IntegerType(), self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_OBJECT, + ]; + + yield 'toIterable(simple_object), full entity' => [ + new IterableType(new IntegerType(), new ObjectType(Simple::class)), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_SIMPLEOBJECT, + ]; + + yield 'toIterable(array), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'toIterable', + Query::HYDRATE_ARRAY, + ]; + + yield 'getArrayResult(), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getArrayResult', + ]; + + yield 'getArrayResult(), fields' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::numericString()], + [new ConstantStringType('floatColumn'), new FloatType()], + ])), + ' + SELECT s.decimalColumn, s.floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getArrayResult', + ]; + + yield 'getArrayResult(), expressions' => [ + self::list(self::constantArray([ + [new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()], + [new ConstantStringType('floatColumn'), self::floatOrStringified()], + ])), + ' + SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn + FROM QueryResult\Entities\Simple s + ', + 'getArrayResult', + ]; + + yield 'getResult(single_scalar), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SINGLE_SCALAR, + ]; + + yield 'getResult(scalar), full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SCALAR, + ]; + + yield 'getResult(scalar), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getResult', + Query::HYDRATE_SCALAR, + ]; + + yield 'getScalarResult, full entity' => [ + new MixedType(), + ' + SELECT s + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getScalarResult(), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getScalarResult(), decimal expression' => [ + new MixedType(), + ' + SELECT -s.decimalColumn as col + FROM QueryResult\Entities\Simple s + ', + 'getScalarResult', + ]; + + yield 'getSingleScalarResult(), decimal field' => [ + new MixedType(), + ' + SELECT s.decimalColumn + FROM QueryResult\Entities\Simple s + ', + 'getSingleScalarResult', + ]; + } + + /** + * @param array $elements + */ + private static function constantArray(array $elements): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + foreach ($elements as $element) { + $offsetType = $element[0]; + $valueType = $element[1]; + $optional = $element[2] ?? false; + $builder->setOffsetValueType($offsetType, $valueType, $optional); + } + + return $builder->getArray(); + } + + private static function list(Type $values): Type + { + return AccessoryArrayListType::intersectWith(new ArrayType(new IntegerType(), $values)); + } + + private static function numericString(): Type + { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + ]); + } + + /** + * @param Query $query + * @return mixed + */ + private function getQueryResult(Query $query, string $methodName, ?int $hydrationMode) + { + if ($methodName === 'getResult') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for getResult() method.'); + } + return $query->getResult($hydrationMode); // @phpstan-ignore-line dynamic arg + } + + if ($methodName === 'getArrayResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getArrayResult() method.'); + } + return $query->getArrayResult(); + } + + if ($methodName === 'getScalarResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getScalarResult() method.'); + } + return $query->getScalarResult(); + } + + if ($methodName === 'getSingleResult') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for getSingleResult() method.'); + } + + return $query->getSingleResult($hydrationMode); // @phpstan-ignore-line dynamic arg + } + + if ($methodName === 'getSingleScalarResult') { + if ($hydrationMode !== null) { + throw new LogicException('Hydration mode must NOT be set for getSingleScalarResult() method.'); + } + + return $query->getSingleScalarResult(); + } + + if ($methodName === 'toIterable') { + if ($hydrationMode === null) { + throw new LogicException('Hydration mode must be set for toIterable() method.'); + } + + return $query->toIterable([], $hydrationMode); // @phpstan-ignore-line dynamic arg + } + + throw new LogicException(sprintf('Unsupported method %s.', $methodName)); + } + + private function getRealHydrationMode(string $methodName, ?int $hydrationMode): int + { + if ($hydrationMode !== null) { + return $hydrationMode; + } + + if ($methodName === 'getArrayResult') { + return Query::HYDRATE_ARRAY; + } + + if ($methodName === 'getScalarResult') { + return Query::HYDRATE_SCALAR; + } + + if ($methodName === 'getSingleScalarResult') { + return Query::HYDRATE_SCALAR; + } + + throw new LogicException(sprintf('Using %s without hydration mode is not supported.', $methodName)); + } + + + private static function stringifies(): bool + { + return PHP_VERSION_ID < 80100; + } + + private static function floatOrStringified(): Type + { + return self::stringifies() + ? self::numericString() + : new FloatType(); + } + + private static function floatOrIntOrStringified(): Type + { + return self::stringifies() + ? self::numericString() + : TypeCombinator::union(new FloatType(), new IntegerType()); + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php b/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php new file mode 100644 index 00000000..5169a9e5 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/Entities/Simple.php @@ -0,0 +1,71 @@ +