diff --git a/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtension.php b/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtension.php index 62b5acad..cdc5ef88 100644 --- a/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtension.php +++ b/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\IntegerType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function in_array; class EntityQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -66,6 +67,14 @@ public function getTypeFromMethodCall( ? IntegerRangeType::createAllGreaterThanOrEqualTo(0) : new EntityQueryExecuteWithoutAccessCheckCountType(); } + if ($varType->isAggregate()) { + // An aggregate query returns a list of result rows, each keyed + // by the aggregate results and the groupBy columns. + $rowType = new ArrayType(new StringType(), TypeCombinator::addNull(new StringType())); + return $varType->hasAccessCheck() + ? new ArrayType(new IntegerType(), $rowType) + : new EntityQueryExecuteWithoutAccessCheckType(new IntegerType(), $rowType); + } if ($varType instanceof ConfigEntityQueryType) { return $varType->hasAccessCheck() ? new ArrayType(new StringType(), new StringType()) diff --git a/src/Type/EntityQuery/EntityQueryType.php b/src/Type/EntityQuery/EntityQueryType.php index 9da78347..25aa1db5 100644 --- a/src/Type/EntityQuery/EntityQueryType.php +++ b/src/Type/EntityQuery/EntityQueryType.php @@ -13,6 +13,8 @@ class EntityQueryType extends ObjectType private bool $isCount = false; + private bool $isAggregate = false; + public function hasAccessCheck(): bool { return $this->hasAccessCheck; @@ -23,6 +25,11 @@ public function isCount(): bool return $this->isCount; } + public function isAggregate(): bool + { + return $this->isAggregate; + } + public function withAccessCheck(): self { // The constructor of ObjectType is under backward compatibility promise. @@ -35,6 +42,7 @@ public function withAccessCheck(): self ); $type->hasAccessCheck = true; $type->isCount = $this->isCount; + $type->isAggregate = $this->isAggregate; return $type; } @@ -48,6 +56,21 @@ public function asCount(): self ); $type->hasAccessCheck = $this->hasAccessCheck; $type->isCount = true; + $type->isAggregate = $this->isAggregate; + return $type; + } + + public function asAggregate(): self + { + // @phpstan-ignore new.static + $type = new static( + $this->getClassName(), + $this->getSubtractedType(), + $this->getClassReflection() + ); + $type->hasAccessCheck = $this->hasAccessCheck; + $type->isCount = $this->isCount; + $type->isAggregate = true; return $type; } @@ -55,7 +78,8 @@ protected function describeAdditionalCacheKey(): string { $parts = [ $this->hasAccessCheck ? 'with-access-check' : 'without-access-check', - $this->isCount ? 'count' : '' + $this->isCount ? 'count' : '', + $this->isAggregate ? 'aggregate' : '', ]; return implode('-', $parts); } diff --git a/src/Type/EntityStorage/GetQueryReturnTypeExtension.php b/src/Type/EntityStorage/GetQueryReturnTypeExtension.php index 62efb7d9..0b9de299 100644 --- a/src/Type/EntityStorage/GetQueryReturnTypeExtension.php +++ b/src/Type/EntityStorage/GetQueryReturnTypeExtension.php @@ -59,12 +59,19 @@ public function getTypeFromMethodCall( return $returnType; } + $isAggregate = $methodReflection->getName() === 'getAggregateQuery'; + if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) { - return new ContentEntityQueryType($className); + $queryType = new ContentEntityQueryType($className); + return $isAggregate ? $queryType->asAggregate() : $queryType; } if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) { + // Core throws QueryException for an aggregate query over config + // entities, so there is no aggregate query type to produce here. + // @see \Drupal\Core\Config\Entity\Query\QueryFactory::getAggregate() return new ConfigEntityQueryType($className); } - return new EntityQueryType($className); + $queryType = new EntityQueryType($className); + return $isAggregate ? $queryType->asAggregate() : $queryType; } } diff --git a/tests/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtensionTest.php b/tests/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtensionTest.php index 8d2086c1..9cff14bf 100644 --- a/tests/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtensionTest.php +++ b/tests/src/Type/EntityQuery/EntityQueryDynamicReturnTypeExtensionTest.php @@ -14,6 +14,7 @@ final class EntityQueryDynamicReturnTypeExtensionTest extends TypeInferenceTestC public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/../data/entity-query-execute.php'); + yield from self::gatherAssertTypes(__DIR__ . '/../data/aggregate-query-execute.php'); yield from self::gatherAssertTypes(__DIR__ . '/../data/bug-355-entity-query.php'); yield from self::gatherAssertTypes(__DIR__ . '/../data/bug-522.php'); yield from self::gatherAssertTypes(__DIR__ . '/../data/bug-909.php'); diff --git a/tests/src/Type/data/aggregate-query-execute.php b/tests/src/Type/data/aggregate-query-execute.php new file mode 100644 index 00000000..b8a008ed --- /dev/null +++ b/tests/src/Type/data/aggregate-query-execute.php @@ -0,0 +1,34 @@ +getStorage('node')->getAggregateQuery() + ->accessCheck(FALSE) + ->condition('status', TRUE) + ->groupBy('type') + ->execute(); +assertType('array>', $rows); + +// array_column() over those rows pulls a single groupBy column back out, which +// is the pattern used to collect the grouped IDs for a follow-up query. +$ids = array_column( + \Drupal::entityTypeManager()->getStorage('node')->getAggregateQuery() + ->accessCheck(FALSE) + ->condition('status', TRUE) + ->groupBy('type') + ->execute(), + 'type' +); +assertType('list', $ids); + +// Because the column can be present, the result is not statically empty, so a +// truthiness check on it is meaningful rather than "always false". +$filtered = $ids ? $ids : []; +assertType('list', $filtered);