Skip to content

Commit 4bdf042

Browse files
authored
feat(mongodb): Add pagination metadata to the aggregation results (#6912)
1 parent 0860151 commit 4bdf042

File tree

5 files changed

+133
-209
lines changed

5 files changed

+133
-209
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@
124124
"doctrine/common": "^3.2.2",
125125
"doctrine/dbal": "^4.0",
126126
"doctrine/doctrine-bundle": "^2.11",
127-
"doctrine/mongodb-odm": "^2.6",
127+
"doctrine/mongodb-odm": "^2.9.2",
128128
"doctrine/mongodb-odm-bundle": "^4.0 || ^5.0",
129129
"doctrine/orm": "^2.17 || ^3.0",
130130
"elasticsearch/elasticsearch": "^8.4",

src/Doctrine/Odm/Extension/PaginationExtension.php

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,25 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC
6363
* @var DocumentRepository
6464
*/
6565
$repository = $manager->getRepository($resourceClass);
66-
$resultsAggregationBuilder = $repository->createAggregationBuilder()->skip($offset);
66+
67+
$facet = $aggregationBuilder->facet();
68+
$addFields = $aggregationBuilder->addFields();
69+
70+
// Get the results slice, from $offset to $offset + $limit
71+
// MongoDB does not support $limit: O, so we return an empty array directly
6772
if ($limit > 0) {
68-
$resultsAggregationBuilder->limit($limit);
73+
$facet->field('results')->pipeline($repository->createAggregationBuilder()->skip($offset)->limit($limit));
6974
} else {
70-
// Results have to be 0 but MongoDB does not support a limit equal to 0.
71-
$resultsAggregationBuilder->match()->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->equals(Paginator::LIMIT_ZERO_MARKER);
75+
$addFields->field('results')->literal([]);
7276
}
7377

74-
$aggregationBuilder
75-
->facet()
76-
->field('results')->pipeline(
77-
$resultsAggregationBuilder
78-
)
79-
->field('count')->pipeline(
80-
$repository->createAggregationBuilder()
81-
->count('count')
82-
);
78+
// Count the total number of items
79+
$facet->field('count')->pipeline($repository->createAggregationBuilder()->count('count'));
80+
81+
// Store pagination metadata, read by the Paginator
82+
// Using __ to avoid field names mapping
83+
$addFields->field('__api_first_result__')->literal($offset);
84+
$addFields->field('__api_max_results__')->literal($limit);
8385
}
8486

8587
/**
@@ -109,7 +111,7 @@ public function getResult(Builder $aggregationBuilder, string $resourceClass, ?O
109111
$attribute = $operation?->getExtraProperties()['doctrine_mongodb'] ?? [];
110112
$executeOptions = $attribute['execute_options'] ?? [];
111113

112-
return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline());
114+
return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass);
113115
}
114116

115117
private function addCountToContext(Builder $aggregationBuilder, array $context): array

src/Doctrine/Odm/Paginator.php

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Odm;
1515

16-
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
16+
use ApiPlatform\Metadata\Exception\RuntimeException;
1717
use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
1818
use ApiPlatform\State\Pagination\PaginatorInterface;
1919
use Doctrine\ODM\MongoDB\Iterator\Iterator;
@@ -27,29 +27,42 @@
2727
*/
2828
final class Paginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface
2929
{
30-
public const LIMIT_ZERO_MARKER_FIELD = '___';
31-
public const LIMIT_ZERO_MARKER = 'limit0';
32-
33-
private ?\ArrayIterator $iterator = null;
30+
private readonly \ArrayIterator $iterator;
3431

3532
private readonly int $firstResult;
3633

3734
private readonly int $maxResults;
3835

3936
private readonly int $totalItems;
4037

41-
public function __construct(private readonly Iterator $mongoDbOdmIterator, private readonly UnitOfWork $unitOfWork, private readonly string $resourceClass, private readonly array $pipeline)
38+
private readonly int $count;
39+
40+
public function __construct(Iterator $mongoDbOdmIterator, UnitOfWork $unitOfWork, string $resourceClass)
4241
{
43-
$resultsFacetInfo = $this->getFacetInfo('results');
44-
$this->getFacetInfo('count');
45-
46-
/*
47-
* Since the {@see \MongoDB\Driver\Cursor} class does not expose information about
48-
* skip/limit parameters of the query, the values set in the facet stage are used instead.
49-
*/
50-
$this->firstResult = $this->getStageInfo($resultsFacetInfo, '$skip');
51-
$this->maxResults = $this->hasLimitZeroStage($resultsFacetInfo) ? 0 : $this->getStageInfo($resultsFacetInfo, '$limit');
52-
$this->totalItems = $mongoDbOdmIterator->toArray()[0]['count'][0]['count'] ?? 0;
42+
$result = $mongoDbOdmIterator->toArray()[0];
43+
44+
if (array_diff_key(['results' => 1, 'count' => 1, '__api_first_result__' => 1, '__api_max_results__' => 1], $result)) {
45+
throw new RuntimeException('The result of the query must contain only "__api_first_result__", "__api_max_results__", "results" and "count" fields.');
46+
}
47+
48+
// The "count" facet contains the total number of documents,
49+
// it is not set when the query does not return any document
50+
$this->totalItems = $result['count'][0]['count'] ?? 0;
51+
52+
// The "results" facet contains the returned documents
53+
if ([] === $result['results']) {
54+
$this->count = 0;
55+
$this->iterator = new \ArrayIterator();
56+
} else {
57+
$this->count = \count($result['results']);
58+
$this->iterator = new \ArrayIterator(array_map(
59+
static fn ($result): object => $unitOfWork->getOrCreateDocument($resourceClass, $result),
60+
$result['results'],
61+
));
62+
}
63+
64+
$this->firstResult = $result['__api_first_result__'];
65+
$this->maxResults = $result['__api_max_results__'];
5366
}
5467

5568
/**
@@ -97,15 +110,15 @@ public function getTotalItems(): float
97110
*/
98111
public function getIterator(): \Traversable
99112
{
100-
return $this->iterator ?? $this->iterator = new \ArrayIterator(array_map(fn ($result): object => $this->unitOfWork->getOrCreateDocument($this->resourceClass, $result), $this->mongoDbOdmIterator->toArray()[0]['results']));
113+
return $this->iterator;
101114
}
102115

103116
/**
104117
* {@inheritdoc}
105118
*/
106119
public function count(): int
107120
{
108-
return is_countable($this->mongoDbOdmIterator->toArray()[0]['results']) ? \count($this->mongoDbOdmIterator->toArray()[0]['results']) : 0;
121+
return $this->count;
109122
}
110123

111124
/**
@@ -115,47 +128,4 @@ public function hasNextPage(): bool
115128
{
116129
return $this->getLastPage() > $this->getCurrentPage();
117130
}
118-
119-
/**
120-
* @throws InvalidArgumentException
121-
*/
122-
private function getFacetInfo(string $field): array
123-
{
124-
foreach ($this->pipeline as $indexStage => $infoStage) {
125-
if (\array_key_exists('$facet', $infoStage)) {
126-
if (!isset($this->pipeline[$indexStage]['$facet'][$field])) {
127-
throw new InvalidArgumentException("\"$field\" facet was not applied to the aggregation pipeline.");
128-
}
129-
130-
return $this->pipeline[$indexStage]['$facet'][$field];
131-
}
132-
}
133-
134-
throw new InvalidArgumentException('$facet stage was not applied to the aggregation pipeline.');
135-
}
136-
137-
/**
138-
* @throws InvalidArgumentException
139-
*/
140-
private function getStageInfo(array $resultsFacetInfo, string $stage): int
141-
{
142-
foreach ($resultsFacetInfo as $resultFacetInfo) {
143-
if (isset($resultFacetInfo[$stage])) {
144-
return $resultFacetInfo[$stage];
145-
}
146-
}
147-
148-
throw new InvalidArgumentException("$stage stage was not applied to the facet stage of the aggregation pipeline.");
149-
}
150-
151-
private function hasLimitZeroStage(array $resultsFacetInfo): bool
152-
{
153-
foreach ($resultsFacetInfo as $resultFacetInfo) {
154-
if (self::LIMIT_ZERO_MARKER === ($resultFacetInfo['$match'][self::LIMIT_ZERO_MARKER_FIELD] ?? null)) {
155-
return true;
156-
}
157-
}
158-
159-
return false;
160-
}
161131
}

src/Doctrine/Odm/Tests/Extension/PaginationExtensionTest.php

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
namespace ApiPlatform\Doctrine\Odm\Tests\Extension;
1515

1616
use ApiPlatform\Doctrine\Odm\Extension\PaginationExtension;
17-
use ApiPlatform\Doctrine\Odm\Paginator;
1817
use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmSetup;
1918
use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy;
2019
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
@@ -23,9 +22,9 @@
2322
use ApiPlatform\State\Pagination\PaginatorInterface;
2423
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
2524
use Doctrine\ODM\MongoDB\Aggregation\Builder;
25+
use Doctrine\ODM\MongoDB\Aggregation\Stage\AddFields;
2626
use Doctrine\ODM\MongoDB\Aggregation\Stage\Count;
2727
use Doctrine\ODM\MongoDB\Aggregation\Stage\Facet;
28-
use Doctrine\ODM\MongoDB\Aggregation\Stage\MatchStage as AggregationMatch;
2928
use Doctrine\ODM\MongoDB\Aggregation\Stage\Skip;
3029
use Doctrine\ODM\MongoDB\DocumentManager;
3130
use Doctrine\ODM\MongoDB\Iterator\Iterator;
@@ -42,6 +41,7 @@ class PaginationExtensionTest extends TestCase
4241
{
4342
use ProphecyTrait;
4443

44+
/** @var ObjectProphecy<ManagerRegistry> */
4545
private ObjectProphecy $managerRegistryProphecy;
4646

4747
/**
@@ -322,11 +322,14 @@ public function testGetResult(): void
322322
$iteratorProphecy = $this->prophesize(Iterator::class);
323323
$iteratorProphecy->toArray()->willReturn([
324324
[
325+
'results' => [],
325326
'count' => [
326327
[
327328
'count' => 9,
328329
],
329330
],
331+
'__api_first_result__' => 3,
332+
'__api_max_results__' => 6,
330333
],
331334
]);
332335

@@ -344,6 +347,12 @@ public function testGetResult(): void
344347
],
345348
],
346349
],
350+
[
351+
'$addFields' => [
352+
'__api_first_result__' => ['$literal' => 3],
353+
'__api_max_results__' => ['$literal' => 6],
354+
],
355+
],
347356
]);
348357

349358
$paginationExtension = new PaginationExtension(
@@ -370,11 +379,14 @@ public function testGetResultWithExecuteOptions(): void
370379
$iteratorProphecy = $this->prophesize(Iterator::class);
371380
$iteratorProphecy->toArray()->willReturn([
372381
[
382+
'results' => [],
373383
'count' => [
374384
[
375385
'count' => 9,
376386
],
377387
],
388+
'__api_first_result__' => 3,
389+
'__api_max_results__' => 6,
378390
],
379391
]);
380392

@@ -392,6 +404,12 @@ public function testGetResultWithExecuteOptions(): void
392404
],
393405
],
394406
],
407+
[
408+
'$addFields' => [
409+
'__api_first_result__' => ['$literal' => 3],
410+
'__api_max_results__' => ['$literal' => 6],
411+
],
412+
],
395413
]);
396414

397415
$paginationExtension = new PaginationExtension(
@@ -407,43 +425,52 @@ public function testGetResultWithExecuteOptions(): void
407425

408426
private function mockAggregationBuilder(int $expectedOffset, int $expectedLimit): ObjectProphecy
409427
{
410-
$skipProphecy = $this->prophesize(Skip::class);
411-
if ($expectedLimit > 0) {
412-
$skipProphecy->limit($expectedLimit)->shouldBeCalled();
413-
} else {
414-
$matchProphecy = $this->prophesize(AggregationMatch::class);
415-
$matchProphecy->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->shouldBeCalled()->willReturn($matchProphecy->reveal());
416-
$matchProphecy->equals(Paginator::LIMIT_ZERO_MARKER)->shouldBeCalled()->willReturn($matchProphecy->reveal());
417-
$skipProphecy->match()->shouldBeCalled()->willReturn($matchProphecy->reveal());
418-
}
419-
420-
$resultsAggregationBuilderProphecy = $this->prophesize(Builder::class);
421-
$resultsAggregationBuilderProphecy->skip($expectedOffset)->shouldBeCalled()->willReturn($skipProphecy->reveal());
422-
423428
$countProphecy = $this->prophesize(Count::class);
424-
425429
$countAggregationBuilderProphecy = $this->prophesize(Builder::class);
426430
$countAggregationBuilderProphecy->count('count')->shouldBeCalled()->willReturn($countProphecy->reveal());
427431

428432
$repositoryProphecy = $this->prophesize(DocumentRepository::class);
429-
$repositoryProphecy->createAggregationBuilder()->shouldBeCalled()->willReturn(
430-
$resultsAggregationBuilderProphecy->reveal(),
431-
$countAggregationBuilderProphecy->reveal()
432-
);
433433

434434
$objectManagerProphecy = $this->prophesize(DocumentManager::class);
435435
$objectManagerProphecy->getRepository('Foo')->shouldBeCalled()->willReturn($repositoryProphecy->reveal());
436436

437437
$this->managerRegistryProphecy->getManagerForClass('Foo')->shouldBeCalled()->willReturn($objectManagerProphecy->reveal());
438438

439439
$facetProphecy = $this->prophesize(Facet::class);
440-
$facetProphecy->pipeline($skipProphecy)->shouldBeCalled()->willReturn($facetProphecy);
441-
$facetProphecy->pipeline($countProphecy)->shouldBeCalled()->willReturn($facetProphecy);
442-
$facetProphecy->field('count')->shouldBeCalled()->willReturn($facetProphecy);
443-
$facetProphecy->field('results')->shouldBeCalled()->willReturn($facetProphecy);
440+
$addFieldsProphecy = $this->prophesize(AddFields::class);
441+
442+
if ($expectedLimit > 0) {
443+
$resultsAggregationBuilderProphecy = $this->prophesize(Builder::class);
444+
$repositoryProphecy->createAggregationBuilder()->shouldBeCalled()->willReturn(
445+
$resultsAggregationBuilderProphecy->reveal(),
446+
$countAggregationBuilderProphecy->reveal()
447+
);
448+
449+
$skipProphecy = $this->prophesize(Skip::class);
450+
$skipProphecy->limit($expectedLimit)->shouldBeCalled()->willReturn($skipProphecy->reveal());
451+
$resultsAggregationBuilderProphecy->skip($expectedOffset)->shouldBeCalled()->willReturn($skipProphecy->reveal());
452+
$facetProphecy->field('results')->shouldBeCalled()->willReturn($facetProphecy);
453+
$facetProphecy->pipeline($skipProphecy)->shouldBeCalled()->willReturn($facetProphecy->reveal());
454+
} else {
455+
$repositoryProphecy->createAggregationBuilder()->shouldBeCalled()->willReturn(
456+
$countAggregationBuilderProphecy->reveal()
457+
);
458+
459+
$addFieldsProphecy->field('results')->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
460+
$addFieldsProphecy->literal([])->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
461+
}
462+
463+
$facetProphecy->field('count')->shouldBeCalled()->willReturn($facetProphecy->reveal());
464+
$facetProphecy->pipeline($countProphecy)->shouldBeCalled()->willReturn($facetProphecy->reveal());
465+
466+
$addFieldsProphecy->field('__api_first_result__')->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
467+
$addFieldsProphecy->literal($expectedOffset)->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
468+
$addFieldsProphecy->field('__api_max_results__')->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
469+
$addFieldsProphecy->literal($expectedLimit)->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
444470

445471
$aggregationBuilderProphecy = $this->prophesize(Builder::class);
446472
$aggregationBuilderProphecy->facet()->shouldBeCalled()->willReturn($facetProphecy->reveal());
473+
$aggregationBuilderProphecy->addFields()->shouldBeCalled()->willReturn($addFieldsProphecy->reveal());
447474

448475
return $aggregationBuilderProphecy;
449476
}

0 commit comments

Comments
 (0)