Skip to content
Draft
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
40 changes: 40 additions & 0 deletions src/Pagination/AggregationCursorPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Pagination;

use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Iterator\Iterator;

/**
* @template-covariant TValue
* @template-extends Paginator<TValue>
*/
final class AggregationCursorPaginator extends Paginator
{
public function __construct(
private readonly Builder $aggregation,
public readonly mixed $after = null,
public readonly int $perPage = 24,
public readonly string $field = 'id',
) {
}

/** @return Iterator<TValue> */
protected function getResultsForCurrentPage(): Iterator
{
$builder = clone $this->aggregation;

if ($this->after) {
$builder
->match()
->field($this->field)->gt($this->after);
}

return $builder
->limit($this->perPage)
->getAggregation()
->getIterator();
}
}
58 changes: 58 additions & 0 deletions src/Pagination/AggregationOffsetPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Pagination;

use Countable;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Iterator\Iterator;

use function ceil;
use function iterator_to_array;

/**
* @template-covariant TValue
* @template-extends Paginator<TValue>
*/
final class AggregationOffsetPaginator extends Paginator implements Countable
{
private int $pageCount;

/** @psalm-param positive-int $page */
public function __construct(
private readonly Builder $aggregation,
public readonly int $page,
public readonly int $perPage = 24,
) {
}

public function count(): int
{
return $this->pageCount ??= $this->getNumberOfPages();
}

private function getNumberOfPages(): int
{
$builder = clone $this->aggregation;
$results = $builder
->hydrate(null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between hydrate(null) and hydrate(false) used in the query paginators?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type, probably nothing else. We should eventually have a string|false type to control this.

->count('numDocuments')
->getAggregation()
->getIterator();
$numResults = iterator_to_array($results)[0]['numDocuments'] ?? 0;

return (int) ceil($numResults / $this->perPage);
}

/** @return Iterator<TValue> */
protected function getResultsForCurrentPage(): Iterator
{
$builder = clone $this->aggregation;
$builder
->skip(($this->page - 1) * $this->perPage)
->limit($this->perPage);

return $builder->getAggregation()->getIterator();
}
}
24 changes: 24 additions & 0 deletions src/Pagination/Paginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Pagination;

use Doctrine\ODM\MongoDB\Iterator\Iterator;
use IteratorAggregate;

/**
* @template-covariant TValue
* @template-implements IteratorAggregate<mixed, TValue>
*/
abstract class Paginator implements IteratorAggregate
{
/** @return Iterator<TValue> */
abstract protected function getResultsForCurrentPage(): Iterator;

/** @return Iterator<TValue> */
final public function getIterator(): Iterator
{
return $this->getResultsForCurrentPage();
}
}
39 changes: 39 additions & 0 deletions src/Pagination/QueryCursorPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Pagination;

use Doctrine\ODM\MongoDB\Iterator\Iterator;
use Doctrine\ODM\MongoDB\Query\Builder;

/**
* @template-covariant TValue
* @template-extends Paginator<TValue>
*/
final class QueryCursorPaginator extends Paginator
{
public function __construct(
private readonly Builder $query,
public readonly mixed $after = null,
public readonly int $perPage = 24,
public readonly string $field = 'id',
) {
}

/** @return Iterator<TValue> */
protected function getResultsForCurrentPage(): Iterator
{
$builder = clone $this->query;

if ($this->after) {
$builder
->field($this->field)->gt($this->after);
}

return $builder
->limit($this->perPage)
->getQuery()
->getIterator();
}
}
55 changes: 55 additions & 0 deletions src/Pagination/QueryOffsetPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Pagination;

use Countable;
use Doctrine\ODM\MongoDB\Iterator\Iterator;
use Doctrine\ODM\MongoDB\Query\Builder;

use function ceil;

/**
* @template-covariant TValue
* @template-extends Paginator<TValue>
*/
final class QueryOffsetPaginator extends Paginator implements Countable
{
private int $pageCount;

/** @psalm-param positive-int $page */
public function __construct(
private readonly Builder $query,
public readonly int $page,
public readonly int $perPage = 24,
) {
}

public function count(): int
{
return $this->pageCount ??= $this->getNumberOfPages();
}

private function getNumberOfPages(): int
{
$builder = clone $this->query;

return (int) ceil($builder
->hydrate(false)
->count()
->getQuery()
->execute() / $this->perPage);
Comment on lines +38 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is immediately forgetting the exact number of pages. I think it would be better to make this value accessible with a method such as getNumberOfResults().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add something like this. Since we're discussing this, how do you feel about the paginator count being the number of pages? I felt that made more sense than returning the number of results (or worse, the number of items on the current page). If we don't want to change the Countable behaviour, I would use something like countResults (or similar) and use that to return the number of results, storing them in a private property as well.

}

/** @return Iterator<TValue> */
protected function getResultsForCurrentPage(): Iterator
{
$builder = clone $this->query;
$builder
->skip(($this->page - 1) * $this->perPage)
->limit($this->perPage);

return $builder->getQuery()->getIterator();
}
}
102 changes: 102 additions & 0 deletions tests/Tests/Pagination/AggregationCursorPaginatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\ODM\MongoDB\Pagination;

use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Pagination\AggregationCursorPaginator;
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
use Documents\Tag;

use function array_fill;
use function iterator_to_array;

class AggregationCursorPaginatorTest extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();

$this->prepareData();
}

protected function tearDown(): void
{
$this->dm->getDocumentCollection(Tag::class)->drop();

parent::tearDown();
}

public function testFirstPage(): void
{
$paginator = new AggregationCursorPaginator($this->createBuilder());

$results = iterator_to_array($paginator);
$this->assertCount(24, $results);

$this->assertSame('0', $results[0]->name);
$this->assertSame('3', $results[2]->name);
}

public function testThirdPage(): void
{
$last = $this->createBuilder()->skip(47)->limit(24)->getAggregation()->getSingleResult();

$results = iterator_to_array(new AggregationCursorPaginator($this->createBuilder(), $last->id));

Check failure on line 46 in tests/Tests/Pagination/AggregationCursorPaginatorTest.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.4)

Cannot access property $id on array<string, mixed>|object.
$this->assertCount(24, $results);

$this->assertSame('49', $results[0]->name);
}

public function testPartialPage(): void
{
$last = $this->createBuilder()->skip(95)->limit(24)->getAggregation()->getSingleResult();

$paginator = new AggregationCursorPaginator($this->createBuilder(), $last->id);

Check failure on line 56 in tests/Tests/Pagination/AggregationCursorPaginatorTest.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.4)

Cannot access property $id on array<string, mixed>|object.

$results = iterator_to_array($paginator);
$this->assertCount(3, $results);

$this->assertSame('97', $results[0]->name);
}

public function testDifferentPerPage(): void
{
$paginator = new AggregationCursorPaginator($this->createBuilder(), perPage: 12);

$results = iterator_to_array($paginator);
$this->assertCount(12, $results);
}

public function testDifferentField(): void
{
$paginator = new AggregationCursorPaginator($this->createBuilder(), after: '5', field: 'name');

$results = iterator_to_array($paginator);
$this->assertCount(24, $results);

$this->assertSame('6', $results[0]->name);
}

private function prepareData(): void
{
foreach (array_fill(0, 100, true) as $key => $t) {
$this->dm->persist(new Tag((string) $key));
}

$this->dm->flush();
}

private function createBuilder(): Builder
{
$builder = $this->dm->createAggregationBuilder(Tag::class);
$builder
->hydrate(Tag::class)
->match()
->field('name')->notEqual('2')
->sort('id', 1);

return $builder;
}
}
Loading
Loading