Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
"jangregor/phpstan-prophecy": "^0.3",
"justinrainbow/json-schema": "^5.0",
"nelmio/api-doc-bundle": "^2.13.3",
"php-mock/php-mock-phpunit": "^2.0",
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0",
"phpdocumentor/type-resolver": "^0.3 || ^0.4",
"phpspec/prophecy": "^1.8",
Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ parameters:
path: %currentWorkingDirectory%/src/Test/DoctrineMongoDbOdmFilterTestCase.php
- '#Method ApiPlatform\\Core\\(Serializer\\Abstract|JsonApi\\Serializer\\)ItemNormalizer::normalizeRelation\(\) should return array\|string but returns array\|bool\|float\|int\|string\.#'
- '#Method ApiPlatform\\Core\\Util\\RequestParser::parseRequestParams\(\) should return array but returns array\|false\.#'
-
message: '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Util\\QueryBuilderHelper::mapJoinAliases() should return array<string, array<string>\|string> but returns array<int|string, mixed>\.#'
path: %currentWorkingDirectory%/src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php
- "#Call to method PHPUnit\\\\Framework\\\\Assert::assertSame\\(\\) with array\\('(collection_context|item_context|subresource_context)'\\) and array<Symfony\\\\Component\\\\VarDumper\\\\Cloner\\\\Data>\\|bool\\|float\\|int\\|string\\|null will always evaluate to false\\.#"
# https://github.com/symfony/symfony/pull/30535
-
Expand Down
66 changes: 42 additions & 24 deletions src/Bridge/Doctrine/Orm/Extension/PaginationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ public function getResult(QueryBuilder $queryBuilder, string $resourceClass = nu
$query->setHint(CountWalker::HINT_DISTINCT, false);
}

$doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->useFetchJoinCollection($queryBuilder, $resourceClass, $operationName));
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));
$doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->shouldDoctrinePaginatorFetchJoinCollection($queryBuilder, $resourceClass, $operationName));
$doctrineOrmPaginator->setUseOutputWalkers($this->shouldDoctrinePaginatorUseOutputWalkers($queryBuilder, $resourceClass, $operationName));

if (null === $this->requestStack) {
$isPartialEnabled = $this->pagination->isPartialEnabled($resourceClass, $operationName, $context);
Expand Down Expand Up @@ -276,64 +276,82 @@ private function getPaginationParameter(Request $request, string $parameterName,
}

/**
* Determines whether the Paginator should fetch join collections, if the root entity uses composite identifiers it should not.
*
* @see https://github.com/doctrine/doctrine2/issues/2910
* Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
*/
private function useFetchJoinCollection(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
{
if (null !== $resourceClass) {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

if (null !== $fetchJoinCollection = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_fetch_join_collection', null, true)) {
return $fetchJoinCollection;
}
}

/*
* "Cannot count query which selects two FROM components, cannot make distinction"
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
* @see https://github.com/doctrine/doctrine2/issues/2910
*/
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
return false;
}

if (null === $resourceClass) {
if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
return true;
}

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

return $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_fetch_join_collection', true, true);
// disable $fetchJoinCollection by default (performance)
return false;
}

/**
* Determines whether output walkers should be used.
* Determines whether the Doctrine ORM Paginator should use output walkers.
*/
private function useOutputWalkers(QueryBuilder $queryBuilder): bool
private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, string $resourceClass = null, string $operationName = null): bool
{
if (null !== $resourceClass) {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

if (null !== $useOutputWalkers = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_use_output_walkers', null, true)) {
return $useOutputWalkers;
}
}

/*
* "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L50
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
*/
if (QueryChecker::hasHavingClause($queryBuilder)) {
return true;
}

/*
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
* "Cannot count query which selects two FROM components, cannot make distinction"
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L87
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
*/
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
return true;
}

/*
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L149
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
*/
if (
QueryChecker::hasMaxResults($queryBuilder) &&
QueryChecker::hasOrderByOnToManyJoin($queryBuilder, $this->managerRegistry)
) {
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
return true;
}

/*
* When using composite identifiers pagination will need Output walkers
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
*/
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
return true;
}

Expand Down
149 changes: 147 additions & 2 deletions src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Util;

use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;

Expand All @@ -28,7 +29,7 @@ private function __construct()
}

/**
* Adds a join to the queryBuilder if none exists.
* Adds a join to the QueryBuilder if none exists.
*/
public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $association, string $joinType = null, string $conditionType = null, string $condition = null, string $originAlias = null): string
{
Expand All @@ -51,7 +52,113 @@ public static function addJoinOnce(QueryBuilder $queryBuilder, QueryNameGenerato
}

/**
* Get the existing join from queryBuilder DQL parts.
* Gets the entity class name by an alias used in the QueryBuilder.
*/
public static function getEntityClassByAlias(string $alias, QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): string
{
if (!\in_array($alias, $queryBuilder->getAllAliases(), true)) {
throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias));
}

$rootAliasMap = self::mapRootAliases($queryBuilder->getRootAliases(), $queryBuilder->getRootEntities());

if (isset($rootAliasMap[$alias])) {
return $rootAliasMap[$alias];
}

$metadata = null;

foreach (self::traverseJoins($alias, $queryBuilder, $managerRegistry) as [$currentMetadata]) {
$metadata = $currentMetadata;
}

if (null === $metadata) {
throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias));
}

return $metadata->getName();
}

/**
* Finds the root alias for an alias used in the QueryBuilder.
*/
public static function findRootAlias(string $alias, QueryBuilder $queryBuilder): string
{
if (\in_array($alias, $queryBuilder->getRootAliases(), true)) {
return $alias;
}

foreach ($queryBuilder->getDQLPart('join') as $rootAlias => $joins) {
foreach ($joins as $join) {
if ($alias === $join->getAlias()) {
return $rootAlias;
}
}
}

throw new \LogicException(sprintf('The alias "%s" does not exist in the QueryBuilder.', $alias));
}

/**
* Traverses through the joins for an alias used in the QueryBuilder.
*
* @return \Generator<string, array>
*/
public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, ManagerRegistry $managerRegistry): \Generator
{
$rootAliasMap = self::mapRootAliases($queryBuilder->getRootAliases(), $queryBuilder->getRootEntities());

$joinParts = $queryBuilder->getDQLPart('join');
$rootAlias = self::findRootAlias($alias, $queryBuilder);

$joinAliasMap = self::mapJoinAliases($joinParts[$rootAlias]);

$aliasMap = array_merge($rootAliasMap, $joinAliasMap);

$apexEntityClass = null;
$associationStack = [];
$aliasStack = [];
$currentAlias = $alias;

while (null === $apexEntityClass) {
if (!isset($aliasMap[$currentAlias])) {
throw new \LogicException(sprintf('Unknown alias "%s".', $currentAlias));
}

if (\is_string($aliasMap[$currentAlias])) {
$aliasStack[] = $currentAlias;
$apexEntityClass = $aliasMap[$currentAlias];
} else {
[$parentAlias, $association] = $aliasMap[$currentAlias];

$associationStack[] = $association;
$aliasStack[] = $currentAlias;
$currentAlias = $parentAlias;
}
}

$entityClass = $apexEntityClass;

while (null !== ($alias = array_pop($aliasStack))) {
$metadata = $managerRegistry
->getManagerForClass($entityClass)
->getClassMetadata($entityClass);

$association = array_pop($associationStack);

yield $alias => [
$metadata,
$association,
];

if (null !== $association) {
$entityClass = $metadata->getAssociationTargetClass($association);
}
}
}

/**
* Gets the existing join from QueryBuilder DQL parts.
*/
private static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, string $originAlias = null): ?Join
{
Expand All @@ -71,4 +178,42 @@ private static function getExistingJoin(QueryBuilder $queryBuilder, string $alia

return null;
}

/**
* Maps the root aliases to root entity classes.
*
* @return array<string, string>
*/
private static function mapRootAliases(array $rootAliases, array $rootEntities): array
{
$aliasMap = array_combine($rootAliases, $rootEntities);
if (false === $aliasMap) {
throw new \LogicException('Number of root aliases and root entities do not match.');
}

return $aliasMap;
}

/**
* Maps the join aliases to the parent alias and association, or the entity class.
*
* @return array<string, string[]|string>
*/
private static function mapJoinAliases(iterable $joins): array
{
$aliasMap = [];

foreach ($joins as $join) {
$alias = $join->getAlias();
$relationship = $join->getJoin();

if (false !== strpos($relationship, '.')) {
$aliasMap[$alias] = explode('.', $relationship);
} else {
$aliasMap[$alias] = $relationship;
}
}

return $aliasMap;
}
}
Loading