From 26707b20e0edf170bcab3f2d91bd73f7f6ac9e25 Mon Sep 17 00:00:00 2001 From: Arnaud Le Blanc Date: Fri, 29 Oct 2021 14:59:40 +0200 Subject: [PATCH 1/2] Infer Query result type --- extension.neon | 11 ++ .../CreateQueryDynamicReturnTypeExtension.php | 98 +++++++++++++++ .../QueryResultDynamicReturnTypeExtension.php | 119 ++++++++++++++++++ .../Query/ResultSetMappingTypeResolver.php | 77 ++++++++++++ stubs/ORM/AbstractQuery.stub | 3 + stubs/ORM/Query.stub | 12 ++ 6 files changed, 320 insertions(+) create mode 100644 src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php create mode 100644 src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php create mode 100644 src/Type/Doctrine/Query/ResultSetMappingTypeResolver.php create mode 100644 stubs/ORM/Query.stub diff --git a/extension.neon b/extension.neon index 55f143df..2f9a08b1 100644 --- a/extension.neon +++ b/extension.neon @@ -30,6 +30,7 @@ parameters: - stubs/Collections/Selectable.stub - stubs/ORM/QueryBuilder.stub - stubs/ORM/AbstractQuery.stub + - stubs/ORM/Query.stub - stubs/ServiceDocumentRepository.stub parametersSchema: @@ -111,6 +112,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..d5795f71 --- /dev/null +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -0,0 +1,98 @@ +objectMetadataResolver = $objectMetadataResolver; + } + + 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; + + if (!isset($methodCall->args[$queryStringArgIndex])) { + return $this->fallbackType(); + } + + $argType = $scope->getType($methodCall->args[$queryStringArgIndex]->value); + if (!$argType instanceof ConstantStringType) { + return $this->fallbackType(); + } + + $queryString = $argType->getValue(); + + $em = $this->objectMetadataResolver->getObjectManager(); + + if (!$em instanceof EntityManagerInterface) { + return $this->fallbackType(); + } + + $getResultSetMapping = new \ReflectionMethod( + Query::class, + 'getResultSetMapping', + ); + + $getResultSetMapping->setAccessible(true); + + try { + $query = $em->createQuery($queryString); + $resultSetMapping = $getResultSetMapping->invoke($query); + } catch (ORMException | DBALException | CommonException $e) { + return $this->fallbackType(); + } + + if (!$resultSetMapping instanceof ResultSetMapping) { + return $this->fallbackType(); + } + + $resolver = new ResultSetMappingTypeResolver($resultSetMapping); + + return new GenericObjectType( + Query::class, + [$resolver->resolveType()], + ); + } + + private function fallbackType(): GenericObjectType + { + return new GenericObjectType( + Query::class, + [new MixedType(true)], + ); + } +} diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php new file mode 100644 index 00000000..38572d18 --- /dev/null +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -0,0 +1,119 @@ + 0, + 'execute' => 1, + 'executeIgnoreQueryCache' => 1, + 'executeUsingQueryCache' => 1, + 'getSingleResult' => 0, + ]; + + private const METHOD_RETURNS_SINGLE = [ + 'getSingleResult' => true, + 'getOneOrNullResult' => true, + ]; + + private const METHOD_RETURNS_NULL = [ + 'getOneOrNullResult' => true, + ]; + + 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(); + $argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName] ?? 0; + + $isHydrationModeObject = $this->isHydrationModeObject($methodCall, $scope, $argIndex); + + if (!$isHydrationModeObject) { + return $this->fallbackType($methodReflection); + } + + $queryType = $scope->getType($methodCall->var); + + if (!$queryType instanceof GenericObjectType) { + return $this->fallbackType($methodReflection); + } + + $types = $queryType->getTypes(); + + if (!isset($types[0])) { + return $this->fallbackType($methodReflection); + } + + $resultType = $types[0]; + + if ($resultType instanceof VoidType) { + return $this->fallbackType($methodReflection); + } + + if ($methodName === 'getOneOrNullResult') { + return TypeCombinator::addNull($resultType); + } + + if ($methodName === 'getSingleResult') { + return $resultType; + } + + return new ArrayType( + new IntegerType(), + $resultType, + ); + } + + private function isHydrationModeObject( + MethodCall $methodCall, + Scope $scope, + int $argIndex + ): bool { + if (!isset($methodCall->args[$argIndex])) { + return true; + } + + $argType = $scope->getType($methodCall->args[$argIndex]->value); + if (!$argType instanceof ConstantIntegerType) { + return false; + } + + return $argType->getValue() === Query::HYDRATE_OBJECT; + } + + private function fallbackType(MethodReflection $methodReflection): Type + { + $parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()); + + return $parametersAcceptor->getReturnType(); + } +} diff --git a/src/Type/Doctrine/Query/ResultSetMappingTypeResolver.php b/src/Type/Doctrine/Query/ResultSetMappingTypeResolver.php new file mode 100644 index 00000000..3810bf8c --- /dev/null +++ b/src/Type/Doctrine/Query/ResultSetMappingTypeResolver.php @@ -0,0 +1,77 @@ +rsm = $rsm; + } + + public function resolveType(): Type + { + if (!$this->rsm->isSelect) { + return new VoidType(); + } + + // Mixed results are arrays containing more than one value. + // + // ResultSetMapping has no info about nullability of elements in a mixed + // result, so we just return array for now. + // + // Example queries that trigger a mixed result: + // + // In the following example query, result has type array{alert: Alert, id: int}: + // "SELECT a, a.id FROM Alert" + // + // In the following example query, result has type array{alert1: Alert, alert2: Alert}: + // "SELECT a1 AS alert1, a2 AS alert2 FROM Alert a1 JOIN Alert a2 WITH a1.id = a2.id" + // + // Example query that does not trigger a mixed result: + // + // In the following example query, result has type Alert: + // "SELECT a, o FROM Alert a JOIN a.owner o" + if ($this->rsm->isMixed || count($this->rsm->aliasMap) === 0) { + return new ArrayType( + new MixedType(true), + new MixedType(true), + ); + } + + $rootClassName = null; + + foreach ($this->rsm->aliasMap as $alias => $className) { + if (isset($this->rsm->parentAliasMap[$alias])) { + continue; + } + if ($rootClassName === null) { + $rootClassName = $className; + continue; + } + throw new \Exception(sprintf( + 'Unexpectedly found more than 1 root in a non-mixed ResultSetMapping (aliasMap: %s)', + JsonUtils::encode($this->rsm->aliasMap, + ))); + } + + if ($rootClassName === null) { + throw new \Exception(sprintf( + 'Unexpectedly did not found any root in a non-mixed ResultSetMapping (aliasMap: %s)', + JsonUtils::encode($this->rsm->aliasMap, + ))); + } + + return new ObjectType($rootClassName); + } +} diff --git a/stubs/ORM/AbstractQuery.stub b/stubs/ORM/AbstractQuery.stub index 0556ca5c..3b0a09ad 100644 --- a/stubs/ORM/AbstractQuery.stub +++ b/stubs/ORM/AbstractQuery.stub @@ -4,6 +4,9 @@ namespace Doctrine\ORM; use Doctrine\Common\Collections\ArrayCollection; +/** + * @template ResultT + */ abstract class AbstractQuery { diff --git a/stubs/ORM/Query.stub b/stubs/ORM/Query.stub new file mode 100644 index 00000000..9805794b --- /dev/null +++ b/stubs/ORM/Query.stub @@ -0,0 +1,12 @@ + + */ +final class Query extends AbstractQuery +{ +} From 3888f0cdd7415354b78a608650201129a8f901bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Nov 2021 13:53:58 +0000 Subject: [PATCH 2/2] Bump metcalfc/changelog-generator from 1.0.0 to 2.0.0 Bumps [metcalfc/changelog-generator](https://github.com/metcalfc/changelog-generator) from 1.0.0 to 2.0.0. - [Release notes](https://github.com/metcalfc/changelog-generator/releases) - [Changelog](https://github.com/metcalfc/changelog-generator/blob/main/release-notes.png) - [Commits](https://github.com/metcalfc/changelog-generator/compare/v1.0.0...v2.0.0) --- updated-dependencies: - dependency-name: metcalfc/changelog-generator dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 225470a6..59ca18a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v1.0.0 + uses: metcalfc/changelog-generator@v2.0.0 with: myToken: ${{ secrets.GITHUB_TOKEN }}