Repository: phpstan/phpstan-doctrine (php)

Issue:
groupBy adds null to QueryResultType
I have the following QueryBuilder defined:
```php
/**
     * @return array<Category>
     */
    public function getAllForNavigation(): array
    {
        return $this->getEntityManager()->createQueryBuilder()
            ->select('cat')
            ->from(Category::class, 'cat')
            ->join(Navigation::class, 'nav', Join::WITH, 'nav.joinColumn = cat.joinColumn')
            ->groupBy('nav.joinColumn')
            ->getQuery()
            ->getResult();
    }
```
This reports an error:
> should return array<Category> but returns array<Category|null>
When the `groupBy`-call is removed, everything works fine.

Pull request:
Aggregate functions affect nullability only when the query is not grouped
Fixes #265

Patch:
diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php
index b8cb5d7..3438e0f 100644
--- a/src/Type/Doctrine/ObjectMetadataResolver.php
+++ b/src/Type/Doctrine/ObjectMetadataResolver.php
@@ -66,6 +66,10 @@ final class ObjectMetadataResolver
 	 */
 	public function isTransient(string $className): bool
 	{
+		if (!class_exists($className)) {
+			return true;
+		}
+
 		$objectManager = $this->getObjectManager();
 
 		try {
diff --git a/src/Type/Doctrine/Query/QueryResultTypeWalker.php b/src/Type/Doctrine/Query/QueryResultTypeWalker.php
index c15acd0..d82dc2b 100644
--- a/src/Type/Doctrine/Query/QueryResultTypeWalker.php
+++ b/src/Type/Doctrine/Query/QueryResultTypeWalker.php
@@ -97,7 +97,10 @@ class QueryResultTypeWalker extends SqlWalker
 	private $descriptorRegistry;
 
 	/** @var bool */
-	private $isAggregated;
+	private $hasAggregateFunction;
+
+	/** @var bool */
+	private $hasGroupByClause;
 
 	/**
 	 * @param Query<mixed> $query
@@ -125,7 +128,8 @@ class QueryResultTypeWalker extends SqlWalker
 		$this->em = $query->getEntityManager();
 		$this->queryComponents = $queryComponents;
 		$this->nullableQueryComponents = [];
-		$this->isAggregated = false;
+		$this->hasAggregateFunction = false;
+		$this->hasGroupByClause = false;
 
 		// The object is instantiated by Doctrine\ORM\Query\Parser, so receiving
 		// dependencies through the constructor is not an option. Instead, we
@@ -166,7 +170,8 @@ class QueryResultTypeWalker extends SqlWalker
 	public function walkSelectStatement(AST\SelectStatement $AST)
 	{
 		$this->typeBuilder->setSelectQuery();
-		$this->isAggregated = $this->isAggregated($AST);
+		$this->hasAggregateFunction = $this->hasAggregateFunction($AST);
+		$this->hasGroupByClause = $AST->groupByClause !== null;
 
 		$this->walkFromClause($AST->fromClause);
 
@@ -231,7 +236,9 @@ class QueryResultTypeWalker extends SqlWalker
 
 				assert(is_string($typeName));
 
-				$nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->isAggregated;
+				$nullable = $this->isQueryComponentNullable($dqlAlias)
+					|| $class->isNullable($fieldName)
+					|| $this->hasAggregateWithoutGroupBy();
 
 				$fieldType = $this->resolveDatabaseInternalType($typeName, $nullable);
 
@@ -267,7 +274,8 @@ class QueryResultTypeWalker extends SqlWalker
 
 				assert(is_string($typeName));
 
-				$nullable = (bool) ($joinColumn['nullable'] ?? true) || $this->isAggregated;
+				$nullable = (bool) ($joinColumn['nullable'] ?? true)
+					|| $this->hasAggregateWithoutGroupBy();
 
 				$fieldType = $this->resolveDatabaseInternalType($typeName, $nullable);
 
@@ -699,7 +707,7 @@ class QueryResultTypeWalker extends SqlWalker
 
 			$type = new ObjectType($class->name);
 
-			if ($this->isQueryComponentNullable($dqlAlias) || $this->isAggregated) {
+			if ($this->isQueryComponentNullable($dqlAlias) || $this->hasAggregateWithoutGroupBy()) {
 				$type = TypeCombinator::addNull($type);
 			}
 
@@ -725,7 +733,9 @@ class QueryResultTypeWalker extends SqlWalker
 
 			assert(is_string($typeName));
 
-			$nullable = $this->isQueryComponentNullable($dqlAlias) || $class->isNullable($fieldName) || $this->isAggregated;
+			$nullable = $this->isQueryComponentNullable($dqlAlias)
+				|| $class->isNullable($fieldName)
+				|| $this->hasAggregateWithoutGroupBy();
 
 			$type = $this->resolveDoctrineType($typeName, $nullable);
 
@@ -1288,12 +1298,22 @@ class QueryResultTypeWalker extends SqlWalker
 		});
 	}
 
-	private function isAggregated(AST\SelectStatement $AST): bool
+	/**
+	 * Returns whether the query has aggregate function and no group by clause
+	 *
+	 * Queries with aggregate functions and no group by clause always have
+	 * exactly 1 group. This implies that they return exactly 1 row, and that
+	 * all column can have a null value.
+	 *
+	 * c.f. SQL92, section 7.9, General Rules
+	 */
+	private function hasAggregateWithoutGroupBy(): bool
 	{
-		if ($AST->groupByClause !== null) {
-			return true;
-		}
+		return $this->hasAggregateFunction && !$this->hasGroupByClause;
+	}
 
+	private function hasAggregateFunction(AST\SelectStatement $AST): bool
+	{
 		foreach ($AST->selectClause->selectExpressions as $selectExpression) {
 			if (!$selectExpression instanceof AST\SelectExpression) {
 				continue;


Test patch:
diff --git a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
index d61023a..ed801ed 100644
--- a/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
+++ b/tests/Type/Doctrine/Query/QueryResultTypeWalkerTest.php
@@ -538,7 +538,7 @@ final class QueryResultTypeWalkerTest extends PHPStanTestCase
 				$this->constantArray([
 					[
 						new ConstantStringType('intColumn'),
-						TypeCombinator::addNull(new IntegerType()),
+						new IntegerType(),
 					],
 					[
 						new ConstantStringType('max'),
@@ -546,11 +546,11 @@ final class QueryResultTypeWalkerTest extends PHPStanTestCase
 					],
 					[
 						new ConstantStringType('arithmetic'),
-						TypeCombinator::addNull($this->intStringified()),
+						$this->intStringified(),
 					],
 					[
 						new ConstantStringType('coalesce'),
-						TypeCombinator::addNull($this->intStringified()),
+						$this->intStringified(),
 					],
 					[
 						new ConstantStringType('count'),