├── 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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 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 | [![Build Status](https://img.shields.io/travis/kgilden/pager/master.svg?style=flat-square)](https://travis-ci.org/kgilden/pager) 5 | [![Quality](https://img.shields.io/scrutinizer/g/kgilden/pager.svg?style=flat-square)](https://scrutinizer-ci.com/g/kgilden/pager/) 6 | [![Packagist](https://img.shields.io/packagist/v/kgilden/pager.svg?style=flat-square)](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 | ![class diagram](class_diagram.png) 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 | --------------------------------------------------------------------------------