diff --git a/composer.json b/composer.json index 12c8f210..cb54847d 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ ], "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.8.11" + "phpstan/phpstan": "^1.9.0" }, "conflict": { "doctrine/collections": "<1.0", diff --git a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php index 6384f718..dfaecda4 100644 --- a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -24,7 +24,7 @@ use PHPStan\Type\UnionType; /** - * Infers TResult in Query on EntityManagerInterface::createQuery() + * Infers TResult and TKey in Query on EntityManagerInterface::createQuery() */ final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -63,7 +63,7 @@ public function getTypeFromMethodCall( if (!isset($args[$queryStringArgIndex])) { return new GenericObjectType( Query::class, - [new MixedType()] + [new MixedType(), new MixedType()] ); } @@ -78,7 +78,7 @@ public function getTypeFromMethodCall( $em = $this->objectMetadataResolver->getObjectManager(); if (!$em instanceof EntityManagerInterface) { - return new QueryType($queryString, null); + return new QueryType($queryString, null, null); } $typeBuilder = new QueryResultTypeBuilder(); @@ -87,14 +87,14 @@ public function getTypeFromMethodCall( $query = $em->createQuery($queryString); QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); } catch (ORMException | DBALException | NewDBALException | CommonException $e) { - return new QueryType($queryString, null); + return new QueryType($queryString, null, null); } - return new QueryType($queryString, $typeBuilder->getResultType()); + return new QueryType($queryString, $typeBuilder->getIndexType(), $typeBuilder->getResultType()); } return new GenericObjectType( Query::class, - [new MixedType()] + [new MixedType(), new MixedType()] ); }); } diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php index 66991f11..657e9bb8 100644 --- a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php @@ -8,8 +8,10 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Doctrine\Type\ListIndexMarkerType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\IntegerType; @@ -18,6 +20,7 @@ use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use PHPStan\Type\VoidType; final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -70,15 +73,17 @@ public function getTypeFromMethodCall( $queryType = $scope->getType($methodCall->var); $queryResultType = $this->getQueryResultType($queryType); + $queryIndexType = $this->getQueryIndexType($queryType); return $this->getMethodReturnTypeForHydrationMode( $methodReflection, $hydrationMode, - $queryResultType + $queryResultType, + $queryIndexType ); } - private function getQueryResultType(Type $queryType): Type + private function getQueryIndexType(Type $queryType): Type { if (!$queryType instanceof GenericObjectType) { return new MixedType(); @@ -89,10 +94,22 @@ private function getQueryResultType(Type $queryType): Type return $types[0] ?? new MixedType(); } + private function getQueryResultType(Type $queryType): Type + { + if (!$queryType instanceof GenericObjectType) { + return new MixedType(); + } + + $types = $queryType->getTypes(); + + return $types[1] ?? new MixedType(); + } + private function getMethodReturnTypeForHydrationMode( MethodReflection $methodReflection, Type $hydrationMode, - Type $queryResultType + Type $queryResultType, + Type $queryIndexType ): Type { $isVoidType = (new VoidType())->isSuperTypeOf($queryResultType); @@ -126,10 +143,17 @@ private function getMethodReturnTypeForHydrationMode( $queryResultType ); default: - return new ArrayType( - new MixedType(), - $queryResultType - ); + return $queryIndexType instanceof ListIndexMarkerType + ? AccessoryArrayListType::intersectWith( + TypeCombinator::intersect( + new ArrayType(new IntegerType(), $queryResultType), + ...TypeUtils::getAccessoryTypes($queryResultType) + ) + ) + : new ArrayType( + new MixedType(), + $queryResultType + ); } } diff --git a/src/Type/Doctrine/Query/QueryResultTypeBuilder.php b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php index d8b93bc0..7a5d657b 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeBuilder.php +++ b/src/Type/Doctrine/Query/QueryResultTypeBuilder.php @@ -5,6 +5,7 @@ use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Doctrine\Type\ListIndexMarkerType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; @@ -62,6 +63,14 @@ final class QueryResultTypeBuilder */ private $newObjects = []; + /** @var Type */ + private $indexedBy; + + public function __construct() + { + $this->indexedBy = new ListIndexMarkerType(); + } + public function setSelectQuery(): void { $this->selectQuery = true; @@ -230,4 +239,18 @@ private function resolveOffsetType($alias): Type return new ConstantStringType($alias); } + public function setIndexedBy(Type $type): void + { + $this->indexedBy = $type; + } + + public function getIndexType(): Type + { + if (!$this->selectQuery) { + return new VoidType(); + } + + return $this->indexedBy; + } + } diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php index 1f592e42..6fef57ed 100644 --- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php +++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php @@ -78,7 +78,7 @@ class QueryResultTypeWalker extends SqlWalker */ private $newObjectCounter = 0; - /** @var Query */ + /** @var Query */ private $query; /** @var EntityManagerInterface */ @@ -107,7 +107,7 @@ class QueryResultTypeWalker extends SqlWalker private $hasGroupByClause; /** - * @param Query $query + * @param Query $query */ public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, DescriptorRegistry $descriptorRegistry): void { @@ -122,7 +122,7 @@ public static function walk(Query $query, QueryResultTypeBuilder $typeBuilder, D /** * {@inheritDoc} * - * @param Query $query + * @param Query $query * @param ParserResult $parserResult * @param array $queryComponents */ @@ -312,6 +312,10 @@ public function walkFromClause($fromClause) */ public function walkIdentificationVariableDeclaration($identificationVariableDecl) { + if ($identificationVariableDecl->indexBy !== null) { + $identificationVariableDecl->indexBy->dispatch($this); + } + foreach ($identificationVariableDecl->joins as $join) { assert($join instanceof AST\Node); @@ -326,6 +330,8 @@ public function walkIdentificationVariableDeclaration($identificationVariableDec */ public function walkIndexBy($indexBy): void { + $type = $this->unmarshalType($indexBy->singleValuedPathExpression->dispatch($this)); + $this->typeBuilder->setIndexedBy($type); } /** diff --git a/src/Type/Doctrine/Query/QueryType.php b/src/Type/Doctrine/Query/QueryType.php index 63766acc..54a58e08 100644 --- a/src/Type/Doctrine/Query/QueryType.php +++ b/src/Type/Doctrine/Query/QueryType.php @@ -14,10 +14,16 @@ class QueryType extends GenericObjectType /** @var string */ private $dql; - public function __construct(string $dql, ?Type $resultType = null) + public function __construct( + string $dql, + ?Type $indexType, + ?Type $resultType + ) { - $resultType = $resultType ?? new MixedType(); - parent::__construct('Doctrine\ORM\Query', [$resultType]); + parent::__construct('Doctrine\ORM\Query', [ + $indexType ?? new MixedType(), + $resultType ?? new MixedType(), + ]); $this->dql = $dql; } diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php index c828411f..788f72e0 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php @@ -153,7 +153,7 @@ private function getQueryType(string $dql): Type { $em = $this->objectMetadataResolver->getObjectManager(); if (!$em instanceof EntityManagerInterface) { - return new QueryType($dql, null); + return new QueryType($dql, null, null); } $typeBuilder = new QueryResultTypeBuilder(); @@ -162,10 +162,10 @@ private function getQueryType(string $dql): Type $query = $em->createQuery($dql); QueryResultTypeWalker::walk($query, $typeBuilder, $this->descriptorRegistry); } catch (ORMException | DBALException | CommonException $e) { - return new QueryType($dql, null); + return new QueryType($dql, null, null); } - return new QueryType($dql, $typeBuilder->getResultType()); + return new QueryType($dql, $typeBuilder->getIndexType(), $typeBuilder->getResultType()); } } diff --git a/src/Type/Doctrine/Type/ListIndexMarkerType.php b/src/Type/Doctrine/Type/ListIndexMarkerType.php new file mode 100644 index 00000000..f353a9ec --- /dev/null +++ b/src/Type/Doctrine/Type/ListIndexMarkerType.php @@ -0,0 +1,10 @@ + + * @extends AbstractQuery */ final class Query extends AbstractQuery { diff --git a/stubs/ORM/QueryBuilder.stub b/stubs/ORM/QueryBuilder.stub index db14c5b2..0630a79a 100644 --- a/stubs/ORM/QueryBuilder.stub +++ b/stubs/ORM/QueryBuilder.stub @@ -15,7 +15,7 @@ class QueryBuilder } /** - * @return Query + * @return Query */ public function getQuery() { diff --git a/stubs/bleedingEdge/ORM/QueryBuilder.stub b/stubs/bleedingEdge/ORM/QueryBuilder.stub index 9fb03cff..aae683aa 100644 --- a/stubs/bleedingEdge/ORM/QueryBuilder.stub +++ b/stubs/bleedingEdge/ORM/QueryBuilder.stub @@ -8,7 +8,7 @@ class QueryBuilder { /** - * @return Query + * @return Query */ public function getQuery() { @@ -143,7 +143,7 @@ class QueryBuilder { } - + /** * @param literal-string|object|array $predicates * @return $this diff --git a/tests/Type/Doctrine/data/QueryResult/config.neon b/tests/Type/Doctrine/data/QueryResult/config.neon index 147e4f94..5ce3210a 100644 --- a/tests/Type/Doctrine/data/QueryResult/config.neon +++ b/tests/Type/Doctrine/data/QueryResult/config.neon @@ -3,3 +3,5 @@ includes: parameters: doctrine: objectManagerLoader: entity-manager.php + featureToggles: + listType: true diff --git a/tests/Type/Doctrine/data/QueryResult/createQuery.php b/tests/Type/Doctrine/data/QueryResult/createQuery.php index 2511b435..204bf329 100644 --- a/tests/Type/Doctrine/data/QueryResult/createQuery.php +++ b/tests/Type/Doctrine/data/QueryResult/createQuery.php @@ -15,28 +15,28 @@ public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): v FROM QueryResult\Entities\Many m '); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); $query = $em->createQuery(' SELECT m.intColumn, m.stringNullColumn FROM QueryResult\Entities\Many m '); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(EntityManagerInterface $em, string $dql): void { $query = $em->createQuery($dql); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em, string $dql): void { $query = $em->createQuery('invalid'); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } } diff --git a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php index 000f9d7e..5c384fd7 100644 --- a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php +++ b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php @@ -20,12 +20,12 @@ private function getQueryBuilderMany(EntityManagerInterface $em): QueryBuilder public function addAndWhereAndGetQuery(EntityManagerInterface $em): void { $qb = $this->getQueryBuilderMany($em)->andWhere('m.intColumn = 1'); - assertType('array', $qb->getQuery()->getResult()); + assertType('list', $qb->getQuery()->getResult()); } public function getQueryDirectly(EntityManagerInterface $em): void { - assertType('array', $this->getQueryBuilderMany($em)->getQuery()->getResult()); + assertType('list', $this->getQueryBuilderMany($em)->getQuery()->getResult()); } public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): void @@ -35,21 +35,74 @@ public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): v ->from(Many::class, 'm') ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); $query = $em->createQueryBuilder() ->select(['m.intColumn', 'm.stringNullColumn']) ->from(Many::class, 'm') ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); + } + + public function testIndexByInfering(EntityManagerInterface $em): void + { + $query = $em->createQueryBuilder() + ->select('m') + ->from(Many::class, 'm', 'm.intColumn') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + + $query = $em->createQueryBuilder() + ->select('m') + ->from(Many::class, 'm', 'm.stringColumn') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + + $query = $em->createQueryBuilder() + ->select(['m.intColumn', 'm.stringNullColumn']) + ->from(Many::class, 'm') + ->indexBy('m', 'm.stringColumn') + ->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + } + + public function testIndexByResultInfering(EntityManagerInterface $em): void + { + $result = $em->createQueryBuilder() + ->select('m') + ->from(Many::class, 'm', 'm.intColumn') + ->getQuery() + ->getResult(); + + assertType('array', $result); + + $result = $em->createQueryBuilder() + ->select('m') + ->from(Many::class, 'm', 'm.stringColumn') + ->getQuery() + ->getResult(); + + assertType('array', $result); + + $result = $em->createQueryBuilder() + ->select(['m.intColumn', 'm.stringNullColumn']) + ->from(Many::class, 'm') + ->indexBy('m', 'm.stringColumn') + ->getQuery() + ->getResult(); + + assertType('array', $result); } public function testQueryResultTypeIsMixedWhenDQLIsNotKnown(QueryBuilder $builder): void { $query = $builder->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterface $em): void @@ -59,7 +112,7 @@ public function testQueryResultTypeIsMixedWhenDQLIsInvalid(EntityManagerInterfac ->from(Many::class, 'm') ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } public function testQueryResultTypeIsVoidWithDeleteOrUpdate(EntityManagerInterface $em): void @@ -71,7 +124,7 @@ public function testQueryResultTypeIsVoidWithDeleteOrUpdate(EntityManagerInterfa ->delete() ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); $query = $em->getRepository(Many::class) ->createQueryBuilder('m') @@ -81,7 +134,7 @@ public function testQueryResultTypeIsVoidWithDeleteOrUpdate(EntityManagerInterfa ->set('m.intColumn', '42') ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } @@ -90,7 +143,7 @@ public function testQueryTypeIsInferredOnAcrossMethods(EntityManagerInterface $e $query = $this->getQueryBuilder($em) ->getQuery(); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); } private function getQueryBuilder(EntityManagerInterface $em): QueryBuilder diff --git a/tests/Type/Doctrine/data/QueryResult/queryResult.php b/tests/Type/Doctrine/data/QueryResult/queryResult.php index 3a1e144e..64384b2c 100644 --- a/tests/Type/Doctrine/data/QueryResult/queryResult.php +++ b/tests/Type/Doctrine/data/QueryResult/queryResult.php @@ -16,15 +16,14 @@ public function testQueryTypeParametersAreInfered(EntityManagerInterface $em): v FROM QueryResult\Entities\Many m '); - assertType('Doctrine\ORM\Query', $query); + assertType('Doctrine\ORM\Query', $query); $query = $em->createQuery(' SELECT m.intColumn, m.stringNullColumn FROM QueryResult\Entities\Many m '); - assertType('Doctrine\ORM\Query', $query); - + assertType('Doctrine\ORM\Query', $query); } /** @@ -41,7 +40,7 @@ public function testReturnTypeOfQueryMethodsWithImplicitHydrationMode(EntityMana '); assertType( - 'array', + 'list', $query->getResult() ); assertType( @@ -83,7 +82,7 @@ public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(Enti '); assertType( - 'array', + 'list', $query->getResult(AbstractQuery::HYDRATE_OBJECT) ); assertType( @@ -91,15 +90,15 @@ public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(Enti $query->toIterable([], AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->execute(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( @@ -117,19 +116,19 @@ public function testReturnTypeOfQueryMethodsWithExplicitObjectHydrationMode(Enti '); assertType( - 'array', + 'list', $query->getResult(AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->execute(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( - 'array', + 'list', $query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT) ); assertType( @@ -232,7 +231,7 @@ public function testReturnTypeOfQueryMethodsWithExplicitNonConstantHydrationMode * Test that we return the original return type when ResultType may be * VoidType * - * @param Query $query + * @param Query $query */ public function testReturnTypeOfQueryMethodsWithReturnTypeIsMixed(EntityManagerInterface $em, Query $query): void { @@ -272,7 +271,7 @@ public function testReturnTypeOfQueryMethodsWithReturnTypeIsMixed(EntityManagerI * * @template T * - * @param Query $query + * @param Query $query */ public function testReturnTypeOfQueryMethodsWithReturnTypeIsTemplateMixedType(EntityManagerInterface $em, Query $query): void { @@ -312,7 +311,7 @@ public function testReturnTypeOfQueryMethodsWithReturnTypeIsTemplateMixedType(En * * @template T of array|object * - * @param Query $query + * @param Query $query */ public function testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(EntityManagerInterface $em, Query $query): void {