├── src
├── .gitkeep
├── Bundle
│ ├── KGPagerBundle.php
│ ├── Doctrine
│ │ ├── PagerAwareInterface.php
│ │ └── PagerAwareRepositoryFactory.php
│ ├── Resources
│ │ └── config
│ │ │ └── services.xml
│ ├── EventListener
│ │ └── OutOfBoundsRedirector.php
│ └── DependencyInjection
│ │ ├── Configuration.php
│ │ └── KGPagerExtension.php
├── Adapter
│ ├── MongoAdapter.php
│ ├── ArrayAdapter.php
│ ├── ElasticaAdapter.php
│ ├── CallbackDecorator.php
│ ├── DqlByHandAdapter.php
│ ├── DqlAdapter.php
│ └── CachedDecorator.php
├── Twig
│ └── Extension.php
├── PagerInterface.php
├── PagingStrategyInterface.php
├── PagingStrategy
│ ├── EquallyPaged.php
│ └── LastPageMerged.php
├── AdapterInterface.php
├── Pager.php
├── Exception
│ └── OutOfBoundsException.php
├── BoundsCheckDecorator.php
├── RequestDecorator.php
├── Adapter.php
├── PageInterface.php
└── Page.php
├── .gitignore
├── .scrutinizer.yml
├── doc
├── class_diagram.png
└── index.md
├── tests
├── bootstrap.php
├── Adapter
│ ├── ArrayAdapterTest.php
│ ├── MongoAdapterTest.php
│ ├── ElasticaAdapterTest.php
│ ├── DqlAdapterTest.php
│ ├── CallbackDecoratorTest.php
│ ├── DqlByHandAdapterTest.php
│ └── CachedDecoratorTest.php
├── Twig
│ └── ExtensionTest.php
├── PagerTest.php
├── Exception
│ └── OutOfBoundsExceptionTest.php
├── PagingStrategy
│ ├── EquallyPagedTest.php
│ └── LastPageMergedTest.php
├── BoundsCheckDecoratorTest.php
├── Bundle
│ ├── Doctrine
│ │ └── PagerAwareRepositoryFactoryTest.php
│ ├── EventListener
│ │ └── OutOfBoundsRedirectorTest.php
│ └── DependencyInjection
│ │ └── KGPagerExtensionTest.php
├── AdapterTest.php
├── RequestDecoratorTest.php
└── PageTest.php
├── .travis.yml
├── phpunit.xml.dist
├── LICENSE
├── composer.json
├── CHANGELOG.md
└── README.md
/src/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /composer.lock
2 | /vendor/
3 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | # .scrutinizer.yml
2 | tools:
3 | external_code_coverage: true
4 |
5 |
--------------------------------------------------------------------------------
/doc/class_diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kgilden/pager/HEAD/doc/class_diagram.png
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | addPsr4('KG\\Pager\\Tests\\', __DIR__);
16 |
--------------------------------------------------------------------------------
/src/Bundle/KGPagerBundle.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 | ./tests/
16 |
17 |
18 |
19 |
20 | ./src/
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/Adapter/MongoAdapter.php:
--------------------------------------------------------------------------------
1 | cursor = $cursor;
14 | }
15 |
16 | /**
17 | * {@inheritDoc}
18 | *
19 | * @api
20 | */
21 | public function getItemCount(): int
22 | {
23 | return $this->cursor->count();
24 | }
25 |
26 | /**
27 | * {@inheritDoc}
28 | *
29 | * @api
30 | */
31 | public function getItems(int $offset, int $limit): array
32 | {
33 | $this->cursor->skip($offset);
34 | $this->cursor->limit($limit);
35 |
36 | return iterator_to_array($this->cursor);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/Adapter/ArrayAdapterTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(5, $adapter->getItemCount());
25 | }
26 |
27 | public function testGetItems(): void
28 | {
29 | $adapter = new ArrayAdapter(array('foo', 'bar', 'baz', 'qux'));
30 | $this->assertEquals(array('bar', 'baz'), $adapter->getItems(1, 2));
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/tests/Twig/ExtensionTest.php:
--------------------------------------------------------------------------------
1 | paged(['a', 'b', 'c']);
21 |
22 | $this->assertInstanceOf(PageInterface::class, $page);
23 | }
24 |
25 | public function testCompile(): void
26 | {
27 | $twig = << $twig,
34 | ]));
35 | $env->addExtension(new Extension(new Pager()));
36 |
37 | $this->assertEquals('3,4,', $env->render('index'));
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/PagerTest.php:
--------------------------------------------------------------------------------
1 | createMock(AdapterInterface::class);
26 | $pager = new Pager();
27 | $this->assertInstanceOf(PageInterface::class, $pager->paginate($adapter));
28 | }
29 |
30 | public function testPagerGetsFirstPageByDefault(): void
31 | {
32 | $pager = new Pager();
33 | $page = $pager->paginate($this->createMock(AdapterInterface::class));
34 |
35 | $this->assertTrue($page->isFirst());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Twig/Extension.php:
--------------------------------------------------------------------------------
1 | pager = $pager;
28 | }
29 |
30 | public function getFunctions(): array
31 | {
32 | return array(
33 | new TwigFunction('paged', array($this, 'paged')),
34 | );
35 | }
36 |
37 | public function paged(array $items, int $itemsPerPage = null, int $page = null)
38 | {
39 | return $this->pager->paginate(Adapter::_array($items), $itemsPerPage, $page);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 Kristen Gilden
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is furnished
8 | to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/PagerInterface.php:
--------------------------------------------------------------------------------
1 | items = $items;
34 | }
35 |
36 | /**
37 | * {@inheritDoc}
38 | *
39 | * @api
40 | */
41 | public function getItemCount(): int
42 | {
43 | return count($this->items);
44 | }
45 |
46 | /**
47 | * {@inheritDoc}
48 | *
49 | * @api
50 | */
51 | public function getItems(int $offset, int $limit): array
52 | {
53 | return array_slice($this->items, $offset, $limit);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/PagingStrategyInterface.php:
--------------------------------------------------------------------------------
1 | getItemCount() / $perPage);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/AdapterInterface.php:
--------------------------------------------------------------------------------
1 | perPage = $perPage ?: 25;
31 | $this->strategy = $strategy ?: new EquallyPaged();
32 | }
33 |
34 | /**
35 | * {@inheritDoc}
36 | */
37 | public function paginate(AdapterInterface $adapter, ?int $itemsPerPage = null, ?int $page = null): PageInterface
38 | {
39 | return new Page(new CachedDecorator($adapter), $this->strategy, $itemsPerPage ?: $this->perPage, $page ?: 1);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Exception/OutOfBoundsException.php:
--------------------------------------------------------------------------------
1 | pageNumber = $pageNumber;
25 | $this->pageCount = $pageCount;
26 | $this->redirectKey = $redirectKey;
27 |
28 | $message = $message ?: sprintf('The current page (%d) is out of the paginated page range (%d).', $pageNumber, $pageCount);
29 |
30 | parent::__construct($message);
31 | }
32 |
33 | public function getPageNumber(): int
34 | {
35 | return $this->pageNumber;
36 | }
37 |
38 | public function getPageCount(): int
39 | {
40 | return $this->pageCount;
41 | }
42 |
43 | public function getRedirectKey(): string
44 | {
45 | return $this->redirectKey;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/BoundsCheckDecorator.php:
--------------------------------------------------------------------------------
1 | pager = $pager;
31 | $this->redirectKey = $redirectKey;
32 | }
33 |
34 | /**
35 | * {@inheritDoc}
36 | */
37 | public function paginate(AdapterInterface $adapter, ?int $itemsPerPage = null, ?int $page = null): PageInterface
38 | {
39 | $page = $this->pager->paginate($adapter, $itemsPerPage, $page);
40 |
41 | if ($page->isOutOfBounds()) {
42 | throw new OutOfBoundsException($page->getNumber(), $page->getPageCount(), $this->redirectKey);
43 | }
44 |
45 | return $page;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/tests/Exception/OutOfBoundsExceptionTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(5, $e->getPageNumber());
16 | }
17 |
18 | public function testGetPageCount(): void
19 | {
20 | $e = new OutOfBoundsException(5, 10);
21 | $this->assertEquals(10, $e->getPageCount());
22 | }
23 |
24 | public function testDefaultMessageSetIfNoMessage(): void
25 | {
26 | $e = new OutOfBoundsException(5, 10);
27 | $this->assertEquals('The current page (5) is out of the paginated page range (10).', $e->getMessage());
28 | }
29 |
30 | public function testRedirectKeySetByDefault(): void
31 | {
32 | $e = new OutOfBoundsException(5, 10);
33 | $this->assertEquals('page', $e->getRedirectKey());
34 | }
35 |
36 | public function testCustomRedirectKeyCanBeUsed(): void
37 | {
38 | $e = new OutOfBoundsException(5, 10, 'foo');
39 | $this->assertEquals('foo', $e->getRedirectKey());
40 | }
41 |
42 | public function testCustomMessageOverridesDefault(): void
43 | {
44 | $e = new OutOfBoundsException(5, 10, '', 'foo');
45 | $this->assertEquals('foo', $e->getMessage());
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/RequestDecorator.php:
--------------------------------------------------------------------------------
1 | pager = $pager;
31 | $this->stack = $stack;
32 | $this->key = $key;
33 | }
34 |
35 | /**
36 | * {@inheritDoc}
37 | */
38 | public function paginate(AdapterInterface $adapter, ?int $itemsPerPage = null, ?int $page = null): PageInterface
39 | {
40 | return $this->pager->paginate($adapter, $itemsPerPage, $page ?: $this->getCurrentPage());
41 | }
42 |
43 | private function getCurrentPage(): ?int
44 | {
45 | if ($request = $this->stack->getCurrentRequest()) {
46 | return $request->query->getInt($this->key);
47 | }
48 |
49 | return null;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Bundle/Doctrine/PagerAwareRepositoryFactory.php:
--------------------------------------------------------------------------------
1 | factory = $factory;
33 | $this->pager = $pager;
34 | }
35 |
36 | /**
37 | * {@inheritdoc}
38 | */
39 | public function getRepository(EntityManagerInterface $entityManager, $entityName)
40 | {
41 | if (!$this->factory) {
42 | $this->factory = new DefaultRepositoryFactory();
43 | }
44 |
45 | $repository = $this->factory->getRepository($entityManager, $entityName);
46 |
47 | if ($repository instanceof PagerAwareInterface) {
48 | $repository->setPager($this->pager);
49 | }
50 |
51 | return $repository;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kgilden/pager",
3 | "description": "A paginator library to split results into multiple pages",
4 | "keywords": ["pager", "paginator", "pagination", "library"],
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Kristen Gilden",
9 | "email": "kristen.gilden@gmail.com"
10 | }
11 | ],
12 | "minimum-stability": "stable",
13 | "conflict": {
14 | "doctrine/orm": "<2.3",
15 | "symfony/http-foundation": "<4.4|<5.4,>=5",
16 | "twig/twig": "<2.7|>=4",
17 | "ruflin/elastica": "<0.90.1.0"
18 | },
19 | "require": {
20 | "php": "^7.4|^8.0"
21 | },
22 | "require-dev": {
23 | "doctrine/orm": "^2.4",
24 | "symfony/http-foundation": "^4.4|^5.4",
25 | "symfony/config": "^4.4|^5.4",
26 | "symfony/dependency-injection": "^4.4|^5.4",
27 | "symfony/http-kernel": "^4.4|^5.4",
28 | "ruflin/elastica": ">=0.90.1.0",
29 | "phpunit/phpunit": "^8.2.3",
30 | "twig/twig": "^2.7|^3",
31 | "symfony/yaml": "^5.1"
32 | },
33 | "autoload": {
34 | "psr-4": {"KG\\Pager\\": "src/"}
35 | },
36 | "suggest": {
37 | "doctrine/orm": "^2.4, if you're using DqlAdapter",
38 | "symfony/http-foundation": "^4.4|^5.4 for automatic current page detection",
39 | "ruflin/elastica": ">=0.90.1.0 to use ElasticaAdapter",
40 | "twig/twig": "^2.7|^3 to use the Twig Extension",
41 | "ext-mongo": "to use MongoAdapter"
42 | },
43 | "extra": {
44 | "branch-alias": {
45 | "dev-master": "2.0-dev"
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Adapter/ElasticaAdapter.php:
--------------------------------------------------------------------------------
1 | addIndex('foo')
25 | * ->addType('bar')
26 | * ->setQuery($query)
27 | * ;
28 | *
29 | * $page = $pager->paginate(new ElasticaAdapter($search));
30 | *
31 | * @api
32 | */
33 | class ElasticaAdapter implements AdapterInterface
34 | {
35 | private Search $search;
36 |
37 | /**
38 | * @api
39 | */
40 | public function __construct(Search $search)
41 | {
42 | $this->search = clone $search;
43 | $this->search->setQuery(clone $search->getQuery());
44 | }
45 |
46 | /**
47 | * {@inheritDoc}
48 | *
49 | * @api
50 | */
51 | public function getItemCount(): int
52 | {
53 | return $this->search->count();
54 | }
55 |
56 | /**
57 | * {@inheritDoc}
58 | *
59 | * @api
60 | */
61 | public function getItems(int $offset, int $limit): array
62 | {
63 | $query = $this->search->getQuery();
64 | $query->setFrom($offset);
65 | $query->setSize($limit);
66 |
67 | return $this->search->search()->getResults();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/PagingStrategy/EquallyPagedTest.php:
--------------------------------------------------------------------------------
1 | createMock(AdapterInterface::class);
28 | $adapter->method('getItemCount')->willReturn($itemCount);
29 |
30 | $strategy = new EquallyPaged();
31 | $this->assertEquals($expectedCount, $strategy->getCount($adapter, 1, $perPage));
32 | }
33 |
34 | public function getTestDataForCount(): array
35 | {
36 | return [
37 | [2, 2, 1],
38 | [0, 2, 0],
39 | [3, 2, 2],
40 | [4, 2, 2],
41 | [5, 1, 5],
42 | ];
43 | }
44 |
45 | /**
46 | * @dataProvider getTestDataForLimit
47 | */
48 | public function testGetLimit(int $perPage, int $page, array $expectedLimit): void
49 | {
50 | $strategy = new EquallyPaged();
51 | $this->assertEquals($expectedLimit, $strategy->getLimit($this->createMock(AdapterInterface::class), $page, $perPage));
52 | }
53 |
54 | public function getTestDataForLimit()
55 | {
56 | return [
57 | [5, 1, [0, 5]],
58 | [3, 2, [3, 3]],
59 | ];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/tests/BoundsCheckDecoratorTest.php:
--------------------------------------------------------------------------------
1 | expectException(OutOfBoundsException::class);
28 | $this->expectExceptionMessage('The current page (69) is out of the paginated page range (42)');
29 |
30 | $page = $this->createMock(PageInterface::class);
31 | $page->method('isOutOfBounds')->willReturn(true);
32 | $page->method('getNumber')->willReturn(69);
33 | $page->method('getPageCount')->willReturn(42);
34 |
35 | $pager = $this->createMock(PagerInterface::class);
36 | $pager->method('paginate')->willReturn($page);
37 |
38 | $pager = new BoundsCheckDecorator($pager);
39 | $pager->paginate($this->createMock(AdapterInterface::class));
40 | }
41 |
42 | public function testPaginateReturnsPage(): void
43 | {
44 | $page = $this->createMock(PageInterface::class);
45 | $page->method('isOutOfBounds')->willReturn(false);
46 |
47 | $pager = $this->createMock(PagerInterface::class);
48 | $pager->method('paginate')->willReturn($page);
49 |
50 | $pager = new BoundsCheckDecorator($pager);
51 | $this->assertSame($page, $pager->paginate($this->createMock(AdapterInterface::class)));
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Adapter/CallbackDecorator.php:
--------------------------------------------------------------------------------
1 | callbacks;
31 | $callbacks[] = $callback;
32 | $adapter = $adapter->adapter;
33 | } else {
34 | $callbacks = [$callback];
35 | }
36 |
37 | $this->adapter = $adapter;
38 | $this->callbacks = $callbacks;
39 | }
40 |
41 | /**
42 | * {@inheritDoc}
43 | */
44 | public function getItemCount(): int
45 | {
46 | return $this->adapter->getItemCount();
47 | }
48 |
49 | /**
50 | * {@inheritDoc}
51 | */
52 | public function getItems(int $offset, int $limit): array
53 | {
54 | $items = $this->adapter->getItems($offset, $limit);
55 |
56 | $oldCount = count($items);
57 |
58 | foreach ($this->callbacks as $callback) {
59 | $items = call_user_func($callback, $items);
60 | }
61 |
62 | $newCount = count($items);
63 |
64 | if ($oldCount !== $newCount) {
65 | throw new \LogicException(sprintf('Callbacks may not change the number of items (old count: %d, new count: %d).', $oldCount, $newCount));
66 | }
67 |
68 | return $items;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | ### 2.0.0 (2022-04-05)
5 |
6 | * Bump minimum required PHP version to 7.4.
7 | * The library now enforces PHP7 types. Code not respecting previous docblock
8 | type hints might break.
9 | * Support symfony ^4.4 or ^5.5.
10 |
11 | ### 1.2.1 (2020-02-21)
12 |
13 | * Added Twig extension.
14 | * Dropped support for PHP v5.3 & HHVM.
15 |
16 | ### 1.2.0 (2016-10-01)
17 |
18 | * Merged `kgilden/pager-bundle` to this package
19 | * Made `Page::isOutOfBounds` not cause counting rows
20 |
21 | ### 1.1.1 (2016-03-12)
22 |
23 | * Added build matrix to test for highest and lowest dependencies
24 | * Support for Symfony v3.x
25 |
26 | ### 1.1.0 (2015-12-22)
27 |
28 | * Added `Page::getItemsOfAllPages()` to fetch all items from all pages
29 |
30 | ### 1.0.0 (2015-05-09)
31 |
32 | * Minor refactorings
33 | * New `MongoAdapter` to page mongoDB cursors
34 |
35 | ### 1.0.0-beta1 (2015-03-22)
36 |
37 | * New generic Adapter to create any adapter
38 | * New `ElasticaAdapter` to page ElasticSearch queries
39 | * New `DqlByHandAdapter` to page DQL queries and provide a manually created count
40 | query;
41 | * Added `getNext` and `getPrevious` methods to get next and previous pages
42 | * Always have at least 1 page
43 | * Made items per page configurable via `Pager::__construct`
44 | * Added bounds checking (i.e. seeing whether a page is out of bounds)
45 | * Fixed arguments of `Page` passed flipped when adding a callback
46 | * `CallbackDecorator` fails, if the item count differs after the callback
47 | * Fixed `CachedDecorator` not returning items, if they equalled `null`
48 | * Fixed `CachedDecorator` asking for items again if none found at first
49 | * Marked the public API with `@api` annotations
50 |
51 | ### 0.2.0 (2015-03-01)
52 |
53 | * Added decorator to infer current page straight from Request
54 | * Added new "last merged" paging strategy
55 | * Adapters return arrays of items instead of iterators
56 | * Got rid of Paged object
57 | * Made items per page configurable via Pager::paginate
58 |
59 | ### 0.1.0 (2015-02-25)
60 |
61 | * Initial prototype
62 |
63 |
--------------------------------------------------------------------------------
/src/Bundle/Resources/config/services.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 | KG\Pager\Pager
10 | KG\Pager\RequestDecorator
11 | KG\Pager\BoundsCheckDecorator
12 | KG\Pager\PagingStrategy\LastPageMerged
13 | KG\Pager\Bundle\EventListener\OutOfBoundsRedirector
14 | KG\Pager\Bundle\Doctrine\PagerAwareRepositoryFactory
15 | KG\Pager\Twig\Extension
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
31 |
32 |
33 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/Adapter.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('ext/mongodb must be installed to run this test');
17 | }
18 | }
19 |
20 | public function testGetItemCountDelegatesToCount(): void
21 | {
22 | $cursor = $this->createMock(Cursor::class);
23 |
24 | $cursor
25 | ->expects($this->once())
26 | ->method('count')
27 | ->willReturn(5)
28 | ;
29 |
30 | $adapter = new MongoAdapter($cursor);
31 | $this->assertEquals(5, $adapter->getItemCount());
32 | }
33 |
34 | public function testGetItemsDelegatesToCursor(): void
35 | {
36 | $cursor = $this->createMock(Cursor::class);
37 |
38 | $cursor
39 | ->expects($this->once())
40 | ->method('skip')
41 | ->with($this->equalTo(10))
42 | ;
43 |
44 | $cursor
45 | ->expects($this->once())
46 | ->method('limit')
47 | ->with($this->equalTo(2))
48 | ;
49 |
50 | $this->mockIterator($cursor, $expected = ['foo', 'bar']);
51 |
52 | $adapter = new MongoAdapter($cursor);
53 | $this->assertEquals($expected, $adapter->getItems(10, 2));
54 | }
55 |
56 | private function mockIterator($mock, $values): void
57 | {
58 | $iterator = new \ArrayIterator($values);
59 |
60 | $methodsToMock = [
61 | 'current',
62 | 'key',
63 | 'next',
64 | 'rewind',
65 | 'valid',
66 | ];
67 |
68 | foreach ($methodsToMock as $methodToMock) {
69 | $mock
70 | ->method($methodToMock)
71 | ->will($this->returnCallback(function () use ($iterator, $methodToMock) {
72 | return call_user_func_array([$iterator, $methodToMock], func_get_args());
73 | }))
74 | ;
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Pager
2 | =====
3 |
4 | [](https://travis-ci.org/kgilden/pager)
5 | [](https://scrutinizer-ci.com/g/kgilden/pager/)
6 | [](https://packagist.org/packages/kgilden/pager)
7 |
8 | Pager is a library to split results to multiple pages - any way you want them!
9 |
10 | Features
11 | --------
12 |
13 | * 5 built-in adapters for arrays, Doctrine ORM, ElasticSearch & MongoDB;
14 | * safe subset of methods to not even count items;
15 | * strategies to split pages inequally (i.e. 2 last pages merged);
16 | * integrates nicely with Symfony's `HttpKernel` to infer the current page;
17 | * provides a bundle to seamlessly integrate with Symfony;
18 |
19 | Documentation
20 | -------------
21 |
22 | [Read the documentation](doc/index.md)
23 |
24 | Usage
25 | -----
26 |
27 | Two objects work together to split a set of items to pages: pager and adapter.
28 | Pagers act as factories for pages. Adapters allow concrete item sets to be
29 | paged (for example there's an adapter for Doctrine queries).
30 |
31 | Here's an example with arrays (check out [the docs](doc/index.md) for more):
32 |
33 | ```php
34 | paginate(new ArrayAdapter($list), $itemsPerPage, $currentPage);
45 |
46 | $page->isFirst(); // false
47 | $page->isLast(); // true - there's a total of 3 pages
48 | $page->getNumber(); // 3 - it's $currentPage
49 |
50 | count($page->getItems()); // 1
51 | $page->getItems(); // ["eggplant"]
52 |
53 | ?>
54 | ```
55 |
56 | Installation
57 | ------------
58 |
59 | Install using [composer](https://getcomposer.org/download/): `composer.phar require kgilden/pager`
60 |
61 | Testing
62 | -------
63 |
64 | Simply run `phpunit` in the root directory of the library for the full
65 | test suite.
66 |
67 | License
68 | -------
69 |
70 | This library is under the [MIT license](LICENSE).
71 |
--------------------------------------------------------------------------------
/src/Bundle/EventListener/OutOfBoundsRedirector.php:
--------------------------------------------------------------------------------
1 | getThrowable();
31 |
32 | if (!$exception instanceof OutOfBoundsException) {
33 | return;
34 | }
35 |
36 | $pageNumber = $exception->getPageNumber();
37 | $pageCount = $exception->getPageCount();
38 |
39 | if ($pageCount < 1) {
40 | return; // No pages...so let the exception fall through.
41 | }
42 |
43 | $queryBag = clone $event->getRequest()->query;
44 |
45 | if ($pageNumber > $pageCount) {
46 | $queryBag->set($exception->getRedirectKey(), $pageCount);
47 | } elseif ($pageNumber < 1) {
48 | $queryBag->set($exception->getRedirectKey(), 1);
49 | } else {
50 | return; // Super weird, because current page is within the bounds, fall through.
51 | }
52 |
53 | if (null !== $qs = http_build_query($queryBag->all(), '', '&')) {
54 | $qs = '?'.$qs;
55 | }
56 |
57 | // Create identical uri except for the page key in the query string which
58 | // was changed by this listener.
59 | //
60 | // @see Symfony\Component\HttpFoundation\Request::getUri()
61 | $request = $event->getRequest();
62 | $uri = $request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs;
63 |
64 | $event->setResponse(new RedirectResponse($uri));
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Adapter/DqlByHandAdapter.php:
--------------------------------------------------------------------------------
1 | createQuery('SELECT COUNT(e.id) FROM Entity e');
28 | * $limitQuery = $em->createQuery('SELECT e, f FROM Entity e JOIN e.foo f');
29 | *
30 | * $page = $pager->paginate(new DqlByHandAdapter($limitQuery, $countQuery));
31 | *
32 | * @api
33 | */
34 | final class DqlByHandAdapter implements AdapterInterface
35 | {
36 | private AbstractQuery $countQuery;
37 | private AdapterInterface $adapter;
38 |
39 | /**
40 | * @param Query|QueryBuilder $limitQuery
41 | * @param Query|QueryBuilder $countQuery
42 | * @param bool $fetchJoinCollection Whether the query joins a collection (true by default)
43 | *
44 | * @api
45 | */
46 | public function __construct($limitQuery, $countQuery, bool $fetchJoinCollection = true)
47 | {
48 | if ($countQuery instanceof QueryBuilder) {
49 | $countQuery = $countQuery->getQuery();
50 | }
51 |
52 | $this->countQuery = $countQuery;
53 | $this->adapter = DqlAdapter::fromQuery($limitQuery, $fetchJoinCollection);
54 | }
55 |
56 | /**
57 | * {@inheritDoc}
58 | *
59 | * @api
60 | */
61 | public function getItemCount(): int
62 | {
63 | try {
64 | return array_sum(array_map('current', $this->countQuery->getScalarResult()));
65 | } catch (NoResultException $e) {
66 | return 0;
67 | }
68 | }
69 |
70 | /**
71 | * {@inheritDoc}
72 | *
73 | * @api
74 | */
75 | public function getItems(int $offset, int $limit): array
76 | {
77 | return $this->adapter->getItems($offset, $limit);
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/tests/Bundle/Doctrine/PagerAwareRepositoryFactoryTest.php:
--------------------------------------------------------------------------------
1 | createMock(PagerInterface::class);
29 |
30 | $em = $this->createMock(EntityManagerInterface::class);
31 |
32 | $repository = $this->createMock(PagerAwareInterface::class);
33 | $repository
34 | ->expects($this->once())
35 | ->method('setPager')
36 | ->with($this->identicalTo($pager))
37 | ;
38 |
39 | $parent = $this->createMock(RepositoryFactory::class);
40 | $parent
41 | ->expects($this->once())
42 | ->method('getRepository')
43 | ->with($this->identicalTo($em), 'foo')
44 | ->willReturn($repository)
45 | ;
46 |
47 | $factory = new PagerAwareRepositoryFactory($pager, $parent);
48 | $factory->getRepository($em, 'foo');
49 | }
50 |
51 | public function testPagerNotSetToNativeRepositories()
52 | {
53 | $createMockFn = method_exists($this, 'createMock') ? 'createMock' : 'getMock';
54 |
55 | $pager = $this->createMock(PagerInterface::class);
56 |
57 | $em = $this->createMock(EntityManagerInterface::class);
58 |
59 | $repository = $this->createMock(EntityRepository::class);
60 |
61 | $parent = $this->createMock(RepositoryFactory::class);
62 | $parent
63 | ->expects($this->once())
64 | ->method('getRepository')
65 | ->with($this->identicalTo($em), 'foo')
66 | ->willReturn($repository)
67 | ;
68 |
69 | $factory = new PagerAwareRepositoryFactory($pager, $parent);
70 | $factory->getRepository($em, 'foo');
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tests/Adapter/ElasticaAdapterTest.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('ruflin/elastica must be installed to run this test');
19 | }
20 | }
21 |
22 | public function testItemCountDelegatesToSearch()
23 | {
24 | $search = $this->createMock(Search::class);
25 | $search->method('getQuery')->willReturn($this->createMock(Query::class));
26 |
27 | $search
28 | ->expects($this->once())
29 | ->method('count')
30 | ->willReturn(42)
31 | ;
32 |
33 | $adapter = new ElasticaAdapter($search);
34 |
35 | $this->assertEquals(42, $adapter->getItemCount());
36 | }
37 |
38 | public function testGetItemsDelegatesToSearch()
39 | {
40 | $search = $this->createMock(Search::class);
41 | $search->method('getQuery')->willReturn($this->createMock(Query::class));
42 |
43 | $search
44 | ->method('search')
45 | ->willReturn($resultSet = $this->createMock(ResultSet::class))
46 | ;
47 |
48 | $resultSet
49 | ->expects($this->once())
50 | ->method('getResults')
51 | ->willReturn($expected = ['foo', 'bar'])
52 | ;
53 |
54 | $adapter = new ElasticaAdapter($search);
55 |
56 | $this->assertSame($expected, $adapter->getItems(0, 2));
57 | }
58 |
59 | public function testGetItemsSetsOffsetAndLimit()
60 | {
61 | $query = $this->createMock(Query::class);
62 |
63 | $query
64 | ->expects($this->once())
65 | ->method('setFrom')
66 | ->with(15)
67 | ;
68 |
69 | $query
70 | ->expects($this->once())
71 | ->method('setSize')
72 | ->with(5)
73 | ;
74 |
75 | $search = $this->createConfiguredMock(Search::class, [
76 | 'getQuery' => $query,
77 | 'search' => $this->createConfiguredMock(ResultSet::class, [
78 | 'getResults' => [],
79 | ]),
80 | ]);
81 |
82 | $adapter = new ElasticaAdapter($search);
83 | $adapter->getItems(15, 5);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Adapter/DqlAdapter.php:
--------------------------------------------------------------------------------
1 | paginate(DqlAdapter::fromQuery($query));
24 | *
25 | * // Or simply wrap Query & QueryBuilder instances in a Paginator object.
26 | * $pager->paginate(new DqlAdapter(new Paginator($query)));
27 | *
28 | * Be careful with using this adapter though: performance is abysmal for
29 | * sufficiently complex queries. You might want to use DqlByHandAdapter in
30 | * such cases.
31 | *
32 | * @api
33 | */
34 | final class DqlAdapter implements AdapterInterface
35 | {
36 | private Paginator $paginator;
37 | private ?int $itemCount = null;
38 |
39 | /**
40 | * @api
41 | */
42 | public function __construct(Paginator $paginator)
43 | {
44 | $this->paginator = $paginator;
45 | }
46 |
47 | /**
48 | * @see Paginator
49 | *
50 | * @param \Doctrine\ORM\Query|\Doctrine\ORM\QueryBuilder $query A Doctrine ORM query or query builder.
51 | * @param boolean $fetchJoinCollection Whether the query joins a collection (true by default).
52 | *
53 | * @api
54 | */
55 | public static function fromQuery($query, bool $fetchJoinCollection = true): DqlAdapter
56 | {
57 | return new static(new Paginator($query, $fetchJoinCollection));
58 | }
59 |
60 | /**
61 | * {@inheritDoc}
62 | *
63 | * @api
64 | */
65 | public function getItemCount(): int
66 | {
67 | if (null === $this->itemCount) {
68 | $this->itemCount = (int) $this->paginator->count();
69 | }
70 |
71 | return $this->itemCount;
72 | }
73 |
74 | /**
75 | * {@inheritDoc}
76 | *
77 | * @api
78 | */
79 | public function getItems(int $offset, int $limit): array
80 | {
81 | $this
82 | ->paginator
83 | ->getQuery()
84 | ->setFirstResult($offset)
85 | ->setMaxResults($limit)
86 | ;
87 |
88 | return iterator_to_array($this->paginator->getIterator());
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/PageInterface.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('doctrine/orm must be installed to run this test');
28 | }
29 | }
30 |
31 | public function testgetItemCountDelegatesToCount()
32 | {
33 | $paginator = $this->createMock(Paginator::class);
34 |
35 | $paginator
36 | ->expects($this->once())
37 | ->method('count')
38 | ->willReturn(5)
39 | ;
40 |
41 | $adapter = new DqlAdapter($paginator);
42 | $this->assertEquals(5, $adapter->getItemCount());
43 | }
44 |
45 | public function testGetItemsDelegatesToGetIterator(): void
46 | {
47 | $query = $this->getMockQuery();
48 |
49 | $query
50 | ->expects($this->once())
51 | ->method('setFirstResult')
52 | ->with(10)
53 | ->will($this->returnSelf())
54 | ;
55 |
56 | $query
57 | ->expects($this->once())
58 | ->method('setMaxResults')
59 | ->with(2)
60 | ->will($this->returnSelf())
61 | ;
62 |
63 | $paginator = $this->createMock(Paginator::class);
64 | $paginator->method('getQuery')->willReturn($query);
65 |
66 | $paginator
67 | ->expects($this->once())
68 | ->method('getIterator')
69 | ->willReturn(new \ArrayIterator($expected = ['foo', 'bar']))
70 | ;
71 |
72 | $adapter = new DqlAdapter($paginator);
73 | $this->assertSame($expected, $adapter->getItems(10, 2));
74 | }
75 |
76 | public function testFromQuery(): void
77 | {
78 | $query = $this->getMockQuery();
79 | $adapter = DqlAdapter::fromQuery($query);
80 |
81 | $this->addToAssertionCount(1);
82 | }
83 |
84 | private function getMockQuery()
85 | {
86 | return $this
87 | ->getMockBuilder(AbstractQuery::class)
88 | ->setMethods(['setParameter', 'getResult', 'getQuery', 'setFirstResult', 'setMaxResults'])
89 | ->disableOriginalConstructor()
90 | ->getMockForAbstractClass()
91 | ;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/tests/AdapterTest.php:
--------------------------------------------------------------------------------
1 | getConstructor();
34 | $this->assertFalse($constructor->isPublic());
35 | }
36 |
37 | public function testArray(): void
38 | {
39 | $this->assertInstanceOf(ArrayAdapter::class, Adapter::_array(array()));
40 | }
41 |
42 | public function testDql(): void
43 | {
44 | if (!class_exists(AbstractQuery::class)) {
45 | $this->markTestSkipped('doctrine/orm must be installed to run this test');
46 | }
47 |
48 | $query = $this->createMock(AbstractQuery::class);
49 | $this->assertInstanceOf(DqlAdapter::class, Adapter::dql($query));
50 | }
51 |
52 | public function testDqlByHand(): void
53 | {
54 | if (!class_exists(AbstractQuery::class)) {
55 | $this->markTestSkipped('doctrine/orm must be installed to run this test');
56 | }
57 |
58 | $a = $this->createMock(AbstractQuery::class);
59 | $b = $this->createMock(AbstractQuery::class);
60 |
61 | $this->assertInstanceOf(DqlByHandAdapter::class, Adapter::dqlByHand($a, $b));
62 | }
63 |
64 | public function testElastica(): void
65 | {
66 | if (!class_exists(Search::class)) {
67 | $this->markTestSkipped('ruflin/elastica must be installed to run this test');
68 | }
69 |
70 | $search = $this->createConfiguredMock(Search::class, [
71 | 'getQuery' => $this->createMock(Query::class),
72 | ]);
73 |
74 | $this->assertInstanceOf(ElasticaAdapter::class, Adapter::elastica($search));
75 | }
76 |
77 | public function testMongo(): void
78 | {
79 | if (!class_exists(MongoCursor::class)) {
80 | $this->markTestSkipped('ext/mongodb must be installed to run this test');
81 | }
82 |
83 | $this->assertInstanceOf(MongoAdapter::class, Adapter::mongo($this->createMock(MongoCursor::class)));
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/Bundle/EventListener/OutOfBoundsRedirectorTest.php:
--------------------------------------------------------------------------------
1 | createMock(HttpKernelInterface::class),
32 | new Request(),
33 | HttpKernelInterface::MAIN_REQUEST,
34 | new Exception('Everything is on fire!')
35 | );
36 |
37 | $redirector = new OutOfBoundsRedirector();
38 | $redirector->onKernelException($event);
39 |
40 | $this->assertNull($event->getResponse());
41 | }
42 |
43 | /**
44 | * @dataProvider getTestData
45 | */
46 | public function testRedirection(int $pageNumber, int $pageCount, ?int $expectedPage): void
47 | {
48 | $request = Request::create('http://example.com/?a=2&page=' . $pageNumber);
49 |
50 | $event = new ExceptionEvent(
51 | $this->createMock(HttpKernelInterface::class),
52 | $request,
53 | HttpKernelInterface::MAIN_REQUEST,
54 | new OutOfBoundsException($pageNumber, $pageCount)
55 | );
56 |
57 | $redirector = new OutOfBoundsRedirector();
58 | $redirector->onKernelException($event);
59 |
60 | if (is_null($expectedPage)) {
61 | $this->assertNull($event->getResponse());
62 | } else {
63 | $this->assertInstanceOf(Response::class, $response = $event->getResponse());
64 | $this->assertEquals('http://example.com/?a=2&page='.$expectedPage, $response->getTargetUrl());
65 | }
66 | }
67 |
68 | public function getTestData(): array
69 | {
70 | return [
71 | [3, 2, 2], // redirect to last page, if current page higher
72 | [0, 2, 1], // redirect to first page, if current page is not positive
73 | [2, 0, null], // don't redirect, if no pages exist
74 | [2, 4, null], // don't redirect, if the current page is inside the page range
75 | ];
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Bundle/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 | getRootNode();
25 |
26 | $rootNode
27 | ->fixXmlConfig('pager')
28 | ->children()
29 | ->scalarNode('default')
30 | ->defaultValue('default')
31 | ->end()
32 | ->arrayNode('pagers')
33 | ->requiresAtLeastOneElement()
34 | ->useAttributeAsKey('name')
35 | ->prototype('array')
36 | ->addDefaultsIfNotSet()
37 | ->children()
38 | ->integerNode('per_page')
39 | ->info('Number of items to display on a single page.')
40 | ->min(1)
41 | ->defaultValue(25)
42 | ->end()
43 | ->scalarNode('key')
44 | ->info('Name of the query string parameter where the current page is inferred from.')
45 | ->defaultValue('page')
46 | ->end()
47 | ->floatNode('merge')
48 | ->info('Threshold to merge 2 last pages (<1 is per cent, >=1 means number of items).')
49 | ->defaultNull()
50 | ->treatNullLike(0.25)
51 | ->end()
52 | ->booleanNode('redirect')
53 | ->info('Whether to redirect out of range requests')
54 | ->defaultValue(true)
55 | ->treatNullLike(true)
56 | ->end()
57 | ->end()
58 | ->end()
59 | ->defaultValue(array('default' => array(
60 | 'per_page' => 25,
61 | 'key' => 'page',
62 | 'redirect' => true,
63 | )))
64 | ->end()
65 | ->end()
66 | ;
67 |
68 | return $treeBuilder;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/tests/Adapter/CallbackDecoratorTest.php:
--------------------------------------------------------------------------------
1 | createMock(AdapterInterface::class);
25 | $adapter->method('getItemCount')->willReturn(42);
26 |
27 | $decorator = new CallbackDecorator($adapter, function (array $items) {
28 | return $items;
29 | });
30 |
31 | $this->assertEquals(42, $decorator->getItemCount());
32 | }
33 |
34 | public function testCallbacksAppliedToItems(): void
35 | {
36 | $adapter = $this->createMock(AdapterInterface::class);
37 | $adapter
38 | ->method('getItems')
39 | ->with(0, 2)
40 | ->willReturn(array(2, 4))
41 | ;
42 |
43 | $decorator = new CallbackDecorator($adapter, function (array $items) {
44 | return array_map(function ($item) { return $item * 2; }, $items);
45 | });
46 |
47 | $this->assertEquals(array(4, 8), $decorator->getItems(0, 2));
48 | }
49 |
50 | public function testMultipleCallbacksAppliedInOrder(): void
51 | {
52 | $adapter = $this->createMock(AdapterInterface::class);
53 | $adapter
54 | ->method('getItems')
55 | ->with(0, 2)
56 | ->willReturn(array(2, 4))
57 | ;
58 |
59 | $addFn = function (array $items) {
60 | return array_map(function ($item) { return $item + 2; }, $items);
61 | };
62 |
63 | $mulFn = function (array $items) {
64 | return array_map(function ($item) { return $item * 2; }, $items);
65 | };
66 |
67 | $decorator = new CallbackDecorator($adapter, $addFn);
68 | $decorator = new CallbackDecorator($decorator, $mulFn);
69 |
70 | $this->assertEquals(array(8, 12), $decorator->getItems(0, 2));
71 | }
72 |
73 | public function testGetItemsFailsIfItemCountDifferentAfterCallback(): void
74 | {
75 | $this->expectException(\LogicException::class);
76 |
77 | $adapter = $this->createMock(AdapterInterface::class);
78 | $adapter
79 | ->method('getItems')
80 | ->willReturn(array(1, 2))
81 | ;
82 |
83 | $fn = function (array $items) {
84 | return array(1);
85 | };
86 |
87 | $decorator = new CallbackDecorator($adapter, $fn);
88 | $decorator->getItems(0, 2);
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/PagingStrategy/LastPageMerged.php:
--------------------------------------------------------------------------------
1 | 10% of items on the last page)
25 | * >= 1 - number of items (3 -> 3 items on the last page)
26 | *
27 | * For example, given 10 items per page with a total of 23 items with 0.4
28 | * threshold, the strategy would tell the pager to merge the last two pages
29 | * leaving a total of 2 pages with 13 items on the last page.
30 | */
31 | final class LastPageMerged implements PagingStrategyInterface
32 | {
33 | private float $threshold;
34 |
35 | public function __construct(float $threshold)
36 | {
37 | $this->threshold = $threshold;
38 | }
39 |
40 | /**
41 | * {@inheritDoc}
42 | */
43 | public function getLimit(AdapterInterface $adapter, int $page, int $perPage): array
44 | {
45 | $offset = ($page - 1) * $perPage;
46 | $length = $perPage;
47 |
48 | // Blah! This is horrible that we have to fetch 2 pages worth of data
49 | // every time. A solution would be to have a sort of a caching adapter.
50 | $itemCount = count($adapter->getItems($offset, $length * 2));
51 |
52 | if ($this->shouldMerge($itemCount, $perPage)) {
53 | $length = $itemCount;
54 | }
55 |
56 | return array($offset, $length);
57 | }
58 |
59 | /**
60 | * {@inheritDoc}
61 | */
62 | public function getCount(AdapterInterface $adapter, int $page, int $perPage): int
63 | {
64 | $itemCount = $adapter->getItemCount();
65 | $pageCount = (int) ceil($itemCount / $perPage);
66 |
67 | if ($this->shouldMerge($itemCount, $perPage)) {
68 | return $pageCount - 1;
69 | }
70 |
71 | return $pageCount;
72 | }
73 |
74 | private function shouldMerge(int $itemCount, int $perPage): bool
75 | {
76 | $lastPageItemCount = $itemCount % $perPage;
77 |
78 | if ($lastPageItemCount <= 0) {
79 | return false;
80 | }
81 |
82 | if ($this->threshold < 1 && (($lastPageItemCount / $perPage) > $this->threshold)) {
83 | return false;
84 | }
85 |
86 | if ($this->threshold >= 1 && ($lastPageItemCount > $this->threshold)) {
87 | return false;
88 | }
89 |
90 | return true;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/tests/PagingStrategy/LastPageMergedTest.php:
--------------------------------------------------------------------------------
1 | createMock(AdapterInterface::class);
28 | $adapter
29 | ->method('getItems')
30 | ->willReturn(array_fill(0, $itemCount, null))
31 | ;
32 |
33 | $strategy = new LastPageMerged($threshold);
34 | $this->assertEquals($expectedLimit, $strategy->getLimit($adapter, $page, $perPage));
35 | }
36 |
37 | public function getTestDataForLimit()
38 | {
39 | return [
40 | [0, 1, 5, 10, [0, 5]],
41 | [0, 1, 5, 5, [0, 5]],
42 | [0.1, 1, 10, 12, [0, 10]],
43 | [0.5, 1, 5, 7, [0, 7]],
44 | [0.5, 1, 5, 8, [0, 5]],
45 | [0.5, 1, 5, 10, [0, 5]],
46 | [0.5, 3, 5, 7, [10, 7]],
47 | [0.5, 3, 4, 6, [8, 6]],
48 | [1, 1, 4, 5, [0, 5]],
49 | [1, 1, 4, 6, [0, 4]],
50 | [3, 2, 7, 10, [7, 10]],
51 | [6, 2, 3, 5, [3, 5]],
52 | // @todo what about cases where the current page is non-positive?
53 | ];
54 | }
55 |
56 | public function testItemsForTwoPagesAskedFromAdapter(): void
57 | {
58 | $adapter = $this->createMock(AdapterInterface::class);
59 | $adapter
60 | ->method('getItems')
61 | ->with(0, 10)
62 | ->willReturn(array())
63 | ;
64 |
65 | $strategy = new LastPageMerged(0.5);
66 | $strategy->getLimit($adapter, 1, 5);
67 |
68 | $this->addToAssertionCount(1);
69 | }
70 |
71 | /**
72 | * @dataProvider getTestDataForCount
73 | */
74 | public function testCount(float $threshold, int $perPage, int $itemCount, int $expectedCount): void
75 | {
76 | $adapter = $this->createMock(AdapterInterface::class);
77 | $adapter
78 | ->method('getItemCount')
79 | ->willReturn($itemCount)
80 | ;
81 |
82 | $strategy = new LastPageMerged($threshold);
83 | $this->assertEquals($expectedCount, $strategy->getCount($adapter, 1, $perPage));
84 | }
85 |
86 | public function getTestDataForCount(): array
87 | {
88 | return [
89 | [0.0, 5, 15, 3],
90 | [0.5, 5, 17, 3],
91 | [0.1, 10, 12, 2],
92 | [0.5, 5, 18, 4],
93 | [1, 4, 13, 3],
94 | [1, 4, 14, 4],
95 | [3, 6, 15, 2],
96 | [3, 2, 11, 5],
97 | ];
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/Adapter/CachedDecorator.php:
--------------------------------------------------------------------------------
1 | adapter = $adapter;
34 | }
35 |
36 | /**
37 | * {@inheritDoc}
38 | */
39 | public function getItemCount(): int
40 | {
41 | if (null === $this->itemCount) {
42 | $this->itemCount = $this->adapter->getItemCount();
43 | }
44 |
45 | return $this->itemCount;
46 | }
47 |
48 | /**
49 | * {@inheritDoc}
50 | */
51 | public function getItems(int $offset, int $limit): array
52 | {
53 | list($notCachedOffset, $notCachedLimit) = $this->findNotCachedRange($offset, $limit);
54 |
55 | if ($notCachedLimit > 0) {
56 | $this->cache($notCachedOffset, $notCachedLimit);
57 | }
58 |
59 | return $this->fromCache($offset, $limit);
60 | }
61 |
62 | private function findNotCachedRange(int $offset, int $limit): array
63 | {
64 | $begin = $offset;
65 | $end = $offset + $limit;
66 |
67 | if (null !== $this->lastItemPos && $end > $this->lastItemPos) {
68 | $end = $this->lastItemPos;
69 | }
70 |
71 | // Start narrowing both beginning & end indices until an item at the
72 | // given position is not cached.
73 | while ($begin < $end && array_key_exists($begin, $this->cached)) {
74 | $begin++;
75 | }
76 |
77 | while ($begin < $end && array_key_exists($end - 1, $this->cached)) {
78 | $end--;
79 | }
80 |
81 | return [$begin, $end - $begin];
82 | }
83 |
84 | /**
85 | * Fetches the given range of items from the adapter and stores them in
86 | * the cache array.
87 | */
88 | private function cache(int $offset, int $limit): void
89 | {
90 | $items = $this->adapter->getItems($offset, $limit);
91 | $i = $offset;
92 |
93 | foreach ($items as $item) {
94 | $this->cached[$i++] = $item;
95 | }
96 |
97 | if (count($items) < $limit) {
98 | $this->lastItemPos = $i - 1;
99 | }
100 | }
101 |
102 | /**
103 | * Returns the range from previously cached items.
104 | */
105 | private function fromCache(int $offset, int $limit): array
106 | {
107 | $items = [];
108 |
109 | for ($i = $offset; $i < ($offset + $limit); $i++) {
110 | if (!array_key_exists($i, $this->cached)) {
111 | break;
112 | }
113 |
114 | $items[] = $this->cached[$i];
115 | }
116 |
117 | return $items;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tests/RequestDecoratorTest.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('symfony/http-foundation must be installed to run this test');
30 | }
31 | }
32 |
33 | public function testPagianteGetsPage(): void
34 | {
35 | $adapter = $this->createMock(AdapterInterface::class);
36 |
37 | $pager = $this->createMock(PagerInterface::class);
38 | $pager
39 | ->method('paginate')
40 | ->with($adapter, null, null)
41 | ->willReturn($expected = $this->createMock(PageInterface::class))
42 | ;
43 |
44 | $decorated = new RequestDecorator($pager, $this->createMock(RequestStack::class));
45 |
46 | $this->assertSame($expected, $decorated->paginate($adapter));
47 | }
48 |
49 | public function testPaginateInfersCurrentPageFromRequest(): void
50 | {
51 | $pager = $this->createMock(PagerInterface::class);
52 | $pager
53 | ->expects($this->once())
54 | ->method('paginate')
55 | ->with($this->anything(), $this->anything(), 5)
56 | ;
57 |
58 | $stack = $this->createMock(RequestStack::class);
59 | $stack
60 | ->method('getCurrentRequest')
61 | ->willReturn(new Request(['page' => 5]))
62 | ;
63 |
64 | $decorated = new RequestDecorator($pager, $stack);
65 | $decorated->paginate($this->createMock(AdapterInterface::class));
66 | }
67 |
68 | public function testCustomKeyUsed(): void
69 | {
70 | $pager = $this->createMock(PagerInterface::class);
71 | $pager
72 | ->expects($this->once())
73 | ->method('paginate')
74 | ->with($this->anything(), $this->anything(), 5)
75 | ;
76 |
77 | $stack = $this->createMock(RequestStack::class);
78 | $stack
79 | ->method('getCurrentRequest')
80 | ->willReturn(new Request(['foo' => 5]))
81 | ;
82 |
83 | $decorated = new RequestDecorator($pager, $stack, 'foo');
84 | $decorated->paginate($this->createMock(AdapterInterface::class));
85 | }
86 |
87 | public function testPassedPageOverridesInferredCurrentPage(): void
88 | {
89 | $pager = $this->createMock(PagerInterface::class);
90 | $pager
91 | ->method('paginate')
92 | ->with($this->anything(), $this->anything(), 3)
93 | ;
94 |
95 | $stack = $this->createMock(RequestStack::class);
96 | $stack
97 | ->expects($this->never())
98 | ->method('getCurrentRequest')
99 | ;
100 |
101 | $decorated = new RequestDecorator($pager, $stack);
102 | $decorated->paginate($this->createMock(AdapterInterface::class), null, 3);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/tests/Adapter/DqlByHandAdapterTest.php:
--------------------------------------------------------------------------------
1 | markTestSkipped('doctrine/orm must be installed to run this test');
31 | }
32 | }
33 |
34 | public function testSupportsQueryBuilders()
35 | {
36 | $mainQb = $this->createMock(QueryBuilder::class);
37 | $countQb = $this->createMock(QueryBuilder::class);
38 |
39 | foreach ([$mainQb, $countQb] as $qb) {
40 | $qb
41 | ->expects($this->once())
42 | ->method('getQuery')
43 | ->willReturn($this->createMock(AbstractQuery::class))
44 | ;
45 | }
46 |
47 | $adapter = new DqlByHandAdapter($mainQb, $countQb);
48 | }
49 |
50 | public function testGetItemCountReturnsNullIfNoResultFound(): void
51 | {
52 | $countQuery = $this->getMockQuery();
53 | $countQuery
54 | ->method('getScalarResult')
55 | ->will($this->returnCallback(function () {
56 | throw new NoResultException();
57 | }))
58 | ;
59 |
60 | $adapter = new DqlByHandAdapter($this->getMockQuery(), $countQuery);
61 | $this->assertEquals(0, $adapter->getItemCount());
62 | }
63 |
64 | public function testItemCountSumsAllRows(): void
65 | {
66 | $countQuery = $this->getMockQuery();
67 | $countQuery
68 | ->method('getScalarResult')
69 | ->willReturn(array_fill(0, 3, [5]))
70 | ;
71 |
72 | $adapter = new DqlByHandAdapter($this->getMockQuery(), $countQuery);
73 | $this->assertEquals(15, $adapter->getItemCount());
74 | }
75 |
76 | public function testGetItemsDelegatesToAdapter(): void
77 | {
78 | $adapter = new DqlByHandAdapter($this->getMockQuery(), $this->getMockQuery());
79 |
80 | $class = new \ReflectionClass($adapter);
81 | $property = $class->getProperty('adapter');
82 | $property->setAccessible(true);
83 |
84 | $this->assertInstanceOf(DqlAdapter::class, $property->getValue($adapter));
85 | $property->setValue($adapter, $parent = $this->createMock(AdapterInterface::class));
86 |
87 | $parent
88 | ->expects($this->once())
89 | ->method('getItems')
90 | ->with(5, 10)
91 | ->willReturn($expected = array_fill(0, 10, ['id' => 5, 'foo' => 'bar']))
92 | ;
93 |
94 | $this->assertSame($expected, $adapter->getItems(5, 10));
95 | }
96 |
97 | private function getMockQuery()
98 | {
99 | return $this
100 | ->getMockBuilder(AbstractQuery::class)
101 | ->setMethods([
102 | 'getHydrationMode',
103 | 'getQuery',
104 | 'getResult',
105 | 'getScalarResult',
106 | 'setFirstResult',
107 | 'setMaxResults',
108 | 'setParameter',
109 | ])
110 | ->disableOriginalConstructor()
111 | ->getMockForAbstractClass()
112 | ;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/Bundle/DependencyInjection/KGPagerExtension.php:
--------------------------------------------------------------------------------
1 | load('services.xml');
40 |
41 | $configuration = $this->getConfiguration($configs, $container);
42 | $config = $this->processConfiguration($configuration, $configs);
43 |
44 | $this->registerPagers($config['pagers'], $container);
45 | $this->setDefaultPager($config['default'], $container);
46 |
47 | if (!class_exists('Doctrine\ORM\Version')) {
48 | // Doctrine not installed, remove the pager aware repository factory.
49 | $container->removeDefinition('kg_pager.pager_aware_repository_factory');
50 | }
51 |
52 | if (!class_exists('Twig\Environment')) {
53 | $container->removeDefinition('kg_pager.twig_extension');
54 | }
55 | }
56 |
57 | private function setDefaultPager(string $name, ContainerBuilder $container): void
58 | {
59 | $defaultId = sprintf('%s.%s', self::PREFIX_PAGER, $name);
60 |
61 | if (!$container->findDefinition($defaultId)) {
62 | throw new LogicException(sprintf('No pager named %s registered (i.e. found no service named %s).', $name, $defaultId));
63 | }
64 |
65 | $container->setAlias('kg_pager', $defaultId);
66 | }
67 |
68 | private function registerPagers(array $configs, ContainerBuilder $container): void
69 | {
70 | $shouldDisableRedirector = true;
71 |
72 | foreach ($configs as $name => $config) {
73 | $serviceId = sprintf("%s.%s", self::PREFIX_PAGER, $name);
74 | $definition = $container->register($serviceId, $container->getParameter('kg_pager.class'));
75 |
76 | // Sets the default items per page for the given pager.
77 | if (isset($config['per_page'])) {
78 | $definition->addArgument($config['per_page']);
79 | }
80 |
81 | // Changes the strategy, if this pager should merge last two pages
82 | // given the following threshold.
83 | if (isset($config['merge']) && $config['merge'] > 0) {
84 | $strategyDefinition = new Definition($container->getParameter('kg_pager.strategy.last_page_merged.class'));
85 | $strategyDefinition->addArgument($config['merge']);
86 |
87 | $definition->addArgument($strategyDefinition);
88 | }
89 |
90 | // Wraps the pager inside a request decorator to have it automatically
91 | // infer the current page from the request.
92 | if ($config['key']) {
93 | $definition = $container
94 | ->register($serviceId, $container->getParameter('kg_pager.request_decorator.class'))
95 | ->setArguments(array(
96 | $definition,
97 | new Reference('request_stack'),
98 | $config['key'],
99 | ))
100 | ;
101 | }
102 |
103 | if ($config['redirect']) {
104 | $shouldDisableRedirector = false;
105 |
106 | $definition = $container
107 | ->register($serviceId, $container->getParameter('kg_pager.bounds_check_decorator.class'))
108 | ->setArguments(array($definition))
109 | ;
110 |
111 | if ($config['key']) {
112 | $definition->addArgument($config['key']);
113 | }
114 | }
115 | }
116 |
117 | if ($shouldDisableRedirector) {
118 | $container->removeDefinition('kg_pager.out_of_bounds_redirector');
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tests/Bundle/DependencyInjection/KGPagerExtensionTest.php:
--------------------------------------------------------------------------------
1 | createContainer('');
31 |
32 | $this->assertInstanceOf(PagerInterface::class, $container->get('kg_pager'));
33 | $this->assertSame($container->get('kg_pager'), $container->get('kg_pager.pager.default'));
34 | }
35 |
36 | public function testPerPageSet()
37 | {
38 | $yaml = <<createContainer($yaml)
48 | ->findDefinition('kg_pager.pager.default')
49 | ;
50 |
51 | $this->assertEquals(15, $definition->getArgument(0));
52 | }
53 |
54 | public function testPagerWrappedInRequestDecoratorIfCurrentPageSet(): void
55 | {
56 | $yaml = <<createContainer($yaml)
65 | ->get('kg_pager.pager.default')
66 | ;
67 |
68 | $this->assertInstanceOf(RequestDecorator::class, $pager);
69 | }
70 |
71 | public function testPagerNotWrappedInRequestDecoratorIfCurrentPageNotSet(): void
72 | {
73 | $yaml = <<createContainer($yaml)
82 | ->getDefinition('kg_pager.pager.default')
83 | ;
84 |
85 | $this->assertNotEquals(RequestDecorator::class, $definition->getClass());
86 | }
87 |
88 | public function testPagerWrappedInBoundsCheckDecorator(): void
89 | {
90 | $yaml = <<createContainer($yaml);
98 | $definition = $container->getDefinition('kg_pager.pager.default');
99 |
100 | $this->assertEquals(BoundsCheckDecorator::class, $definition->getClass());
101 |
102 | $refl = new \ReflectionClass($definition->getArgument(0)->getClass());
103 | $this->assertTrue($refl->implementsInterface(PagerInterface::class));
104 | $this->assertEquals('foo', $definition->getArgument(1));
105 |
106 | $this->assertTrue($container->has('kg_pager.out_of_bounds_redirector'));
107 | }
108 |
109 | public function testRedirectorRemovedIfNoPagersShouldBeRedirected(): void
110 | {
111 | $yaml = <<createContainer($yaml);
119 |
120 | $this->assertFalse($container->has('kg_pager.out_of_bounds_redirector'));
121 | }
122 |
123 | public function testMergeStrategyNotUsedByDefault(): void
124 | {
125 | $yaml = <<createContainer($yaml)
133 | ->getDefinition('kg_pager.pager.default')
134 | ->getArguments()
135 | ;
136 |
137 | $this->assertTrue(!isset($arguments[1]) || is_null($arguments[1]));
138 | }
139 |
140 | public function testMergeStrategyUsedIfMergeNotNull(): void
141 | {
142 | $yaml = <<createContainer($yaml)
152 | ->getDefinition('kg_pager.pager.default')
153 | ->getArgument(1)
154 | ;
155 |
156 | $this->assertNotNull($definition);
157 |
158 | $this->assertEquals(LastPageMerged::class, $definition->getClass());
159 | $this->assertEquals(0.333, $definition->getArgument(0));
160 | }
161 |
162 | private function createContainer(string $yaml): ContainerBuilder
163 | {
164 | $parser = new Parser();
165 | $container = new ContainerBuilder();
166 | $container->register('request_stack', RequestStack::class);
167 |
168 | $loader = new KGPagerExtension();
169 | $loader->load(array($parser->parse($yaml)), $container);
170 |
171 | return $container;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/Page.php:
--------------------------------------------------------------------------------
1 | adapter = $adapter;
41 | $this->strategy = $strategy;
42 | $this->perPage = $perPage;
43 | $this->number = $number;
44 | }
45 |
46 | /**
47 | * {@inheritDoc}
48 | */
49 | public function getItems(): array
50 | {
51 | return array_slice($this->getItemsWithOneExtra(), 0, $this->getLength());
52 | }
53 |
54 | /**
55 | * {@inheritDoc}
56 | */
57 | public function getItemsOfAllPages(): array
58 | {
59 | // @todo this is suboptimal - ideally we don't need to know item count
60 | // to get all items. Instead the entire collection should be returned.
61 | // Doing it this way to minimize BC breaks.
62 | return $this->adapter->getItems(0, $this->getItemCount());
63 | }
64 |
65 | /**
66 | * {@inheritDoc}
67 | */
68 | public function getNumber(): int
69 | {
70 | return $this->number;
71 | }
72 |
73 | /**
74 | * {@inheritDoc}
75 | */
76 | public function getNext(): ?PageInterface
77 | {
78 | if ($this->isLast()) {
79 | return null;
80 | }
81 |
82 | return $this->getPage($this->number + 1);
83 | }
84 |
85 | /**
86 | * {@inheritDoc}
87 | */
88 | public function getPrevious(): ?PageInterface
89 | {
90 | if ($this->isFirst()) {
91 | return null;
92 | }
93 |
94 | return $this->getPage($this->number - 1);
95 | }
96 |
97 | /**
98 | * {@inheritDoc}
99 | */
100 | public function isFirst(): bool
101 | {
102 | return 1 === $this->getNumber();
103 | }
104 |
105 | /**
106 | * {@inheritDoc}
107 | */
108 | public function isLast(): bool
109 | {
110 | return count($this->getItemsWithOneExtra()) <= $this->getLength();
111 | }
112 |
113 | /**
114 | * {@inheritDoc}
115 | */
116 | public function isOutOfBounds(): bool
117 | {
118 | $number = $this->getNumber();
119 |
120 | if ($this->getNumber() < 1) {
121 | return true;
122 | }
123 |
124 | if (($number > 1) && !count($this->getItemsWithOneExtra())) {
125 | return true;
126 | }
127 |
128 | return false;
129 | }
130 |
131 | /**
132 | * {@inheritDoc}
133 | */
134 | public function getPageCount(): int
135 | {
136 | // Should never allow a scenario with no pages even though this is
137 | // technically correct. So if no elements were found, the page count
138 | // will still be 1.
139 | return $this->strategy->getCount($this->adapter, $this->getNumber(), $this->perPage) ?: 1;
140 | }
141 |
142 | /**
143 | * {@inheritDoc}
144 | */
145 | public function getItemCount(): int
146 | {
147 | return $this->itemCount ?: $this->itemCount = $this->adapter->getItemCount();
148 | }
149 |
150 | /**
151 | * {@inheritDoc}
152 | */
153 | public function callback(callable $callback): PageInterface
154 | {
155 | $adapter = new CallbackDecorator($this->adapter, $callback);
156 |
157 | return new self($adapter, $this->strategy, $this->perPage, $this->number);
158 | }
159 |
160 | private function getOffset(): int
161 | {
162 | if (null === $this->offset) {
163 | list($this->offset, $this->length) = $this->getLimit();
164 | }
165 |
166 | return $this->offset;
167 | }
168 |
169 | private function getLength(): int
170 | {
171 | if (null === $this->length) {
172 | list($this->offset, $this->length) = $this->getLimit();
173 | }
174 |
175 | return $this->length;
176 | }
177 |
178 | /**
179 | * @see PagingStrategyInterface::getLimit()
180 | *
181 | * @return integer[]
182 | */
183 | private function getLimit(): array
184 | {
185 | return $this->strategy->getLimit($this->adapter, $this->getNumber(), $this->perPage);
186 | }
187 |
188 | /**
189 | * Gets items of this page as well as the 1-st item from the next page.
190 | * Doing it this way keeps us from having to run the expensive total item
191 | * count query in some scenarios.
192 | *
193 | * @return array
194 | */
195 | private function getItemsWithOneExtra(): array
196 | {
197 | if (null === $this->itemsWithOneExtra) {
198 | $this->itemsWithOneExtra = $this
199 | ->adapter
200 | ->getItems($this->getOffset(), $this->getLength() + 1)
201 | ;
202 | }
203 |
204 | return $this->itemsWithOneExtra ?: array();
205 | }
206 |
207 | /**
208 | * Creates a new page with the given number.
209 | */
210 | private function getPage(int $number): Page
211 | {
212 | return new self($this->adapter, $this->strategy, $this->perPage, $number);
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/tests/Adapter/CachedDecoratorTest.php:
--------------------------------------------------------------------------------
1 | createMock(AdapterInterface::class);
25 |
26 | $adapter
27 | ->expects($this->once())
28 | ->method('getItemCount')
29 | ->willReturn(42)
30 | ;
31 |
32 | $decorator = new CachedDecorator($adapter);
33 |
34 | $this->assertEquals(42, $decorator->getItemCount());
35 | $this->assertEquals(42, $decorator->getItemCount());
36 | }
37 |
38 | public function testGetItemsNotCached(): void
39 | {
40 | $adapter = $this->createMock(AdapterInterface::class);
41 | $adapter
42 | ->expects($this->once())
43 | ->method('getItems')
44 | ->with(8, 4)
45 | ->willReturn($expected = range(0, 3))
46 | ;
47 |
48 | $decorator = new CachedDecorator($adapter);
49 |
50 | $this->assertEquals($expected, $decorator->getItems(8, 4));
51 | }
52 |
53 | public function testLessItemsFoundThanAskedFor(): void
54 | {
55 | $adapter = $this->createMock(AdapterInterface::class);
56 | $adapter
57 | ->expects($this->once())
58 | ->method('getItems')
59 | ->with(8, 6)
60 | ->willReturn($expected = range(0, 3))
61 | ;
62 |
63 | $decorator = new CachedDecorator($adapter);
64 |
65 | $this->assertEquals($expected, $decorator->getItems(8, 6));
66 | }
67 |
68 | /**
69 | * This makes sure the decorator won't go asking for the same range of
70 | * items again, if the first query returned nothing.
71 | */
72 | public function testNullResultsCached(): void
73 | {
74 | $adapter = $this->createMock(AdapterInterface::class);
75 | $adapter
76 | ->expects($this->once())
77 | ->method('getItems')
78 | ->willReturn(array())
79 | ;
80 |
81 | $decorator = new CachedDecorator($adapter);
82 | $decorator->getItems(4, 5);
83 |
84 | $this->assertEquals(array(), $decorator->getItems(4, 5));
85 | }
86 |
87 | public function testNoExtraCallsMadeIfPreviousItemWasAlreadyNotFound(): void
88 | {
89 | $adapter = $this->createMock(AdapterInterface::class);
90 | $adapter
91 | ->expects($this->once())
92 | ->method('getItems')
93 | ->willReturn(array())
94 | ;
95 |
96 | $decorator = new CachedDecorator($adapter);
97 | $decorator->getItems(4, 5);
98 | $decorator->getItems(9, 5);
99 | }
100 |
101 | public function testSupportsNullItems(): void
102 | {
103 | $adapter = $this->createMock(AdapterInterface::class);
104 | $adapter
105 | ->method('getItems')
106 | ->willReturn($expected = array(null, null))
107 | ;
108 |
109 | $decorator = new CachedDecorator($adapter);
110 | $items = $decorator->getItems(1, 3);
111 |
112 | $this->assertEquals($expected, $items);
113 | }
114 |
115 | /**
116 | * @dataProvider getTestsForCachingSystem
117 | */
118 | public function testCachingSystem(array $targetLimits, array $expectedLimits): void
119 | {
120 | $adapter = $this->createMock(AdapterInterface::class);
121 |
122 | foreach ($expectedLimits as $i => $expectedLimit) {
123 | list($offset, $length) = $expectedLimit;
124 |
125 | $adapter
126 | ->expects($this->at($i))
127 | ->method('getItems')
128 | ->with($offset, $length)
129 | ->willReturn(range($offset, $offset + $length - 1))
130 | ;
131 | }
132 |
133 | $decorator = new CachedDecorator($adapter);
134 |
135 | foreach ($targetLimits as $i => $targetLimit) {
136 | list($offset, $length) = $targetLimit;
137 |
138 | $this->assertEquals(
139 | range($offset, $offset + $length - 1),
140 | $decorator->getItems($offset, $length),
141 | 'Fail at #'.$i
142 | );
143 | }
144 | }
145 |
146 | /**
147 | * Each test has a list of limits asked from the decorator and another
148 | * list with the expected limits for calls made to the wrapped adapter.
149 | *
150 | * Comments above each test better illustrate the scenario:
151 | *
152 | * - items marked by "=" are expected to be retrieved from the decorator;
153 | * - items enclosed in brackets are expected to be retrieved from the adapter;
154 | */
155 | public function getTestsForCachingSystem(): array
156 | {
157 | return [
158 | [
159 | // 06 07 08 09 10 11 12 13 - items
160 | // [==============] - 1st query
161 | // ============ - 2nd query
162 | // ============ - 3rd query
163 | // ======== - 4th query
164 | [[8, 4], [9, 3], [8, 3], [9, 2]],
165 | [[8, 4]],
166 | ],
167 | [
168 | // 06 07 08 09 10 11 12 13 - items
169 | // [==============] - 1st query
170 | // [======]======== - 2nd query
171 | // ========[======] - 3rd query
172 | [[8, 4], [6, 4], [10, 4]],
173 | [[8, 4], [6, 2], [12, 2]],
174 | ],
175 | [
176 | // 06 07 08 09 10 11 12 13 - items
177 | // [======] - 1st query
178 | // [======] - 2nd query
179 | // ====[==============]==== - 3rd query
180 | [[6, 2], [12, 2], [7, 6]],
181 | [[6, 2], [12, 2], [8, 4]],
182 | ],
183 | [
184 | // 06 07 08 09 10 11 12 13 - items
185 | // [======] - 1st query
186 | // [======] - 2nd query
187 | // [==] - 3rd query
188 | // ====[==============]==== - 4th query
189 | [[6, 2], [12, 2], [9, 1], [7, 6]],
190 | [[6, 2], [12, 2], [9, 1], [8, 4]],
191 | ],
192 | [
193 | // 06 07 08 09 10 11 12 13 - items
194 | // [======] - 1st query
195 | // [==============] - 2nd query
196 | [[9, 2], [8, 4]],
197 | [[9, 2], [8, 4]],
198 | ],
199 | ];
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/doc/index.md:
--------------------------------------------------------------------------------
1 | Documentation
2 | -------------
3 |
4 | ### Table of Contents
5 |
6 | * [Adapters](#adapters)
7 | * [Strategies](#strategies)
8 | * [Callbacks](#callbacks)
9 | * [Avoid expensive counting](#avoid-expensive-counting)
10 | * [Bounds checking](#bounds-checking)
11 | * [Symfony bundle](#symfony-bundle)
12 | * [Installation](#installation)
13 | * [Usage](#usage)
14 | * [Configuration](#configuration)
15 | * [Twig](#twig)
16 | * [Class diagram](#class-diagram)
17 |
18 | ### Adapters
19 |
20 | Adapters are used to allow paging of specific types of items. The following
21 | are supported out of the box:
22 |
23 | * [`ArrayAdapter`](/src/Adapter/ArrayAdapter.php)
24 | * [`DqlAdapter`](/src/Adapter/DqlAdapter.php)
25 | * [`DqlByHandAdapter`](/src/Adapter/DqlByHandAdapter.php)
26 | * [`ElasticaAdapter`](/src/Adapter/ElasticaAdapter.php)
27 | * [`MongoAdapter`](/src/Adapter/MongoAdapter.php)
28 |
29 | A single [`Adapter`](/src/Adapter.php) can be used to construct any of the
30 | adapters from within a single class.
31 |
32 | ```php
33 | paginate(Adapter::_array(['foo', 'bar', 'baz']));
36 |
37 | ?>
38 | ```
39 |
40 | ### Strategies
41 |
42 | Strategies define the way items are split to pages. By default the "equally
43 | paged" strategy is used.
44 |
45 | ```php
46 |
56 | ```
57 |
58 | The following strategies exist:
59 |
60 | - [`EquallyPaged`](/src/Strategy/EquallyPaged.php) - split items equally
61 | between the pages;
62 | - [`LastPageMerged`](/src/Strategy/LastPageMerged.php) - split items equally
63 | between the pages, but merge the last two pages if there are too few items
64 | left dangling on the last page;
65 |
66 | ### Callbacks
67 |
68 | Callbacks are used to modify paged items. They're added to page objects and
69 | are applied whenever the items are fetched for the first time. The only
70 | requirement is that the callback must return exactly as many items as were
71 | passed to it.
72 |
73 | Each callback constructs a new `Page` object so you can keep multiple pages
74 | around.
75 |
76 | ```php
77 | paginate(new ArrayAdapter(array(1, 2)))
85 | ->callback(function (array $items) {
86 | foreach ($items as $key => $item) {
87 | $items[$key] = $item * 2;
88 | }
89 |
90 | return $items;
91 | })
92 | ;
93 |
94 | $page->getItems(); // [2, 4]
95 |
96 | ?>
97 | ```
98 |
99 | ### Getting items from all of the pages
100 |
101 | Sometimes it might be necessary to keep using the paging system yet fetch all
102 | items from all of the pages.
103 |
104 | ```php
105 | paginate(new ArrayAdapter(range(0, 3)), $perPage);
113 |
114 | $page->getItems(); // [0, 1]
115 | $page->getItemsOfAllPages(); // [0, 1, 2, 3]
116 |
117 | ?>
118 | ```
119 |
120 | ### Avoiding expensive counting
121 |
122 | On bigger result sets it might be prohibitively expensive to count the total
123 | number of items. The pager won't use adapter's count method by sticking to the
124 | following methods:
125 |
126 | - Page::getNext()
127 | - Page::getPrevious()
128 | - Page::isFirst()
129 | - Page::isLast()
130 | - Page::isOutOfBounds()
131 | - Page::getItems()
132 | - Page::getNumber()
133 | - Page::callback()
134 |
135 | This guarantee only applies when using built-in strategies and adapters.
136 |
137 | ### Automatically setting the current page
138 |
139 | The [symfony/http-foundation](https://packagist.org/packages/symfony/http-foundation)
140 | package is required for this feature. The pager can be wrapped by a special
141 | decorator, which gets the current page automatically from the given request.
142 |
143 | ```php
144 | paginate($adapter);
153 |
154 | $page->getNumber() // 3
155 |
156 | ?>
157 | ```
158 |
159 | ### Bounds checking
160 |
161 | Bounds checking is disabled by default. This can be checked manually any time
162 | by calling `Page::isOutOfBounds()`. However, this requires knowing the total
163 | count of items.
164 |
165 | The pager can be wrapped in a [`BoundsCheckDecorator`](/src/BoundsCheckDecorator.php)
166 | to throw exceptions for out of bounds pages.
167 |
168 | ```php
169 | paginate($adapter, null, -5);
179 | } catch (OutOfBoundsException $e) {
180 | // Location: http://example.com?custom_key=1
181 | header(sprintf('Location: http://example.com?%s=%s', $e->getRedirectKey(), 1));
182 | }
183 |
184 | ?>
185 | ```
186 |
187 | ### Symfony bundle
188 |
189 | The package comes with a bundle to seamlessly integrate with your Symfony
190 | projects.
191 |
192 | #### Installation
193 |
194 | After installing the package, simply enable it in the kernel:
195 |
196 | ```php
197 |
208 | ```
209 |
210 | That's it! no extra configuration necessary. You can make sure the bundle's up
211 | and running by executing
212 |
213 | ```bash
214 | app/console container:debug | grep kg_pager
215 | ```
216 |
217 | If everything's working, it should print out the pager service.
218 |
219 | #### Usage
220 |
221 | By default a single pager is defined. Access it through the `kg_pager` service id.
222 | The current page is inferred from the `page` query parameter.
223 |
224 | ```php
225 | getDoctrine()
236 | ->getRepository('AppBundle:Product')
237 | ->createQueryBuilder('p')
238 | ;
239 |
240 | // 25 items per page is used by default.
241 | $itemsPerPage = 10;
242 | $page = $this->get('kg_pager')->paginate(Adapter::dql($qb), $itemsPerPage);
243 |
244 | return $this->render('App:Product:listPaged.html.twig', array(
245 | 'page' => $page
246 | ));
247 | }
248 | }
249 |
250 | ?>
251 | ```
252 |
253 | Of course the pager can also be injected to any service.
254 |
255 | ```php
256 | pager = $pager;
268 | }
269 |
270 | public function doSomethingPaged()
271 | {
272 | $list = array('foo', 'bar', 'baz');
273 |
274 | return $this->pager->paginate(Adapter::_array($list), 2);
275 | }
276 | }
277 |
278 | ?>
279 | ```
280 |
281 | ```xml
282 |
283 |
284 |
285 | ```
286 |
287 | #### Configuration
288 |
289 | You may want to optinally configure the bundle to define several pagers, each
290 | with their own settings.
291 |
292 | ```yaml
293 | kg_pager:
294 | default: foo # now `kg_pager` returns a pager named `foo`
295 | pagers:
296 | foo:
297 | per_page: 20 # how many items to have on a single page
298 | key: custom_page # the key used to infer the current page i.e. `http://exapmle.com?custom_page=2`
299 | merge: 10 # if less than 10 items are left on the last page, merge it with the previous page
300 | redirect: false # whether to redirect the user, if they requested an out of bounds page
301 | bar: ~ # pager with default settings
302 | ```
303 |
304 | The pagers are registered in the service container as `kg_pager.pager.%name%`
305 | with the default pager aliased to `kg_pager`.
306 |
307 | You may optionally want to have the default pager be automatically injected to
308 | your entity repositories. For this do the following:
309 |
310 | * Have a custom repository class implement [`PagerAwareInterface`][Doctrine/PagerAwareInterface.php];
311 | * Set the class as the default repository class and add a custom factory service
312 | in doctrine configuration:
313 |
314 | ```yml
315 | // app/config/config.yml
316 | doctrine:
317 | orm:
318 | default_repository_class: 'Repository\Implementing\PagerAwareInterface'
319 | repository_factory: 'kg_pager.pager_aware_repository_factory'
320 |
321 | ```
322 |
323 | #### Twig
324 |
325 | The bundle adds a new Twig function `paged`. You can use this to paginate
326 | items in your Twig templates.
327 |
328 | ```twig
329 | {% set items = [1, 2, 3, 4] %}
330 | {% set perPage = 2 %}
331 | {% set currentPage = 2 %}
332 |
333 | {% set pageA = paged(items) %}
334 | {% set pageB = paged(items, perPage, currentPage) %}
335 |
336 | {# Both `pageA` and `pageB` are instances of `PageInterface` #}
337 | ```
338 |
339 | ### Class diagram
340 |
341 | 
342 |
--------------------------------------------------------------------------------
/tests/PageTest.php:
--------------------------------------------------------------------------------
1 | createMock(PagingStrategyInterface::class);
26 | $strategy->method('getLimit')->willReturn([9, 3]);
27 |
28 | $adapter = $this->createMock(AdapterInterface::class);
29 | $adapter
30 | ->expects($this->once())
31 | ->method('getItems')
32 | ->willReturn($expected = [1, 2, 3])
33 | ;
34 |
35 | $page = new Page($adapter, $strategy, 3, 4);
36 | $this->assertSame($expected, $page->getItems());
37 | }
38 |
39 | public function testItemsOfAllPagesReturnsItemsFromAllPages(): void
40 | {
41 | $expected = range(0, 8);
42 |
43 | $strategy = $this->createMock(PagingStrategyInterface::class);
44 |
45 | $adapter = $this->createMock(AdapterInterface::class);
46 | $adapter
47 | ->method('getItemCount')
48 | ->will($this->returnCallback(function () use ($expected) {
49 | return count($expected);
50 | }))
51 | ;
52 |
53 | $adapter
54 | ->method('getItems')
55 | ->will($this->returnCallback(function ($offset, $limit) use ($expected) {
56 | return array_slice($expected, $offset, $limit);
57 | }))
58 | ;
59 |
60 | $page = new Page($adapter, $strategy, 2, 2);
61 | $this->assertEquals($expected, $page->getItemsOfAllPages());
62 | }
63 |
64 | public function testExtraItemFetched(): void
65 | {
66 | $strategy = $this->createMock(PagingStrategyInterface::class);
67 | $strategy->method('getLimit')->willReturn([9, 3]);
68 |
69 | $adapter = $this->createMock(AdapterInterface::class);
70 | $adapter
71 | ->expects($this->once())
72 | ->method('getItems')
73 | ->with(9, 4)
74 | ->willReturn([1, 2, 3, 4])
75 | ;
76 |
77 | $page = new Page($adapter, $strategy, 3, 4);
78 | $this->assertCount(3, $page->getItems(), 'Page may not expose the extra item.');
79 | }
80 |
81 | public function testGetNumber(): void
82 | {
83 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 4, 2);
84 | $this->assertEquals(2, $page->getNumber());
85 | }
86 |
87 | public function testGetNextReturnsNextPage(): void
88 | {
89 | $strategy = $this->createMock(PagingStrategyInterface::class);
90 | $strategy->method('getLimit')->willReturn([0, 25]); // These shouldn't matter
91 | $strategy->method('getCount')->willReturn(3);
92 |
93 | $adapter = $this->createMock(AdapterInterface::class);
94 | $adapter
95 | ->expects($this->once())
96 | ->method('getItems')
97 | ->willReturn(array_fill(0, 26, null))
98 | ;
99 |
100 | $page = new Page($adapter, $strategy, 25, 2);
101 | $this->assertNotNull($nextPage = $page->getNext());
102 |
103 | $this->assertNotSame($page, $nextPage);
104 | $this->assertEquals(3, $nextPage->getNumber());
105 | }
106 |
107 | public function testGetNextNullIfLastPage(): void
108 | {
109 | $strategy = $this->createMock(PagingStrategyInterface::class);
110 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
111 | $strategy->method('getCount')->willReturn(3);
112 |
113 | $page = new Page($this->createMock(AdapterInterface::class), $strategy, 25, 3);
114 | $this->assertNull($page->getNext());
115 | }
116 |
117 | public function testGetPreviousReturnsPreviousPage(): void
118 | {
119 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 25, 2);
120 | $this->assertNotNull($previousPage = $page->getPrevious());
121 |
122 | $this->assertNotSame($page, $previousPage);
123 | $this->assertEquals(1, $previousPage->getNumber());
124 | }
125 |
126 | public function testGetPreviousPageReturnsNullIfFirstPage(): void
127 | {
128 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 25, 1);
129 | $this->assertNull($page->getPrevious());
130 | }
131 |
132 | public function testIsFirstIfPageNumberFirst(): void
133 | {
134 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 5, 1);
135 | $this->assertTrue($page->isFirst());
136 | }
137 |
138 | public function testIsNotFirstIfPageNumberNotFirst(): void
139 | {
140 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 5, 3);
141 | $this->assertFalse($page->isFirst());
142 | }
143 |
144 | public function testLimitAskedOnlyOnce(): void
145 | {
146 | $strategy = $this->createMock(PagingStrategyInterface::class);
147 | $strategy
148 | ->expects($this->once())
149 | ->method('getLimit')
150 | ->willReturn([0, 5])
151 | ;
152 |
153 | $adapter = $this->createMock(AdapterInterface::class);
154 | $adapter->method('getItems')->willReturn([]);
155 |
156 | $page = new Page($adapter, $strategy, 5, 1);
157 | $page->getItems();
158 | $page->getItems();
159 | }
160 |
161 | public function testIsLastPageIfNoRemainingItemsAfterThisPage(): void
162 | {
163 | $strategy = $this->createMock(PagingStrategyInterface::class);
164 | $strategy->method('getLimit')->willReturn([10, 5]);
165 |
166 | $adapter = $this->createMock(AdapterInterface::class);
167 | $adapter->method('getItems')->willReturn(array_fill(0, 5, null));
168 |
169 | $page = new Page($adapter, $strategy, 5, 3);
170 | $this->assertTrue($page->isLast());
171 | }
172 |
173 | public function testIsNotLastIfMoreItemsAfterThisPage(): void
174 | {
175 | $strategy = $this->createMock(PagingStrategyInterface::class);
176 | $strategy->method('getLimit')->willReturn([10, 5]);
177 |
178 | $adapter = $this->createMock(AdapterInterface::class);
179 | $adapter->method('getItems')->willReturn(array_fill(0, 6, null));
180 |
181 | $page = new Page($adapter, $strategy, 5, 3);
182 | $this->assertFalse($page->isLast());
183 | }
184 |
185 | public function testIsOutOfBoundsIfNumberNonPositive(): void
186 | {
187 | $page = new Page($this->createMock(AdapterInterface::class), $this->createMock(PagingStrategyInterface::class), 5, 0);
188 | $this->assertTrue($page->isOutOfBounds());
189 | }
190 |
191 | public function testIsOutOfBoundsIfNoItemsFound(): void
192 | {
193 | $strategy = $this->createMock(PagingStrategyInterface::class);
194 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
195 |
196 | $adapter = $this->createMock(AdapterInterface::class);
197 | $adapter->method('getItems')->willReturn([]);
198 |
199 | $page = new Page($adapter, $strategy, 5, 4);
200 | $this->assertTrue($page->isOutOfBounds());
201 | }
202 |
203 | public function testIsNotOutOfBoundsIfPositiveAndItemsFound(): void
204 | {
205 | $strategy = $this->createMock(PagingStrategyInterface::class);
206 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
207 |
208 | $adapter = $this->createMock(AdapterInterface::class);
209 | $adapter->method('getItems')->willReturn(['a', 'b', 'c']);
210 |
211 | $page = new Page($adapter, $strategy, 5, 3);
212 | $this->assertFalse($page->isOutOfBounds());
213 | }
214 |
215 | public function testIsNotOutOfBoundsIfNoItemsFoundOnFirstPage(): void
216 | {
217 | $adapter = $this->createMock(AdapterInterface::class);
218 | $adapter->method('getItems')->willReturn([]);
219 |
220 | $page = new Page($adapter, $this->createMock(PagingStrategyInterface::class), 5, 1);
221 | $this->assertFalse($page->isOutOfBounds(), 'Being on 1-st page without results implies there are no items - means we\'re not out of bounds.');
222 | }
223 |
224 | public function testGetPageCountDelegatedToStrategy(): void
225 | {
226 | $adapter = $this->createMock(AdapterInterface::class);
227 | $adapter->method('getItemCount')->willReturn(14);
228 |
229 | $strategy = $this->createMock(PagingStrategyInterface::class);
230 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
231 | $strategy
232 | ->expects($this->once())
233 | ->method('getCount')
234 | ->with($this->identicalTo($adapter), 4, 3)
235 | ->willReturn(5)
236 | ;
237 |
238 | $page = new Page($adapter, $strategy, 3, 4);
239 | $this->assertEquals(5, $page->getPageCount());
240 | }
241 |
242 | public function testPageCountNeverLessThanOne(): void
243 | {
244 | $strategy = $this->createMock(PagingStrategyInterface::class);
245 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
246 | $strategy
247 | ->method('getCount')
248 | ->willReturn(0)
249 | ;
250 |
251 | $page = new Page($this->createMock(AdapterInterface::class), $strategy, 1, 5);
252 | $this->assertEquals(1, $page->getPageCount());
253 | }
254 |
255 | public function testItemCountDelegatedToAdapter(): void
256 | {
257 | $strategy = $this->createMock(PagingStrategyInterface::class);
258 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
259 |
260 | $adapter = $this->createMock(AdapterInterface::class);
261 | $adapter
262 | ->expects($this->once())
263 | ->method('getItemCount')
264 | ->willReturn(15)
265 | ;
266 |
267 | $page = new Page($adapter, $strategy, 4, 5);
268 | $this->assertEquals(15, $page->getItemCount());
269 | }
270 |
271 | public function testDifferentPageAfterCallback(): void
272 | {
273 | $strategy = $this->createMock(PagingStrategyInterface::class);
274 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter
275 |
276 | $page = new Page($this->createMock(AdapterInterface::class), $strategy, 5, 2);
277 | $newPage = $page->callback(function ($items) { return $items; });
278 |
279 | $this->assertNotSame($page, $newPage);
280 |
281 | $this->assertEquals(2, $page->getNumber());
282 | $this->assertEquals(2, $newPage->getNumber());
283 | }
284 |
285 | public function testCallbacksApplied(): void
286 | {
287 | $strategy = $this->createMock(PagingStrategyInterface::class);
288 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter.
289 | $strategy->method('getCount')->willReturn(3);
290 |
291 | $adapter = $this->createMock(AdapterInterface::class);
292 | $adapter->method('getItems')->willReturn([2, 4]);
293 |
294 | $page = new Page($adapter, $strategy, 4, 5);
295 | $page = $page->callback(function (array $items) {
296 | return array_map(function ($item) { return $item * 2; }, $items);
297 | });
298 |
299 | $this->assertEquals([4, 8], $page->getItems());
300 | }
301 |
302 | /**
303 | * @dataProvider getMethodsNotRelyingOnItemCount
304 | */
305 | public function testItemsNotCountedForMethod($method, $arguments = []): void
306 | {
307 | $strategy = $this->createMock(PagingStrategyInterface::class);
308 | $strategy->method('getLimit')->willReturn([999, 999]); // These shouldn't matter.
309 |
310 | $strategy
311 | ->expects($this->never())
312 | ->method('getCount')
313 | ;
314 |
315 | $adapter = $this->createMock(AdapterInterface::class);
316 |
317 | $adapter
318 | ->expects($this->never())
319 | ->method('getItemCount');
320 | ;
321 |
322 | $adapter
323 | ->method('getItems')
324 | ->willReturn([2, 4])
325 | ;
326 |
327 | $page = new Page($adapter, $strategy, 4, 5);
328 |
329 | call_user_func_array([$page, $method], $arguments);
330 | }
331 |
332 | public function getMethodsNotRelyingOnItemCount(): array
333 | {
334 | return [
335 | ['getNext'],
336 | ['getPrevious'],
337 | ['isFirst'],
338 | ['isLast'],
339 | ['isOutOfBounds'],
340 | ['getItems'],
341 | ['getNumber'],
342 | ['callback', array(function ($items) { return $items; })],
343 | ];
344 | }
345 | }
346 |
--------------------------------------------------------------------------------