diff --git a/src/Pagination/AggregationCursorPaginator.php b/src/Pagination/AggregationCursorPaginator.php new file mode 100644 index 000000000..d9b5764eb --- /dev/null +++ b/src/Pagination/AggregationCursorPaginator.php @@ -0,0 +1,40 @@ + + */ +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 */ + 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(); + } +} diff --git a/src/Pagination/AggregationOffsetPaginator.php b/src/Pagination/AggregationOffsetPaginator.php new file mode 100644 index 000000000..6fc4bfb46 --- /dev/null +++ b/src/Pagination/AggregationOffsetPaginator.php @@ -0,0 +1,58 @@ + + */ +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) + ->count('numDocuments') + ->getAggregation() + ->getIterator(); + $numResults = iterator_to_array($results)[0]['numDocuments'] ?? 0; + + return (int) ceil($numResults / $this->perPage); + } + + /** @return Iterator */ + protected function getResultsForCurrentPage(): Iterator + { + $builder = clone $this->aggregation; + $builder + ->skip(($this->page - 1) * $this->perPage) + ->limit($this->perPage); + + return $builder->getAggregation()->getIterator(); + } +} diff --git a/src/Pagination/Paginator.php b/src/Pagination/Paginator.php new file mode 100644 index 000000000..fcb13289a --- /dev/null +++ b/src/Pagination/Paginator.php @@ -0,0 +1,24 @@ + + */ +abstract class Paginator implements IteratorAggregate +{ + /** @return Iterator */ + abstract protected function getResultsForCurrentPage(): Iterator; + + /** @return Iterator */ + final public function getIterator(): Iterator + { + return $this->getResultsForCurrentPage(); + } +} diff --git a/src/Pagination/QueryCursorPaginator.php b/src/Pagination/QueryCursorPaginator.php new file mode 100644 index 000000000..09350eef5 --- /dev/null +++ b/src/Pagination/QueryCursorPaginator.php @@ -0,0 +1,39 @@ + + */ +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 */ + 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(); + } +} diff --git a/src/Pagination/QueryOffsetPaginator.php b/src/Pagination/QueryOffsetPaginator.php new file mode 100644 index 000000000..6043cea4e --- /dev/null +++ b/src/Pagination/QueryOffsetPaginator.php @@ -0,0 +1,55 @@ + + */ +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); + } + + /** @return Iterator */ + protected function getResultsForCurrentPage(): Iterator + { + $builder = clone $this->query; + $builder + ->skip(($this->page - 1) * $this->perPage) + ->limit($this->perPage); + + return $builder->getQuery()->getIterator(); + } +} diff --git a/tests/Tests/Pagination/AggregationCursorPaginatorTest.php b/tests/Tests/Pagination/AggregationCursorPaginatorTest.php new file mode 100644 index 000000000..27173862c --- /dev/null +++ b/tests/Tests/Pagination/AggregationCursorPaginatorTest.php @@ -0,0 +1,104 @@ +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(); + $this->assertInstanceOf(Tag::class, $last); + + $results = iterator_to_array(new AggregationCursorPaginator($this->createBuilder(), $last->id)); + $this->assertCount(24, $results); + + $this->assertSame('49', $results[0]->name); + } + + public function testPartialPage(): void + { + $last = $this->createBuilder()->skip(95)->limit(24)->getAggregation()->getSingleResult(); + $this->assertInstanceOf(Tag::class, $last); + + $paginator = new AggregationCursorPaginator($this->createBuilder(), $last->id); + + $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; + } +} diff --git a/tests/Tests/Pagination/AggregationOffsetPaginatorTest.php b/tests/Tests/Pagination/AggregationOffsetPaginatorTest.php new file mode 100644 index 000000000..f46fed5d7 --- /dev/null +++ b/tests/Tests/Pagination/AggregationOffsetPaginatorTest.php @@ -0,0 +1,97 @@ +prepareData(); + } + + protected function tearDown(): void + { + $this->dm->getDocumentCollection(Tag::class)->drop(); + + parent::tearDown(); + } + + public function testFirstPage(): void + { + $paginator = new AggregationOffsetPaginator($this->createBuilder(), 1); + + $this->assertSame(5, count($paginator)); + + $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 + { + $this->assertSame(5, count(new AggregationOffsetPaginator($this->createBuilder(), 3))); + + $results = iterator_to_array(new AggregationOffsetPaginator($this->createBuilder(), 3)); + $this->assertCount(24, $results); + + $this->assertSame('49', $results[0]->name); + } + + public function testPartialPage(): void + { + $paginator = new AggregationOffsetPaginator($this->createBuilder(), 5); + + $this->assertSame(5, count($paginator)); + + $results = iterator_to_array($paginator); + $this->assertCount(3, $results); + + $this->assertSame('97', $results[0]->name); + } + + public function testDifferentPerPage(): void + { + $paginator = new AggregationOffsetPaginator($this->createBuilder(), 1, 12); + + $this->assertSame(9, count($paginator)); + + $results = iterator_to_array($paginator); + $this->assertCount(12, $results); + } + + 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; + } +} diff --git a/tests/Tests/Pagination/PaginatorTest.php b/tests/Tests/Pagination/PaginatorTest.php new file mode 100644 index 000000000..ba368db87 --- /dev/null +++ b/tests/Tests/Pagination/PaginatorTest.php @@ -0,0 +1,38 @@ +createMock(Paginator::class); + $paginator + ->expects($this->once()) + ->method('getResultsForCurrentPage') + ->willReturnCallback( + static fn (): Iterator => new UnrewindableIterator(self::createGenerator()()), + ); + + $this->assertSame(['1', '2', '3'], iterator_to_array($paginator->getIterator())); + } + + /** @param array $values */ + private static function createGenerator(array $values = ['1', '2', '3']): Closure + { + return static function () use ($values): Generator { + yield from $values; + }; + } +} diff --git a/tests/Tests/Pagination/QueryCursorPaginatorTest.php b/tests/Tests/Pagination/QueryCursorPaginatorTest.php new file mode 100644 index 000000000..5dbaddce8 --- /dev/null +++ b/tests/Tests/Pagination/QueryCursorPaginatorTest.php @@ -0,0 +1,103 @@ +prepareData(); + } + + protected function tearDown(): void + { + $this->dm->getDocumentCollection(Tag::class)->drop(); + + parent::tearDown(); + } + + public function testFirstPage(): void + { + $paginator = new QueryCursorPaginator($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)->getQuery()->getSingleResult(); + $this->assertInstanceOf(Tag::class, $last); + + $results = iterator_to_array(new QueryCursorPaginator($this->createBuilder(), $last->id)); + $this->assertCount(24, $results); + + $this->assertSame('49', $results[0]->name); + } + + public function testPartialPage(): void + { + $last = $this->createBuilder()->skip(95)->limit(24)->getQuery()->getSingleResult(); + $this->assertInstanceOf(Tag::class, $last); + + $paginator = new QueryCursorPaginator($this->createBuilder(), $last->id); + + $results = iterator_to_array($paginator); + $this->assertCount(3, $results); + + $this->assertSame('97', $results[0]->name); + } + + public function testDifferentPerPage(): void + { + $paginator = new QueryCursorPaginator($this->createBuilder(), perPage: 12); + + $results = iterator_to_array($paginator); + $this->assertCount(12, $results); + } + + public function testDifferentField(): void + { + $paginator = new QueryCursorPaginator($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->createQueryBuilder(Tag::class); + $builder + ->find() + ->field('name')->notEqual('2') + ->sort('id', 1); + + return $builder; + } +} diff --git a/tests/Tests/Pagination/QueryOffsetPaginatorTest.php b/tests/Tests/Pagination/QueryOffsetPaginatorTest.php new file mode 100644 index 000000000..645cc2e4f --- /dev/null +++ b/tests/Tests/Pagination/QueryOffsetPaginatorTest.php @@ -0,0 +1,96 @@ +prepareData(); + } + + protected function tearDown(): void + { + $this->dm->getDocumentCollection(Tag::class)->drop(); + + parent::tearDown(); + } + + public function testFirstPage(): void + { + $paginator = new QueryOffsetPaginator($this->createBuilder(), 1); + + $this->assertSame(5, count($paginator)); + + $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 + { + $this->assertSame(5, count(new QueryOffsetPaginator($this->createBuilder(), 3))); + + $results = iterator_to_array(new QueryOffsetPaginator($this->createBuilder(), 3)); + $this->assertCount(24, $results); + + $this->assertSame('49', $results[0]->name); + } + + public function testPartialPage(): void + { + $paginator = new QueryOffsetPaginator($this->createBuilder(), 5); + + $this->assertSame(5, count($paginator)); + + $results = iterator_to_array($paginator); + $this->assertCount(3, $results); + + $this->assertSame('97', $results[0]->name); + } + + public function testDifferentPerPage(): void + { + $paginator = new QueryOffsetPaginator($this->createBuilder(), 1, 12); + + $this->assertSame(9, count($paginator)); + + $results = iterator_to_array($paginator); + $this->assertCount(12, $results); + } + + 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->createQueryBuilder(Tag::class); + $builder + ->find() + ->field('name')->notEqual('2') + ->sort('id', 1); + + return $builder; + } +}