Repository: phpstan/phpstan-doctrine (php)

Issue:
Wrong type inference for QueryBuilder::update and QueryBuilder::delete.
The following code
```
$result = $this->getEntityManager()
            ->getRepository(ProductCrossSell::class)
            ->createQueryBuilder('pcs')
            ->where('pcs.id IN (:ids)')
            ->setParameter('ids', $ids)
            ->delete()
            ->getQuery()
            ->getResult();

\PHPStan\dumpType($result);
```
Is considering `$result` as an array. But it's an int: the number of deleted elements. Same with `update()`

It was introduce by https://github.com/phpstan/phpstan-doctrine/pull/232 cc @arnaud-lb 

Pull request description:
We can not assume the type of Query::getResult() if ResultType may be void
Fixes #259 

Normally we assume the type of `Query<ResultType>::getResult()` to be `array<ResultType>`. However, we also assume that `Query<void>::getResult()` returns a `int` (`void` denotes an INSERT or DELETE query).

So we can not assume the type of `Query<ResultType>::getResult()` to be `array<ResultType>` if there is a chance that `ResultType` is `void`.

In this PR I change the `DynamicReturnTypeExtension` of Query::getResult() so that no return type is assumed when ResultType may be void.

Note:

This has an impact on this feature when used in conjunction with template types, as this is not valid anymore:

```
/**
 * @template T
 * @param Query<T> $q
 * @return array<T>
 */
function test($q) {
    return $q->getResult(); // mixed
}
```

However, this is valid:

```
/**
 * @template T of array|object A non-void type parameter
 * @param Query<T> $q
 * @return array<T>
 */
function test($q) {
    return $q->getResult(); // array<T>
}
```

Patch:
diff --git a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php
index d7c9382..bf2526b 100644
--- a/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php
+++ b/src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php
@@ -93,12 +93,20 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn
 		Type $queryResultType
 	): Type
 	{
-		if ($queryResultType instanceof VoidType) {
+		$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 $this->originalReturnType($methodReflection);
+		}
+
 		if (!$this->isObjectHydrationMode($hydrationMode)) {
 			// We support only HYDRATE_OBJECT. For other hydration modes, we
 			// return the declared return type of the method.


Test patch:
diff --git a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php
index 0cd65f5..a9b47e9 100644
--- a/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php
+++ b/tests/Type/Doctrine/data/QueryResult/queryBuilderGetQuery.php
@@ -44,4 +44,26 @@ class QueryBuilderGetQuery
 		assertType('Doctrine\ORM\Query<mixed>', $query);
 	}
 
+	public function testQueryResultTypeIsVoidWithDeleteOrUpdate(EntityManagerInterface $em): void
+	{
+		$query = $em->getRepository(Many::class)
+				 ->createQueryBuilder('m')
+				 ->where('m.id IN (:ids)')
+				 ->setParameter('ids', $ids)
+				 ->delete()
+				 ->getQuery();
+
+		assertType('Doctrine\ORM\Query<void>', $query);
+
+		$query = $em->getRepository(Many::class)
+				 ->createQueryBuilder('m')
+				 ->where('m.id IN (:ids)')
+				 ->setParameter('ids', $ids)
+				 ->update()
+				 ->set('m.intColumn', '42')
+				 ->getQuery();
+
+		assertType('Doctrine\ORM\Query<void>', $query);
+
+	}
 }
diff --git a/tests/Type/Doctrine/data/QueryResult/queryResult.php b/tests/Type/Doctrine/data/QueryResult/queryResult.php
index 064bd7c..1b583bb 100644
--- a/tests/Type/Doctrine/data/QueryResult/queryResult.php
+++ b/tests/Type/Doctrine/data/QueryResult/queryResult.php
@@ -4,6 +4,7 @@ namespace QueryResult\queryResult;
 
 use Doctrine\ORM\AbstractQuery;
 use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Query;
 use function PHPStan\Testing\assertType;
 
 class QueryResultTest
@@ -210,4 +211,110 @@ class QueryResultTest
 			$query->getOneOrNullResult($hydrationMode)
 		);
 	}
+
+	/**
+	 * Test that we return the original return type when ResultType may be
+	 * VoidType
+	 *
+	 * @param Query<mixed> $query
+	 */
+	public function testReturnTypeOfQueryMethodsWithReturnTypeIsMixed(EntityManagerInterface $em, Query $query): void
+	{
+		assertType(
+			'mixed',
+			$query->getResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->execute(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->getSingleResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+	}
+
+	/**
+	 * Test that we return the original return type when ResultType may be
+	 * VoidType (TemplateType variant)
+	 *
+	 * @template T
+	 *
+	 * @param Query<T> $query
+	 */
+	public function testReturnTypeOfQueryMethodsWithReturnTypeIsTemplateMixedType(EntityManagerInterface $em, Query $query): void
+	{
+		assertType(
+			'mixed',
+			$query->getResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->execute(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->getSingleResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'mixed',
+			$query->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+	}
+
+
+	/**
+	 * Test that we return ResultType return ResultType can not be VoidType
+	 *
+	 * @template T of array|object
+	 *
+	 * @param Query<T> $query
+	 */
+	public function testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(EntityManagerInterface $em, Query $query): void
+	{
+		assertType(
+			'array<T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument)>',
+			$query->getResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'array<T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument)>',
+			$query->execute(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'array<T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument)>',
+			$query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'array<T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument)>',
+			$query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument)',
+			$query->getSingleResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+		assertType(
+			'(T of array|object (method QueryResult\queryResult\QueryResultTest::testReturnTypeOfQueryMethodsWithReturnTypeIsNonVoidTemplate(), argument))|null',
+			$query->getOneOrNullResult(AbstractQuery::HYDRATE_OBJECT)
+		);
+	}
 }