diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 84a016ec9b7..0cc0b732bd4 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -119,7 +119,7 @@ jobs: if jq -e '.private == true' "$file" > /dev/null; then continue fi - peerDependencies=$(jq -r '.peerDependencies | keys[]' "$file") + peerDependencies=$(jq -r '.peerDependencies // {} | keys[]' "$file") for peerDependency in $peerDependencies; do peerDependencyVersion=$(jq -r --arg dep "$peerDependency" '.peerDependencies[$dep]' "$file") importmapVersion=$(jq -r ".symfony.importmap.\"$peerDependency\" | if type == \"string\" then . else .version end" "$file") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09c7e1ed061..ef372c36bef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,8 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@25.3.0)(jsdom@26.1.0)(msw@2.10.4(@types/node@25.3.0)(typescript@5.8.3))(vite@7.3.1(@types/node@25.3.0)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.20.3)) + src/Pagination/assets: {} + src/React/assets: devDependencies: '@hotwired/stimulus': diff --git a/splitsh.json b/splitsh.json index 013dd9e3580..774d00626ef 100644 --- a/splitsh.json +++ b/splitsh.json @@ -15,6 +15,7 @@ "ux-leaflet-map": "src/Map/src/Bridge/Leaflet", "ux-native": "src/Native", "ux-notify": "src/Notify", + "ux-pagination": "src/Pagination", "ux-react": "src/React", "ux-toolkit": "src/Toolkit", "ux-translator": "src/Translator", diff --git a/src/Pagination/.gitattributes b/src/Pagination/.gitattributes new file mode 100644 index 00000000000..7ef5afcdf50 --- /dev/null +++ b/src/Pagination/.gitattributes @@ -0,0 +1,7 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/phpstan.dist.neon export-ignore +/phpunit.dist.xml export-ignore +/assets/src export-ignore +/doc export-ignore +/tests export-ignore diff --git a/src/Pagination/.gitignore b/src/Pagination/.gitignore new file mode 100644 index 00000000000..ab264227ab3 --- /dev/null +++ b/src/Pagination/.gitignore @@ -0,0 +1,7 @@ +/assets/node_modules/ +/config/reference.php +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.cache +.DS_Store diff --git a/src/Pagination/.symfony.bundle.yaml b/src/Pagination/.symfony.bundle.yaml new file mode 100644 index 00000000000..9e3566394b9 --- /dev/null +++ b/src/Pagination/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ['3.x'] +maintained_branches: ['3.x'] +doc_dir: 'doc' diff --git a/src/Pagination/CHANGELOG.md b/src/Pagination/CHANGELOG.md new file mode 100644 index 00000000000..b98cfb4d5b0 --- /dev/null +++ b/src/Pagination/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 3.5.0 + +- Add the component. diff --git a/src/Pagination/LICENSE b/src/Pagination/LICENSE new file mode 100644 index 00000000000..94b768f8ae8 --- /dev/null +++ b/src/Pagination/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Pagination/README.md b/src/Pagination/README.md new file mode 100644 index 00000000000..caac5ff2e4a --- /dev/null +++ b/src/Pagination/README.md @@ -0,0 +1,96 @@ +# Symfony UX Pagination + +**EXPERIMENTAL** This bundle is currently experimental and is likely to change, +possibly significantly, before its first stable release. + +Ship stable cursor feeds or classic numbered pages through one request-aware +Symfony service. PHP owns the query, Twig renders accessible navigation, and +the browser follows ordinary links without requiring JavaScript. + +## Installation + +```bash +composer require symfony/ux-pagination +``` + +## Usage + +```php +// src/Controller/EventController.php +namespace App\Controller; + +use App\Repository\EventRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\UX\Pagination\PaginatorInterface; + +final class EventController extends AbstractController +{ + #[Route('/events', name: 'event_index')] + public function __invoke( + EventRepository $events, + PaginatorInterface $paginator, + ): Response { + $pagination = $paginator + ->cursor($events->createQueryBuilder('event')) + ->orderBy(['createdAt', 'id'], 'DESC') + ->perPage(20) + ->paginate(); + + return $this->render('event/index.html.twig', [ + 'pagination' => $pagination, + ]); + } +} +``` + +```twig +{# templates/event/index.html.twig #} +{% for event in pagination %} +
{{ event.name }}
+{% endfor %} + +{{ ux_pagination(pagination) }} +``` + +The paginator reads and validates `?cursor=`, preserves the current filters and +generates previous/next URLs. Use `query($source)` for numbered pagination or +`query($source)->lookahead()` when the UI needs only previous/next without an +exact total. Use `total($counter)` when an aggregate query needs an +application-provided total; invokable Symfony services work directly. + +## What it provides + +- First-class signed cursor pagination with forward and backward navigation +- Offset pagination with lazy totals and numbered pages +- Lookahead pagination without a count query +- Doctrine ORM 3 and DBAL 4.4+ adapters +- Callback builders and custom adapters for APIs, search engines and + application sources +- Request-aware URLs generated through the Symfony Router +- Accessible, translated Twig navigation with Bootstrap, Tailwind and custom + themes, plus validated attribute hooks +- Named paginator policies with Symfony autowiring +- PHP, Twig, JSON and LiveComponent integration +- Real test helpers for page, URL and signed-cursor behavior + +| Need | Strategy | +| ----------------------------------------------------- | --------- | +| Resist offset shifts while ordered values stay stable | Cursor | +| Page numbers and an exact total | Offset | +| Previous/next without a total | Lookahead | + +## Documentation + +- [Getting started](doc/getting-started.rst) +- [Choosing a strategy](doc/strategies.rst) +- [Cursor pagination](doc/cursor.rst) +- [Rendering and customization](doc/rendering.rst) +- [Configuration](doc/configuration.rst) +- [Testing](doc/testing.rst) and [debugging](doc/debugging.rst) + +The complete documentation is published on +[symfony.com](https://symfony.com/bundles/ux-pagination/current/index.html). + +**This repository is a READ-ONLY subtree split.** diff --git a/src/Pagination/assets/LICENSE b/src/Pagination/assets/LICENSE new file mode 100644 index 00000000000..94b768f8ae8 --- /dev/null +++ b/src/Pagination/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Pagination/assets/dist/style.min.css b/src/Pagination/assets/dist/style.min.css new file mode 100644 index 00000000000..7ead68d9055 --- /dev/null +++ b/src/Pagination/assets/dist/style.min.css @@ -0,0 +1 @@ +.ux-pagination{--ux-pagination-color:#1f2937;--ux-pagination-muted-color:#6b7280;--ux-pagination-border-color:#d1d5db;--ux-pagination-background:#fff;--ux-pagination-active-background:#111827;--ux-pagination-active-color:#fff;--ux-pagination-focus-color:#2563eb;color:var(--ux-pagination-color)}.ux-pagination__info{color:var(--ux-pagination-muted-color);margin-block:0 .75rem;font-size:.875rem}.ux-pagination__list{flex-wrap:wrap;align-items:center;gap:.375rem;margin:0;padding:0;list-style:none;display:flex}.ux-pagination__item{display:inline-flex}.ux-pagination__link{border:1px solid var(--ux-pagination-border-color);background:var(--ux-pagination-background);min-width:2.5rem;min-height:2.5rem;color:inherit;border-radius:.375rem;justify-content:center;align-items:center;padding:.5rem .75rem;line-height:1;text-decoration:none;display:inline-flex}a.ux-pagination__link:hover{border-color:currentColor}.ux-pagination__link--current{border-color:var(--ux-pagination-active-background);background:var(--ux-pagination-active-background);color:var(--ux-pagination-active-color);font-weight:600}.ux-pagination__link--disabled{cursor:not-allowed;opacity:.55}.ux-pagination__ellipsis{min-width:2rem;min-height:2.5rem;color:var(--ux-pagination-muted-color);justify-content:center;align-items:center;display:inline-flex}.ux-pagination__link:focus-visible{outline:3px solid var(--ux-pagination-focus-color,#2563eb);outline-offset:2px}.ux-pagination [aria-current=page]{font-weight:600}@media (prefers-color-scheme:dark){.ux-pagination{--ux-pagination-color:#f3f4f6;--ux-pagination-muted-color:#9ca3af;--ux-pagination-border-color:#4b5563;--ux-pagination-background:#111827;--ux-pagination-active-background:#f3f4f6;--ux-pagination-active-color:#111827;--ux-pagination-focus-color:#60a5fa}}@media (prefers-contrast:more){.ux-pagination__link{border:1px solid}.ux-pagination :focus-visible{outline-width:4px}} diff --git a/src/Pagination/assets/package.json b/src/Pagination/assets/package.json new file mode 100644 index 00000000000..272897694d6 --- /dev/null +++ b/src/Pagination/assets/package.json @@ -0,0 +1,28 @@ +{ + "name": "@symfony/ux-pagination", + "description": "Pagination controls for Symfony applications", + "license": "MIT", + "version": "3.5.0", + "keywords": [ + "symfony-ux", + "pagination", + "paginator" + ], + "homepage": "https://ux.symfony.com/pagination", + "repository": "https://github.com/symfony/ux", + "type": "module", + "files": [ + "dist" + ], + "style": "dist/style.min.css", + "exports": { + "./style.min.css": "./dist/style.min.css" + }, + "config": { + "css_source": "src/style.css" + }, + "scripts": { + "build": "node ../../../bin/build_package.ts .", + "watch": "node ../../../bin/build_package.ts . --watch" + } +} diff --git a/src/Pagination/assets/src/style.css b/src/Pagination/assets/src/style.css new file mode 100644 index 00000000000..a85b603b1ad --- /dev/null +++ b/src/Pagination/assets/src/style.css @@ -0,0 +1,109 @@ +/** + * UX Pagination styles. + * + * The component selectors are intentionally scoped: importing this file must + * not change unrelated aria-current elements in the app. + */ + +.ux-pagination { + --ux-pagination-color: #1f2937; + --ux-pagination-muted-color: #6b7280; + --ux-pagination-border-color: #d1d5db; + --ux-pagination-background: #fff; + --ux-pagination-active-background: #111827; + --ux-pagination-active-color: #fff; + --ux-pagination-focus-color: #2563eb; + + color: var(--ux-pagination-color); +} + +.ux-pagination__info { + margin-block: 0 0.75rem; + color: var(--ux-pagination-muted-color); + font-size: 0.875rem; +} + +.ux-pagination__list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; +} + +.ux-pagination__item { + display: inline-flex; +} + +.ux-pagination__link { + display: inline-flex; + min-width: 2.5rem; + min-height: 2.5rem; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + border: 1px solid var(--ux-pagination-border-color); + border-radius: 0.375rem; + background: var(--ux-pagination-background); + color: inherit; + line-height: 1; + text-decoration: none; +} + +a.ux-pagination__link:hover { + border-color: currentColor; +} + +.ux-pagination__link--current { + border-color: var(--ux-pagination-active-background); + background: var(--ux-pagination-active-background); + color: var(--ux-pagination-active-color); + font-weight: 600; +} + +.ux-pagination__link--disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.ux-pagination__ellipsis { + display: inline-flex; + min-width: 2rem; + min-height: 2.5rem; + align-items: center; + justify-content: center; + color: var(--ux-pagination-muted-color); +} + +.ux-pagination__link:focus-visible { + outline: 3px solid var(--ux-pagination-focus-color, #2563eb); + outline-offset: 2px; +} + +.ux-pagination [aria-current='page'] { + font-weight: 600; +} + +@media (prefers-color-scheme: dark) { + .ux-pagination { + --ux-pagination-color: #f3f4f6; + --ux-pagination-muted-color: #9ca3af; + --ux-pagination-border-color: #4b5563; + --ux-pagination-background: #111827; + --ux-pagination-active-background: #f3f4f6; + --ux-pagination-active-color: #111827; + --ux-pagination-focus-color: #60a5fa; + } +} + +@media (prefers-contrast: more) { + .ux-pagination__link { + border: 1px solid currentColor; + } + + .ux-pagination :focus-visible { + outline-width: 4px; + } +} diff --git a/src/Pagination/assets/tsconfig.json b/src/Pagination/assets/tsconfig.json new file mode 100644 index 00000000000..ab1408716a8 --- /dev/null +++ b/src/Pagination/assets/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.package.json" +} diff --git a/src/Pagination/composer.json b/src/Pagination/composer.json new file mode 100644 index 00000000000..669c7ce3e53 --- /dev/null +++ b/src/Pagination/composer.json @@ -0,0 +1,72 @@ +{ + "name": "symfony/ux-pagination", + "type": "symfony-bundle", + "description": "Cursor and numbered pagination for Symfony applications", + "keywords": [ + "symfony-ux", + "pagination", + "paginator", + "pager" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Pagination\\": "src/" + }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Pagination\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.4", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/translation-contracts": "^3.0", + "symfony/twig-bundle": "^7.4|^8.0", + "twig/twig": "^3.28" + }, + "require-dev": { + "doctrine/dbal": "^4.4", + "doctrine/orm": "^3.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.1|^12.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/ux-live-component": "^3.0", + "symfony/ux-twig-component": "^3.0" + }, + "conflict": { + "doctrine/dbal": "<4.4", + "doctrine/orm": "<3.0", + "symfony/ux-live-component": "<3.0", + "symfony/ux-twig-component": "<3.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Pagination/config/services.php b/src/Pagination/config/services.php new file mode 100644 index 00000000000..606000fdfd8 --- /dev/null +++ b/src/Pagination/config/services.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\Cursor\CursorCodecInterface; +use Symfony\UX\Pagination\PaginationInfoFormatter; +use Symfony\UX\Pagination\Paginator; +use Symfony\UX\Pagination\PaginatorInterface; + +return static function (ContainerConfigurator $container): void { + $services = $container->services() + ->defaults() + ->private() + ; + + $services->set('ux_pagination.cursor_codec', CursorCodec::class) + ->lazy(CursorCodecInterface::class) + ; + $services->alias(CursorCodecInterface::class, 'ux_pagination.cursor_codec'); + + $services->set('ux_pagination.adapter.array', ArrayPaginationAdapter::class) + ->tag('ux_pagination.adapter') + ; + + $services->set('ux_pagination.info_formatter', PaginationInfoFormatter::class) + ->arg('$translator', service('translator')->nullOnInvalid()) + ; + + $services->set('ux_pagination.paginator', Paginator::class) + ->arg('$adapters', tagged_iterator('ux_pagination.adapter', defaultPriorityMethod: 'getDefaultPriority')) + ->arg('$requestStack', service('request_stack')->nullOnInvalid()) + ->arg('$urlGenerator', service('router')->nullOnInvalid()) + ->arg('$infoFormatter', service('ux_pagination.info_formatter')) + ->arg('$cursorCodec', service('ux_pagination.cursor_codec')) + ; + + $services->alias(PaginatorInterface::class, 'ux_pagination.paginator'); +}; diff --git a/src/Pagination/config/twig.php b/src/Pagination/config/twig.php new file mode 100644 index 00000000000..4a8c4cb8265 --- /dev/null +++ b/src/Pagination/config/twig.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\UX\Pagination\Twig\PaginationExtension; +use Symfony\UX\Pagination\Twig\PaginationRenderer; + +return static function (ContainerConfigurator $container): void { + $services = $container->services() + ->defaults() + ->private() + ; + + $services->set('ux_pagination.renderer', PaginationRenderer::class) + ->arg('$twig', service('twig')) + ; + + $services->set('ux_pagination.twig.extension', PaginationExtension::class) + ->arg('$renderer', service('ux_pagination.renderer')) + ->autoconfigure() + ->tag('ux.twig_component.twig_renderer', ['key' => 'ux:pagination']) + ; +}; diff --git a/src/Pagination/doc/adopting.rst b/src/Pagination/doc/adopting.rst new file mode 100644 index 00000000000..b92e33b2cc3 --- /dev/null +++ b/src/Pagination/doc/adopting.rst @@ -0,0 +1,98 @@ +Adopting UX Pagination +====================== + +You do not need to replace a working paginator only because this bundle +exists. Adopt UX Pagination when its application-level contract removes +code that your controllers and templates currently own. + +Choose it for a new application +------------------------------- + +UX Pagination is a strong default when you want: + +* one immutable builder for arrays, Doctrine ORM, Doctrine DBAL and + custom sources; +* validated ``page`` and signed ``cursor`` values read from the current + Request; +* generated URLs that preserve filters and use the Symfony Router; +* lazy totals, lookahead navigation or cursor traversal selected + explicitly; +* one iterable result for PHP, Twig and JSON; +* accessible server-rendered links without a JavaScript runtime; +* named paginator policies injectable through the service container; +* test helpers that exercise real URL and cursor behavior. + +The bundle is not another way to calculate ``LIMIT`` and ``OFFSET``. It +owns the complete boundary between the Request, the data-source +strategy, the pagination result and its URLs. + +Keep an existing solution +------------------------- + +Keeping the current paginator is reasonable when: + +* it already expresses the required query, URL and rendering contracts; +* the application relies on extension points that UX Pagination does not + provide; +* cursor traversal is not needed and migrating would only rename + familiar methods; +* a third-party bundle or administration system integrates directly with + the current paginator; +* the team cannot test every existing pagination URL during the + migration. + +Cursor pagination alone can justify adopting UX Pagination for new +high-volume or frequently changing feeds. It does not require converting +every numbered list in the application at the same time. + +Map the concepts +---------------- + +================================== ============================================== +Existing application concept UX Pagination concept +================================== ============================================== +Pagination service ``PaginatorInterface`` +Page size ``items_per_page`` or ``perPage()`` +Current page from the Request Resolved automatically, or ``paginate(page:)`` +Paginated result ``PaginationInterface`` +Total and final page ``NumberedPaginationInterface`` +Previous/next cursor ``CursorPaginationInterface`` +Template or view ``ux_pagination()`` and a Twig template +Reusable paginator configuration A named paginator +Custom data-source integration A tagged adapter +================================== ============================================== + +Migrate one list +---------------- + +Start with one controller. Keep the route and the query parameter +unchanged:: + + // src/Controller/ProductController.php + $products = $paginator + ->query($repository->createQueryBuilder('product')) + ->perPage(20) + ->paginate(); + +Then render the result directly: + +.. code-block:: html+twig + + {# templates/product/index.html.twig #} + {% for product in products %} +
{{ product.name }}
+ {% endfor %} + + {{ ux_pagination(products) }} + +Before replacing the existing implementation, test: + +* first, middle, final and out-of-range pages; +* every filter and sort query parameter; +* generated absolute URLs if feeds or APIs consume them; +* custom templates and translated labels; +* the number of data and count queries. + +Once the numbered list behaves identically, decide separately whether it +benefits from lookahead or cursor navigation. A migration should not +silently change the product's navigation model. diff --git a/src/Pagination/doc/configuration.rst b/src/Pagination/doc/configuration.rst new file mode 100644 index 00000000000..141d8fb3009 --- /dev/null +++ b/src/Pagination/doc/configuration.rst @@ -0,0 +1,367 @@ +Configuration reference +======================= + +Bundle configuration +-------------------- + +.. code-block:: yaml + + # config/packages/ux_pagination.yaml + ux_pagination: + items_per_page: 20 + max_offset: 100000 + page_parameter: page + cursor_parameter: cursor + + navigation: + mode: sliding + size: 5 + + theme: '@UXPagination/theme/default.html.twig' + + cursor: + # Optional: defaults to kernel.secret + secret: '%env(UX_PAGINATION_CURSOR_SECRET)%' + + paginators: + blog: + items_per_page: 12 + navigation: + size: 7 + +==================== =========== ================= ========================= +Option Type Default Purpose +==================== =========== ================= ========================= +``items_per_page`` integer ``20`` Default page size +``max_offset`` integer ``100000`` Largest allowed offset +``page_parameter`` string ``page`` Numbered page parameter +``cursor_parameter`` string ``cursor`` Cursor parameter +``navigation.mode`` string ``sliding`` Numbered navigation mode +``navigation.size`` integer ``5`` Mode size or safety limit +``theme`` string default template Pagination theme +``cursor.secret`` string/null ``kernel.secret`` Cursor signature secret +``paginators`` map ``{}`` Named paginator profiles +==================== =========== ================= ========================= + +The page size is application-owned. Define its default with +``items_per_page`` or select it for a use case with ``perPage()``. UX +Pagination does not read a visitor-controlled page-size query parameter. + +Set ``cursor.secret`` when cursor links need an independent rotation policy. +Leave it unset to reuse ``kernel.secret``. The value must not be empty. +Changing it invalidates existing cursor URLs. + +``navigation.size`` depends on the mode: the moving window size for +``sliding``, the block size for ``fixed``, and the maximum accepted page +count for ``full``. + +``theme`` is a complete Twig template name. It defaults to +``@UXPagination/theme/default.html.twig``. A theme passed directly to the +Twig function or component overrides it for that rendering only. Pass one +template, not a list. Compose application themes with Twig inheritance. + +Named paginators +---------------- + +Each entry under ``paginators`` inherits the root page, URL and navigation +settings, then applies only its explicit overrides: + +.. code-block:: yaml + + # config/packages/ux_pagination.yaml + ux_pagination: + items_per_page: 20 + navigation: + mode: sliding + size: 5 + + paginators: + blog: + items_per_page: 12 + navigation: + size: 7 + + admin: + items_per_page: 50 + navigation: + mode: fixed + size: 10 + +Inject the profile with a named argument:: + + // src/Controller/BlogController.php + public function __construct( + private readonly PaginatorInterface $blogPaginator, + ) { + } + +Use ``#[Target]`` when the argument name should describe its application +role:: + + // src/Controller/BlogController.php + use Symfony\Component\DependencyInjection\Attribute\Target; + + public function __construct( + #[Target('blog')] + private readonly PaginatorInterface $paginator, + ) { + } + +The explicit service escape hatch is ``ux_pagination.paginator.``:: + + // src/Controller/BlogController.php + use Symfony\Component\DependencyInjection\Attribute\Autowire; + + public function __construct( + #[Autowire(service: 'ux_pagination.paginator.blog')] + private readonly PaginatorInterface $paginator, + ) { + } + +Paginator names accept letters, digits, dots, dashes and underscores, but must +start with a letter or underscore. Names that resolve to the same autowiring +target are rejected. + +``theme`` and ``cursor.secret`` stay global. The theme belongs to +the renderer, and the signing secret is an application security invariant +rather than a paginator preference. + +Application entry points +------------------------ + +Inject ``PaginatorInterface`` and choose the smallest entry point that exposes +the policy the application needs: + +=============================== ================================================ +Method Use +=============================== ================================================ +``paginate(mixed, ?int, ?int)`` Numbered pagination with configured defaults +``query(mixed)`` Numbered builder for navigation, total and URLs +``fromCallbacks(...)`` Numbered builder for an offset slice and count + supplied by an API or custom data source +``cursor(mixed)`` Cursor builder with explicit ordering and scope +=============================== ================================================ + +``paginate()`` is the one-line application default:: + + // src/Controller/ProductController.php + $products = $paginator->paginate($repository->createListQuery()); + +The other entry points return immutable builders and end with +``->paginate()``. Cursor pagination intentionally has no argument-heavy +shortcut: ordering, page size, explicit cursor and application context remain +named configuration steps. + +Builder reference +----------------- + +Every builder method is immutable and returns a clone. Configure navigation +policy and URL composition on the builder, then call ``paginate()``. + +=============================== ================================================ +Method Effect +=============================== ================================================ +``perPage(int)`` Set the page size +``sliding(int)`` Consecutive page window of the requested size +``fixed(int)`` Fixed-size page block +``full(int)`` Every page, guarded by a maximum +``lookahead()`` Fetch N+1 rows and skip the count +``total(int|callable)`` Known or lazily computed exact total +``pageParameter(string)`` Override the page request parameter +``route(string, array)`` Generate links for another named route +``queryParameters(array)`` Append parameters to every generated URL +``preserveQueryString()`` Preserve Request query parameters (default) +``discardQueryString()`` Discard Request query parameters +``excludeQueryParameters(...)`` Exclude named Request parameters +``fragment(string)`` Add a URL fragment +``path(string)`` Use a custom path +``maxOffset(int)`` Override the configured offset limit +``throwOnOutOfRange()`` Run the count and throw a 404 past the last page +=============================== ================================================ + +Choose the total strategy explicitly: + +=============================== ================================================ +Need API +=============================== ================================================ +Adapter can count the source ``paginate()`` or ``query(...)->paginate()`` +Known or custom exact total ``query(...)->total(...)->paginate()`` +No total needed ``query(...)->lookahead()->paginate()`` +=============================== ================================================ + +``total()`` accepts a non-negative integer or a callable returning one. The +callable runs lazily and at most once, so an invokable Symfony service can be +passed directly, without a bundle-specific provider interface. + +Do not combine ``lookahead()`` with ``total()`` or ``throwOnOutOfRange()``. +Lookahead deliberately avoids the exact total those policies need, and the +builder rejects both combinations. + +URL methods configure the builder, not an already-created result. This keeps +one URL policy for the complete pagination lifecycle:: + + // src/Controller/ProductController.php + $products = $paginator + ->query($repository->createFilteredQuery($filters)) + ->excludeQueryParameters('debug') + ->queryParameters(['category' => $category->getSlug()]) + ->fragment('results') + ->paginate(); + +Cursor builder reference +------------------------ + +=================================== ================================================ +Method Effect +=================================== ================================================ +``orderBy(string|array, string)`` Field order for array/Doctrine adapters; + optional for adapter-owned remote orders +``perPage(int)`` Set the page size +``cursor(?string)`` Override automatic Request resolution +``cursorParameter(string)`` Override the cursor request parameter +``context(string)`` Bind tokens to an application boundary +``route(string, array)`` Generate links for another route +``queryParameters(array)`` Append parameters to links +``preserveQueryString()`` Preserve Request query parameters (default) +``discardQueryString()`` Discard Request query parameters +``excludeQueryParameters(...)`` Exclude named parameters +``fragment(string)`` Add a fragment +``path(string)`` Use a custom path +=================================== ================================================ + +Result reference +---------------- + +Both strategies implement ``PaginationInterface``. The result interfaces +intentionally expose only the state and links consumers need for iteration, +rendering and serialization. Application services can type against +``PaginationInterface`` when they only iterate items or expose adjacent +navigation: + +================================= ============================================== +Method Result +================================= ============================================== +``getItems()`` Items on the current slice +``count()`` Number of items on the current slice +``isEmpty()`` Whether the current slice has no items +``getItemsPerPage()`` Configured page size +``hasPrevious()`` Whether a previous URL is available +``hasNext()`` Whether a next URL is available +``getPreviousUrl()`` Nullable previous URL +``getNextUrl()`` Nullable next URL +``getInfo()`` Translated result summary +``jsonSerialize()`` Strategy-specific JSON representation +================================= ============================================== + +Numbered and lookahead results implement ``NumberedPaginationInterface``: + +============================ ============================================ +Method Result +============================ ============================================ +``getCurrentPage()`` Current one-based page +``getPageParameterName()`` Request parameter used for the current page +``getTotalItems()`` Exact total, or ``null`` for lookahead +``getTotalPages()`` Exact page count, or ``null`` for lookahead +``getFirstItemNumber()`` First one-based item position, or ``null`` +``getLastItemNumber()`` Last one-based item position, or ``null`` +``getUrl(int)`` URL for a numbered page +``getFirstUrl()`` First-page URL +``getLastUrl()`` Final-page URL, or ``null`` without a total +``getPages()`` Navigation links and gaps +``isFirst()`` / ``isLast()`` Boundary state +``isOutOfRange()`` Whether the requested page exceeds the total +============================ ============================================ + +Cursor results implement ``CursorPaginationInterface``: + +================================= ============================================== +Method Result +================================= ============================================== +``getCursor()`` Opaque cursor that produced the current slice +``getNextCursor()`` Nullable next opaque cursor +``getPreviousCursor()`` Nullable previous opaque cursor +``getCursorUrl(string)`` URL for an opaque cursor +================================= ============================================== + +Concrete result conveniences +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The concrete ``Pagination`` and ``CursorPagination`` classes also expose +immutable conveniences that are intentionally absent from the shared result +interfaces: + +=============================== ================================================ +Method Effect +=============================== ================================================ +``map(callable)`` Transform the current items into a new result +``throwOnOutOfRange()`` Throw a 404 for an invalid numbered result +``getAbsoluteUrl(int)`` Absolute URL for a numbered page +``getMetadata()`` Pagination metadata for API formats +``getLinks()`` API URLs (first/last/prev/next, or prev/next) +=============================== ================================================ + +``map()`` and ``getLinks()`` are available on both concrete result classes. +``throwOnOutOfRange()``, ``getAbsoluteUrl()`` and ``getMetadata()`` are +available on ``Pagination`` only. Prefer the builder method for the +out-of-range policy so it is visible before the result is created. + +Twig exposes conventional method access as properties, for example +``pagination.currentPage``, ``pagination.nextUrl`` and +``pagination.totalItems``. Guard nullable values with ``hasNext``, +``hasPrevious`` or an explicit ``null`` check. + +Twig function +------------- + +The function has an explicit signature: + +.. code-block:: twig + + ux_pagination( + pagination, + attributes = {}, + theme = null, + showInfo = true, + navigationAttributes = {}, + linkAttributes = {}, + ) + +``attributes`` apply to the root `` + {% endif %} +{% endblock %} + +{% block previous_label %} {{ 'Previous'|trans({}, 'UXPaginationBundle') }}{% endblock %} + +{% block next_label %}{{ 'Next'|trans({}, 'UXPaginationBundle') }} {% endblock %} + +{% block page_label %}{{ link.page }}{% endblock %} + +{% block attr %}{% for name, value in attr %}{% if value === true %} {{ name }}{% elseif value !== false and value is not null %} {{ name }}="{{ value }}"{% endif %}{% endfor %}{% endblock %} diff --git a/src/Pagination/tests/Adapter/ArrayCursorInvariantTest.php b/src/Pagination/tests/Adapter/ArrayCursorInvariantTest.php new file mode 100644 index 00000000000..505c94f1300 --- /dev/null +++ b/src/Pagination/tests/Adapter/ArrayCursorInvariantTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Cursor\CursorOrder; + +/** + * Invariant tests for cursor pagination on the array adapter. + * + * These do not test examples but properties: walking all pages must + * partition the dataset (no duplicate, no gap), backward navigation must + * be the exact inverse of forward navigation, and mutations between two + * page fetches must not create offset-shift skips or duplicates while the + * surviving items keep their ordered values. + */ +#[CoversClass(ArrayPaginationAdapter::class)] +final class ArrayCursorInvariantTest extends TestCase +{ + private ArrayPaginationAdapter $adapter; + private CursorOrder $ascendingIdOrder; + + protected function setUp(): void + { + $this->adapter = new ArrayPaginationAdapter(); + $this->ascendingIdOrder = CursorOrder::byFields(['id'], 'ASC'); + } + + public function testForwardWalkPartitionsDatasetAsc() + { + $source = $this->makeSource(23); + + $ids = $this->collectIds($this->walkForward($source, 5, 'id', 'ASC')); + + self::assertSame(range(1, 23), $ids); + } + + public function testForwardWalkPartitionsDatasetDesc() + { + $source = $this->makeSource(23); + + $ids = $this->collectIds($this->walkForward($source, 5, 'id', 'DESC')); + + self::assertSame(range(23, 1), $ids); + } + + public function testForwardWalkPartitionsDatasetWithCompositeCursor() + { + // Non-unique first field: 23 items sharing only 3 distinct prices + $source = []; + for ($i = 1; $i <= 23; ++$i) { + $source[] = ['id' => $i, 'price' => (float) ($i % 3)]; + } + + $pages = $this->walkForward($source, 4, ['price', 'id'], 'ASC'); + $ids = $this->collectIds($pages); + + self::assertCount(23, $ids, 'Every item must be seen exactly once'); + self::assertCount(23, array_unique($ids), 'No item may be duplicated'); + + // Display order must follow (price, id) lexicographically + $expected = $source; + usort($expected, static fn ($a, $b) => [$a['price'], $a['id']] <=> [$b['price'], $b['id']]); + self::assertSame(array_column($expected, 'id'), $ids); + } + + public function testBackwardWalkIsExactInverseOfForwardWalk() + { + $source = $this->makeSource(23); + + $forwardPages = $this->walkForward($source, 5, 'id', 'ASC'); + self::assertCount(5, $forwardPages); + + // Walk backward from the last page using previousCursor only + $backwardPages = []; + $cursor = $forwardPages[\count($forwardPages) - 1]->previous; + $guard = 0; + while (null !== $cursor && ++$guard < 50) { + $page = $this->adapter->sliceWithCursor($source, $cursor, 5, $this->ascendingIdOrder); + $backwardPages[] = $page; + $cursor = $page->previous; + } + + // Backward pages, reversed, must equal forward pages minus the last one + $expected = array_map( + static fn ($page) => array_column($page->items, 'id'), + \array_slice($forwardPages, 0, -1), + ); + $actual = array_reverse(array_map( + static fn ($page) => array_column($page->items, 'id'), + $backwardPages, + )); + + self::assertSame($expected, $actual); + } + + public function testDeletionBetweenPagesNeverSkipsSurvivors() + { + $source = $this->makeSource(15); + + $page1 = $this->adapter->sliceWithCursor($source, null, 5, $this->ascendingIdOrder); + self::assertSame(range(1, 5), array_column($page1->items, 'id')); + + // Delete one already-seen item and one upcoming item + $mutated = array_values(array_filter($source, static fn ($item) => !\in_array($item['id'], [3, 7], true))); + + $page2 = $this->adapter->sliceWithCursor($mutated, $page1->next, 5, $this->ascendingIdOrder); + + // Offset pagination would skip item 6 here; cursors must not + self::assertSame([6, 8, 9, 10, 11], array_column($page2->items, 'id')); + } + + public function testInsertionBetweenPagesNeverDuplicates() + { + $source = $this->makeSource(15); + + $page1 = $this->adapter->sliceWithCursor($source, null, 5, $this->ascendingIdOrder); + + // Insert one item before the cursor position and one after + $mutated = [...$source, ['id' => 0, 'name' => 'before'], ['id' => 99, 'name' => 'after']]; + + $seen = array_column($page1->items, 'id'); + $cursor = $page1->next; + $guard = 0; + while (null !== $cursor && ++$guard < 50) { + $page = $this->adapter->sliceWithCursor($mutated, $cursor, 5, $this->ascendingIdOrder); + $seen = [...$seen, ...array_column($page->items, 'id')]; + $cursor = $page->next; + } + + self::assertSame($seen, array_unique($seen), 'No item may appear twice across the walk'); + // Every original item after the first page, plus the appended one, is seen + self::assertSame([...range(1, 15), 99], array_values(array_intersect($seen, [...range(1, 15), 99]))); + } + + /** + * @return list + */ + private function makeSource(int $count): array + { + $source = []; + for ($i = 1; $i <= $count; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + return $source; + } + + /** + * @param list> $source + * @param string|list $field + * + * @return list, nextCursor: ?string, previousCursor: ?string, hasMore: bool}> + */ + private function walkForward(array $source, int $perPage, string|array $field, string $direction): array + { + $pages = []; + $cursor = null; + $guard = 0; + $order = CursorOrder::byFields((array) $field, $direction); + do { + $page = $this->adapter->sliceWithCursor($source, $cursor, $perPage, $order); + $pages[] = $page; + $cursor = $page->next; + } while (null !== $cursor && ++$guard < 50); + + return $pages; + } + + /** + * @param list}> $pages + * + * @return list + */ + private function collectIds(array $pages): array + { + $ids = []; + foreach ($pages as $page) { + $ids = [...$ids, ...array_column($page->items, 'id')]; + } + + return $ids; + } +} diff --git a/src/Pagination/tests/Adapter/ArrayPaginationAdapterTest.php b/src/Pagination/tests/Adapter/ArrayPaginationAdapterTest.php new file mode 100644 index 00000000000..c6cef486fcd --- /dev/null +++ b/src/Pagination/tests/Adapter/ArrayPaginationAdapterTest.php @@ -0,0 +1,375 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Cursor\CursorOrder; + +#[CoversClass(ArrayPaginationAdapter::class)] +final class ArrayPaginationAdapterTest extends TestCase +{ + private ArrayPaginationAdapter $adapter; + private CursorOrder $defaultOrder; + + protected function setUp(): void + { + $this->adapter = new ArrayPaginationAdapter(); + $this->defaultOrder = CursorOrder::byFields(['id'], 'ASC'); + } + + public function testSupportsArrays() + { + self::assertTrue($this->adapter->supports([1, 2, 3])); + self::assertTrue($this->adapter->supports([])); + self::assertFalse($this->adapter->supports('string')); + self::assertFalse($this->adapter->supports(42)); + self::assertFalse($this->adapter->supports(new \stdClass())); + } + + public function testCount() + { + self::assertSame(0, $this->adapter->count([])); + self::assertSame(3, $this->adapter->count([1, 2, 3])); + self::assertSame(100, $this->adapter->count(range(1, 100))); + } + + public function testCursorContextUsesTheExplicitApplicationContext() + { + self::assertSame('tenant-a:products', $this->adapter->getCursorContext([], 'tenant-a:products')); + } + + public function testCursorContextRequiresAnArraySource() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->getCursorContext(new \stdClass(), 'products'); + } + + public function testCursorContextRequiresAnExplicitContext() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('requires an explicit context()'); + $this->adapter->getCursorContext([], null); + } + + public function testCursorFieldsRequireAnArraySource() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->resolveCursorFields(new \stdClass(), 'id'); + } + + public function testCursorFieldsRejectAnEmptyOrder() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one non-empty cursor field'); + $this->adapter->resolveCursorFields([], []); + } + + public function testCursorFieldsAreNormalizedToAList() + { + self::assertSame(['id'], $this->adapter->resolveCursorFields([], 'id')); + } + + public function testCursorOrderMustBeExplicit() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires an explicit orderBy()'); + + $this->adapter->resolveCursorOrder([], null, null); + } + + public function testCursorOrderIsResolvedFromFieldsAndDirection() + { + $order = $this->adapter->resolveCursorOrder([], ['id'], 'desc'); + + self::assertSame(['id'], $order->getFields()); + self::assertSame('DESC', $order->getDirection()); + } + + public function testSlice() + { + $items = range(1, 50); + + self::assertSame([1, 2, 3, 4, 5], $this->adapter->slice($items, 0, 5)); + self::assertSame([11, 12, 13, 14, 15], $this->adapter->slice($items, 10, 5)); + self::assertSame([46, 47, 48, 49, 50], $this->adapter->slice($items, 45, 5)); + } + + public function testSliceBeyondEnd() + { + $items = [1, 2, 3]; + + self::assertSame([3], $this->adapter->slice($items, 2, 5)); + self::assertSame([], $this->adapter->slice($items, 10, 5)); + } + + public function testSliceWithLookahead() + { + $items = range(1, 50); + + // Page 1 of 10 items: should have more + [$slice, $hasMore] = $this->adapter->sliceWithLookahead($items, 0, 10); + self::assertCount(10, $slice); + self::assertTrue($hasMore); + + // Last page: should not have more + [$slice, $hasMore] = $this->adapter->sliceWithLookahead($items, 40, 10); + self::assertCount(10, $slice); + self::assertFalse($hasMore); + + // Partial last page + [$slice, $hasMore] = $this->adapter->sliceWithLookahead($items, 45, 10); + self::assertCount(5, $slice); + self::assertFalse($hasMore); + } + + public function testCountThrowsForNonArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->count('not an array'); + } + + public function testSliceThrowsForNonArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->slice('not an array', 0, 10); + } + + public function testSliceWithLookaheadThrowsForNonArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->sliceWithLookahead('not an array', 0, 10); + } + + public function testSliceWithCursorFirstPage() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $result = $this->adapter->sliceWithCursor($source, null, 10, $this->defaultOrder); + + self::assertCount(10, $result->items); + self::assertSame(1, $result->items[0]['id']); + self::assertTrue($result->hasNext); + self::assertNotNull($result->next); + self::assertNull($result->previous); + } + + public function testSliceWithCursorRejectsInvalidDirection() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('direction must be "ASC" or "DESC"'); + CursorOrder::byFields(['id'], 'sideways'); + } + + public function testSliceWithCursorSecondPage() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + // Get first page cursor + $page1 = $this->adapter->sliceWithCursor($source, null, 10, $this->defaultOrder); + $cursor = $page1->next; + self::assertNotNull($cursor); + + // Second page + $page2 = $this->adapter->sliceWithCursor($source, $cursor, 10, $this->defaultOrder); + + self::assertCount(10, $page2->items); + self::assertSame(11, $page2->items[0]['id']); + self::assertTrue($page2->hasNext); + self::assertNotNull($page2->previous); + self::assertNotSame($cursor, $page2->previous); + } + + public function testSliceWithCursorBackwardReturnsPreviousPage() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $page1 = $this->adapter->sliceWithCursor($source, null, 10, $this->defaultOrder); + $page2 = $this->adapter->sliceWithCursor($source, $page1->next, 10, $this->defaultOrder); + self::assertSame(11, $page2->items[0]['id']); + + // Navigate backward: must land on page 1 items, in display order + $back = $this->adapter->sliceWithCursor($source, $page2->previous, 10, $this->defaultOrder); + + self::assertCount(10, $back->items); + self::assertSame(1, $back->items[0]['id']); + self::assertSame(10, $back->items[9]['id']); + // First page again: nothing before it + self::assertNull($back->previous); + // The page we came from is the next page + self::assertNotNull($back->next); + $forwardAgain = $this->adapter->sliceWithCursor($source, $back->next, 10, $this->defaultOrder); + self::assertSame(11, $forwardAgain->items[0]['id']); + } + + public function testSliceWithCursorBackwardFromMiddleKeepsPreviousCursor() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $page1 = $this->adapter->sliceWithCursor($source, null, 10, $this->defaultOrder); + $page2 = $this->adapter->sliceWithCursor($source, $page1->next, 10, $this->defaultOrder); + $page3 = $this->adapter->sliceWithCursor($source, $page2->next, 10, $this->defaultOrder); + self::assertSame(21, $page3->items[0]['id']); + + // Back to page 2: a previous page (page 1) still exists + $back = $this->adapter->sliceWithCursor($source, $page3->previous, 10, $this->defaultOrder); + + self::assertSame(11, $back->items[0]['id']); + self::assertSame(20, $back->items[9]['id']); + self::assertNotNull($back->previous); + } + + public function testSliceWithCursorDateTimeField() + { + $source = [ + ['id' => 1, 'createdAt' => new \DateTimeImmutable('2024-01-01 10:00:00')], + ['id' => 2, 'createdAt' => new \DateTimeImmutable('2024-02-01 10:00:00')], + ['id' => 3, 'createdAt' => new \DateTimeImmutable('2024-03-01 10:00:00')], + ]; + + $order = CursorOrder::byFields(['createdAt'], 'ASC'); + $page1 = $this->adapter->sliceWithCursor($source, null, 2, $order); + + self::assertCount(2, $page1->items); + self::assertSame(1, $page1->items[0]['id']); + self::assertNotNull($page1->next); + + $page2 = $this->adapter->sliceWithCursor($source, $page1->next, 2, $order); + + self::assertCount(1, $page2->items); + self::assertSame(3, $page2->items[0]['id']); + } + + public function testCursorDateTimesAreOrderedByInstantAcrossTimezones() + { + $source = [ + // 22:30 UTC: lexicographically later before UTC normalization. + ['id' => 1, 'createdAt' => new \DateTimeImmutable('2024-01-01 00:30:00+02:00')], + // 23:00 UTC. + ['id' => 2, 'createdAt' => new \DateTimeImmutable('2023-12-31 23:00:00+00:00')], + ]; + + $order = CursorOrder::byFields(['createdAt'], 'ASC'); + $first = $this->adapter->sliceWithCursor($source, null, 1, $order); + self::assertSame([1], array_column($first->items, 'id')); + self::assertNotNull($first->next); + + $second = $this->adapter->sliceWithCursor($source, $first->next, 1, $order); + self::assertSame([2], array_column($second->items, 'id')); + } + + public function testSliceWithCursorLastPage() + { + $source = []; + for ($i = 1; $i <= 15; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $page1 = $this->adapter->sliceWithCursor($source, null, 10, $this->defaultOrder); + $page2 = $this->adapter->sliceWithCursor($source, $page1->next, 10, $this->defaultOrder); + + self::assertCount(5, $page2->items); + self::assertFalse($page2->hasNext); + self::assertNull($page2->next); + } + + public function testSliceWithCursorDescDirection() + { + $source = []; + for ($i = 1; $i <= 20; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $result = $this->adapter->sliceWithCursor($source, null, 5, CursorOrder::byFields(['id'], 'DESC')); + + self::assertCount(5, $result->items); + // DESC order: highest IDs first + self::assertSame(20, $result->items[0]['id']); + self::assertTrue($result->hasNext); + } + + public function testSliceWithCursorMultipleFields() + { + $source = [ + ['id' => 1, 'price' => 10.0], + ['id' => 2, 'price' => 10.0], + ['id' => 3, 'price' => 20.0], + ['id' => 4, 'price' => 20.0], + ['id' => 5, 'price' => 30.0], + ]; + + $result = $this->adapter->sliceWithCursor($source, null, 3, CursorOrder::byFields(['price', 'id'], 'ASC')); + + self::assertCount(3, $result->items); + self::assertTrue($result->hasNext); + } + + public function testSliceWithCursorMismatchedFieldsThrows() + { + $source = [['id' => 1, 'price' => 10.0]]; + + $cursor = new \Symfony\UX\Pagination\Cursor\CursorBoundary([1]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor values count does not match'); + + $this->adapter->sliceWithCursor($source, $cursor, 10, CursorOrder::byFields(['price', 'id'], 'ASC')); + } + + public function testSliceWithCursorThrowsForNonArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->adapter->sliceWithCursor('not an array', null, 10, $this->defaultOrder); + } + + public function testSliceWithCursorRejectsAnOpaqueOrder() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires a field-based cursor order'); + + $this->adapter->sliceWithCursor([], null, 10, CursorOrder::byIdentity('remote-order')); + } + + public function testCursorRejectsDuplicateTuplesWithoutUniqueTieBreaker() + { + $source = [ + ['id' => 1, 'category' => 'same'], + ['id' => 2, 'category' => 'same'], + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('duplicate cursor tuples'); + $this->adapter->sliceWithCursor($source, null, 10, CursorOrder::byFields(['category'], 'ASC')); + } + + public function testSliceWithCursorEmptySource() + { + $result = $this->adapter->sliceWithCursor([], null, 10, $this->defaultOrder); + + self::assertCount(0, $result->items); + self::assertFalse($result->hasNext); + self::assertNull($result->next); + } +} diff --git a/src/Pagination/tests/Adapter/CallablePaginationAdapterTest.php b/src/Pagination/tests/Adapter/CallablePaginationAdapterTest.php new file mode 100644 index 00000000000..87848cf87f0 --- /dev/null +++ b/src/Pagination/tests/Adapter/CallablePaginationAdapterTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\CallablePaginationAdapter; + +#[CoversClass(CallablePaginationAdapter::class)] +final class CallablePaginationAdapterTest extends TestCase +{ + public function testSupportsReturnsFalse() + { + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): array => [], + static fn (): int => 0, + ); + + // Callable adapter is never auto-discovered + self::assertFalse($adapter->supports('anything')); + } + + public function testSliceAndCount() + { + $data = range(1, 50); + + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): array => \array_slice($data, $offset, $limit), + static fn (): int => \count($data), + ); + + self::assertSame(50, $adapter->count(null)); + self::assertSame([1, 2, 3, 4, 5], $adapter->slice(null, 0, 5)); + self::assertSame([11, 12, 13, 14, 15], $adapter->slice(null, 10, 5)); + } + + public function testSliceWithLookahead() + { + $data = range(1, 25); + + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): array => \array_slice($data, $offset, $limit), + static fn (): int => \count($data), + ); + + [$items, $hasMore] = $adapter->sliceWithLookahead(null, 0, 10); + self::assertCount(10, $items); + self::assertTrue($hasMore); + + [$items, $hasMore] = $adapter->sliceWithLookahead(null, 20, 10); + self::assertCount(5, $items); + self::assertFalse($hasMore); + } + + public function testSliceThrowsForNonArray() + { + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): string => 'not an array', // @phpstan-ignore return.type + static fn (): int => 0, + ); + + $this->expectException(\RuntimeException::class); + $adapter->slice(null, 0, 10); + } + + public function testSliceWithLookaheadThrowsForNonArray() + { + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): string => 'not an array', // @phpstan-ignore return.type + static fn (): int => 0, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Slicer callback must return an array'); + $adapter->sliceWithLookahead(null, 0, 10); + } + + public function testCountRejectsANonIntegerResult() + { + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): array => [], + static fn (): string => '10', // @phpstan-ignore argument.type + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Counter callback must return a non-negative integer'); + $adapter->count(null); + } + + public function testCountRejectsANegativeResult() + { + $adapter = new CallablePaginationAdapter( + static fn (int $offset, int $limit): array => [], + static fn (): int => -1, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Counter callback must return a non-negative integer'); + $adapter->count(null); + } +} diff --git a/src/Pagination/tests/Adapter/CursorValuesTraitTest.php b/src/Pagination/tests/Adapter/CursorValuesTraitTest.php new file mode 100644 index 00000000000..5c3ea5ca2c2 --- /dev/null +++ b/src/Pagination/tests/Adapter/CursorValuesTraitTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\CursorValuesTrait; + +#[CoversTrait(CursorValuesTrait::class)] +final class CursorValuesTraitTest extends TestCase +{ + private object $encoder; + + protected function setUp(): void + { + // Create anonymous class using the trait for testing + $this->encoder = new class { + use CursorValuesTrait { + extractCursorValues as public; + compareTuples as public; + } + }; + } + + public function testExtractFromObjectGetter() + { + $item = new class { + public function getId(): int + { + return 42; + } + }; + + self::assertSame([42], $this->encoder->extractCursorValues($item, ['id'])); + } + + public function testExtractFromObjectIsGetter() + { + $item = new class { + public function isActive(): bool + { + return true; + } + }; + + self::assertSame([1], $this->encoder->extractCursorValues($item, ['active'])); + } + + public function testExtractFromPublicProperty() + { + $item = new class { + public string $name = 'Alice'; + }; + + self::assertSame(['Alice'], $this->encoder->extractCursorValues($item, ['name'])); + } + + public function testPrivatePropertyWithoutGetterIsReportedAsInaccessible() + { + $item = new class { + private string $name = 'Alice'; + }; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot extract cursor field "name"'); + + $this->encoder->extractCursorValues($item, ['name']); + } + + public function testExtractFromArrayKey() + { + self::assertSame([7, 'b'], $this->encoder->extractCursorValues(['id' => 7, 'code' => 'b'], ['id', 'code'])); + } + + public function testExtractFromScalarWithIdField() + { + self::assertSame([5], $this->encoder->extractCursorValues(5, ['id'])); + } + + public function testExtractNormalizesDateTime() + { + $item = ['createdAt' => new \DateTimeImmutable('2024-06-15 10:30:00')]; + + self::assertSame(['2024-06-15T10:30:00.000000Z'], $this->encoder->extractCursorValues($item, ['createdAt'])); + } + + public function testExtractNormalizesDateTimeFromGetter() + { + $item = new class { + public function getCreatedAt(): \DateTimeImmutable + { + return new \DateTimeImmutable('2023-01-02 03:04:05'); + } + }; + + self::assertSame(['2023-01-02T03:04:05.000000Z'], $this->encoder->extractCursorValues($item, ['createdAt'])); + } + + public function testExtractRejectsNull() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must be non-null'); + + $this->encoder->extractCursorValues(['field' => null], ['field']); + } + + public function testExtractNormalizesBool() + { + self::assertSame([1, 0], $this->encoder->extractCursorValues(['a' => true, 'b' => false], ['a', 'b'])); + } + + public function testExtractThrowsForMissingField() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot extract cursor field "missing"'); + + $this->encoder->extractCursorValues(['id' => 1], ['missing']); + } + + public function testExtractThrowsForUnsupportedValueType() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must be scalar or \DateTimeInterface'); + + $this->encoder->extractCursorValues(['field' => new \stdClass()], ['field']); + } + + public function testCompareTuplesEqual() + { + self::assertSame(0, $this->encoder->compareTuples([1, 'a'], [1, 'a'])); + } + + public function testCompareTuplesFirstFieldWins() + { + self::assertGreaterThan(0, $this->encoder->compareTuples([2, 'a'], [1, 'z'])); + self::assertLessThan(0, $this->encoder->compareTuples([1, 'z'], [2, 'a'])); + } + + public function testCompareTuplesFallsBackToNextField() + { + self::assertGreaterThan(0, $this->encoder->compareTuples([1, 'b'], [1, 'a'])); + self::assertLessThan(0, $this->encoder->compareTuples([1, 'a'], [1, 'b'])); + } + + public function testCompareTuplesWithDateStrings() + { + self::assertLessThan(0, $this->encoder->compareTuples(['2024-01-01 00:00:00'], ['2024-06-15 10:30:00'])); + } +} diff --git a/src/Pagination/tests/Adapter/DoctrineCursorInvariantTest.php b/src/Pagination/tests/Adapter/DoctrineCursorInvariantTest.php new file mode 100644 index 00000000000..282575de537 --- /dev/null +++ b/src/Pagination/tests/Adapter/DoctrineCursorInvariantTest.php @@ -0,0 +1,296 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\Tools\SchemaTool; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\DoctrineOrmAdapter; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Paginator; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Book; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Category; +use Symfony\UX\Pagination\Tests\Fixtures\EntityManagerFactory; + +/** + * Invariant tests for cursor pagination on the Doctrine ORM adapter. + * + * Walking all pages must partition the dataset (no duplicate, no gap), + * backward navigation must be the exact inverse of forward navigation, + * and insert/delete mutations that do not change surviving order values must + * not reproduce the offset-shift skips or duplicates. + */ +#[CoversClass(DoctrineOrmAdapter::class)] +final class DoctrineCursorInvariantTest extends TestCase +{ + private EntityManager $entityManager; + private DoctrineOrmAdapter $adapter; + private CursorOrder $ascendingIdOrder; + + protected function setUp(): void + { + if (!class_exists(EntityManager::class)) { + self::markTestSkipped('Doctrine ORM is not installed.'); + } + + $this->entityManager = EntityManagerFactory::create(); + + $this->adapter = new DoctrineOrmAdapter(); + $this->ascendingIdOrder = CursorOrder::byFields(['id'], 'ASC'); + } + + protected function tearDown(): void + { + if (isset($this->entityManager)) { + $this->entityManager->close(); + } + } + + public function testForwardWalkPartitionsDataset() + { + $this->createAuthors(23); + + $ids = []; + $cursor = null; + $guard = 0; + do { + $page = $this->adapter->sliceWithCursor($this->queryBuilder(), $cursor, 5, $this->ascendingIdOrder); + $ids = [...$ids, ...$this->idsOf($page->items)]; + $cursor = $page->next; + } while (null !== $cursor && ++$guard < 50); + + self::assertSame(range(1, 23), $ids); + } + + public function testBackwardWalkIsExactInverseOfForwardWalk() + { + $this->createAuthors(23); + + $forwardPages = []; + $cursor = null; + $guard = 0; + do { + $page = $this->adapter->sliceWithCursor($this->queryBuilder(), $cursor, 5, $this->ascendingIdOrder); + $forwardPages[] = $this->idsOf($page->items); + $cursor = $page->next; + $lastPage = $page; + } while (null !== $cursor && ++$guard < 50); + + self::assertCount(5, $forwardPages); + + $backwardPages = []; + $cursor = $lastPage->previous; + $guard = 0; + while (null !== $cursor && ++$guard < 50) { + $page = $this->adapter->sliceWithCursor($this->queryBuilder(), $cursor, 5, $this->ascendingIdOrder); + $backwardPages[] = $this->idsOf($page->items); + $cursor = $page->previous; + } + + self::assertSame(\array_slice($forwardPages, 0, -1), array_reverse($backwardPages)); + } + + public function testDeletionBetweenPagesNeverSkipsSurvivors() + { + $this->createAuthors(15); + + $page1 = $this->adapter->sliceWithCursor($this->queryBuilder(), null, 5, $this->ascendingIdOrder); + self::assertSame(range(1, 5), $this->idsOf($page1->items)); + + // Delete one already-seen row and one upcoming row + foreach ([3, 7] as $id) { + $author = $this->entityManager->find(Author::class, $id); + self::assertNotNull($author); + $this->entityManager->remove($author); + } + $this->entityManager->flush(); + + $page2 = $this->adapter->sliceWithCursor($this->queryBuilder(), $page1->next, 5, $this->ascendingIdOrder); + + // Offset pagination would skip id 6 here; cursors must not + self::assertSame([6, 8, 9, 10, 11], $this->idsOf($page2->items)); + } + + public function testInsertionBetweenPagesNeverDuplicates() + { + $this->createAuthors(15); + + $page1 = $this->adapter->sliceWithCursor($this->queryBuilder(), null, 5, $this->ascendingIdOrder); + + // New row inserted mid-walk (gets id 16, after the cursor position) + $author = new Author(); + $author->setName('Inserted'); + $this->entityManager->persist($author); + $this->entityManager->flush(); + + $seen = $this->idsOf($page1->items); + $cursor = $page1->next; + $guard = 0; + while (null !== $cursor && ++$guard < 50) { + $page = $this->adapter->sliceWithCursor($this->queryBuilder(), $cursor, 5, $this->ascendingIdOrder); + $seen = [...$seen, ...$this->idsOf($page->items)]; + $cursor = $page->next; + } + + self::assertSame($seen, array_unique($seen), 'No row may appear twice across the walk'); + self::assertSame(range(1, 16), $seen, 'Every row, including the inserted one, is seen exactly once'); + } + + public function testDatetimeCursorBoundariesAreStableInNonUtcDefaultTimezone() + { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('Europe/Paris'); + + try { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->createSchema([ + $this->entityManager->getClassMetadata(Book::class), + $this->entityManager->getClassMetadata(Category::class), + ]); + + for ($i = 1; $i <= 23; ++$i) { + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setPublishedAt(new \DateTimeImmutable(\sprintf('2024-06-01 12:%02d:00', $i))); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + $this->entityManager->clear(); + + $order = CursorOrder::byFields(['publishedAt', 'id'], 'ASC'); + + $ids = []; + $cursor = null; + $guard = 0; + do { + $page = $this->adapter->sliceWithCursor($this->bookQueryBuilder(), $cursor, 5, $order); + $ids = [...$ids, ...array_map(static fn (Book $book) => $book->getId(), $page->items)]; + $cursor = $page->next; + } while (null !== $cursor && ++$guard < 50); + + self::assertSame(range(1, 23), $ids); + } finally { + date_default_timezone_set($previousTimezone); + } + } + + public function testExistingOrderIsRejectedEvenWhenCompatible() + { + $this->createAuthors(8); + $query = $this->queryBuilder()->orderBy('a.id', 'ASC'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cursor pagination owns ORDER BY'); + $this->adapter->sliceWithCursor($query, null, 5, $this->ascendingIdOrder); + } + + public function testIncompatibleExistingOrderIsRejected() + { + $this->createAuthors(8); + $query = $this->queryBuilder()->orderBy('a.name', 'ASC'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cursor pagination owns ORDER BY'); + $this->adapter->sliceWithCursor($query, null, 5, $this->ascendingIdOrder); + } + + public function testAutomaticContextBindsDoctrineDqlAndParameters() + { + $this->createAuthors(12); + $paginator = new Paginator([$this->adapter], cursorCodec: new CursorCodec('test-secret')); + $firstQuery = $this->queryBuilder() + ->andWhere('a.name LIKE :pattern') + ->setParameter('pattern', 'Author %'); + $token = $paginator->cursor($firstQuery) + ->orderBy('id', 'ASC') + ->perPage(5) + ->paginate() + ->getNextCursor(); + self::assertNotNull($token); + + $otherQuery = $this->queryBuilder() + ->andWhere('a.name LIKE :pattern') + ->setParameter('pattern', 'Other %'); + $builder = $paginator->cursor($otherQuery) + ->orderBy('id', 'ASC') + ->perPage(5) + ->cursor($token); + + $this->expectException(InvalidCursorException::class); + $builder->paginate(); + } + + public function testSignedOrderIncludesAutomaticallyAppendedIdentifierFields() + { + $this->createAuthors(6); + $codec = new CursorCodec('test-secret'); + $paginator = new Paginator([$this->adapter], cursorCodec: $codec); + $query = $this->queryBuilder(); + + $token = $paginator->cursor($query) + ->orderBy('name', 'ASC') + ->perPage(2) + ->paginate() + ->getNextCursor(); + + self::assertNotNull($token); + $effectiveOrder = $this->adapter + ->resolveCursorOrder($query, ['name'], 'ASC') + ->getFingerprint(); + $decoded = $codec->decode( + $token, + $effectiveOrder, + $this->adapter->getCursorContext($query, null), + ); + + self::assertCount(2, $decoded['values']); + } + + private function createAuthors(int $count): void + { + for ($i = 1; $i <= $count; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + } + + private function queryBuilder(): QueryBuilder + { + return $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + } + + private function bookQueryBuilder(): QueryBuilder + { + return $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + } + + /** + * @param list $items + * + * @return list + */ + private function idsOf(array $items): array + { + return array_map(static fn (Author $author) => $author->getId(), $items); + } +} diff --git a/src/Pagination/tests/Adapter/DoctrineDbalAdapterTest.php b/src/Pagination/tests/Adapter/DoctrineDbalAdapterTest.php new file mode 100644 index 00000000000..eb7d12957e4 --- /dev/null +++ b/src/Pagination/tests/Adapter/DoctrineDbalAdapterTest.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Schema\Table; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\DoctrineDbalAdapter; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Exception\RuntimeException; + +#[CoversClass(DoctrineDbalAdapter::class)] +final class DoctrineDbalAdapterTest extends TestCase +{ + private Connection $connection; + private DoctrineDbalAdapter $adapter; + + protected function setUp(): void + { + $this->connection = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]); + $this->adapter = new DoctrineDbalAdapter(); + + $table = new Table('items'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('category', 'string', ['length' => 32]); + $table->addColumn('name', 'string', ['length' => 255]); + $table->setPrimaryKey(['id']); + $this->connection->createSchemaManager()->createTable($table); + + for ($id = 1; $id <= 25; ++$id) { + $this->connection->insert('items', [ + 'category' => 0 === $id % 2 ? 'even' : 'odd', + 'name' => 'Item '.$id, + ]); + } + } + + protected function tearDown(): void + { + $this->connection->close(); + } + + public function testSupportsDbalQueryBuilder() + { + self::assertTrue($this->adapter->supports($this->query())); + self::assertFalse($this->adapter->supports([])); + } + + public function testSlicesWithoutMutatingTheSource() + { + $source = $this->query()->orderBy('id', 'ASC'); + + $items = $this->adapter->slice($source, 10, 5); + + self::assertSame([11, 12, 13, 14, 15], array_column($items, 'id')); + self::assertCount(25, $source->executeQuery()->fetchAllAssociative()); + } + + public function testCountsAFilteredQueryAndIgnoresOrderingAndLimits() + { + $source = $this->query() + ->where('category = :category') + ->setParameter('category', 'even') + ->orderBy('id', 'DESC') + ->setFirstResult(2) + ->setMaxResults(3); + + self::assertSame(12, $this->adapter->count($source)); + } + + public function testCursorContextFingerprintsSqlParametersTypesAndApplicationContext() + { + $source = $this->query() + ->where('category = :category') + ->andWhere('id > :filters') + ->setParameter('category', 'odd', ParameterType::STRING) + ->setParameter('filters', [ + 'date' => new \DateTimeImmutable('2026-07-28 12:34:56.123456+02:00'), + 'backed' => DbalBackedContext::Books, + 'unit' => DbalUnitContext::Catalog, + 'scalar' => 10, + 'null' => null, + ]); + + $context = json_decode($this->adapter->getCursorContext($source, 'tenant-a'), true, flags: \JSON_THROW_ON_ERROR); + + self::assertSame(DoctrineDbalAdapter::class, $context['adapter']); + self::assertSame('tenant-a', $context['context']); + self::assertSame(['category', 'filters'], array_keys($context['parameters'])); + self::assertSame(ParameterType::STRING->name, $context['parameters']['category']['type']); + self::assertSame('odd', $context['parameters']['category']['value']); + self::assertSame('2026-07-28 12:34:56.123456+02:00', $context['parameters']['filters']['value']['date']['value']); + self::assertSame('books', $context['parameters']['filters']['value']['backed']['value']); + self::assertSame('Catalog', $context['parameters']['filters']['value']['unit']['name']); + + self::assertNotSame( + $this->adapter->getCursorContext($source, 'tenant-a'), + $this->adapter->getCursorContext($source, 'tenant-b'), + ); + } + + public function testCursorContextRejectsUnsupportedSources() + { + $this->expectException(InvalidArgumentException::class); + $this->adapter->getCursorContext([], null); + } + + public function testCursorContextRejectsUnstableParameterValues() + { + $source = $this->query()->setParameter('invalid', new \stdClass()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot derive a stable cursor context'); + $this->adapter->getCursorContext($source, null); + } + + public function testSlicesWithLookahead() + { + [$items, $hasMore] = $this->adapter->sliceWithLookahead($this->query()->orderBy('id', 'ASC'), 10, 10); + + self::assertSame(range(11, 20), array_column($items, 'id')); + self::assertTrue($hasMore); + } + + public function testCursorPaginatesForwardAndBackward() + { + $source = $this->query(); + $order = CursorOrder::byFields(['id'], 'ASC'); + + $first = $this->adapter->sliceWithCursor($source, null, 10, $order); + self::assertSame(range(1, 10), array_column($first->items, 'id')); + self::assertNotNull($first->next); + self::assertNull($first->previous); + + $second = $this->adapter->sliceWithCursor($source, $first->next, 10, $order); + self::assertSame(range(11, 20), array_column($second->items, 'id')); + self::assertNotNull($second->next); + self::assertNotNull($second->previous); + + $back = $this->adapter->sliceWithCursor($source, $second->previous, 10, $order); + self::assertSame(range(1, 10), array_column($back->items, 'id')); + self::assertNull($back->previous); + self::assertNotNull($back->next); + } + + public function testCursorDoesNotOverwriteApplicationParameters() + { + $source = $this->query() + ->where('id > :ux_pagination_cursor_0') + ->setParameter('ux_pagination_cursor_0', 8); + + $result = $this->adapter->sliceWithCursor( + $source, + new CursorBoundary([3]), + 2, + CursorOrder::byFields(['id'], 'ASC'), + ); + + self::assertSame([9, 10], array_column($result->items, 'id')); + } + + public function testCursorBackwardFromThirdPageKeepsBothDirections() + { + $source = $this->query(); + $order = CursorOrder::byFields(['id'], 'ASC'); + $first = $this->adapter->sliceWithCursor($source, null, 10, $order); + $second = $this->adapter->sliceWithCursor($source, $first->next, 10, $order); + $third = $this->adapter->sliceWithCursor($source, $second->next, 10, $order); + $back = $this->adapter->sliceWithCursor($source, $third->previous, 10, $order); + + self::assertSame(range(11, 20), array_column($back->items, 'id')); + self::assertNotNull($back->previous); + self::assertNotNull($back->next); + } + + public function testCursorBackwardBeforeFirstItemReturnsAnEmptySlice() + { + $result = $this->adapter->sliceWithCursor( + $this->query(), + new CursorBoundary([1], false), + 10, + CursorOrder::byFields(['id'], 'ASC'), + ); + + self::assertSame([], $result->items); + self::assertNull($result->previous); + self::assertNull($result->next); + self::assertFalse($result->hasNext); + } + + public function testCursorSupportsCompositeQualifiedFields() + { + $source = $this->connection->createQueryBuilder() + ->select('i.id', 'i.category', 'i.name') + ->from('items', 'i'); + $order = CursorOrder::byFields(['i.category', 'i.id'], 'ASC'); + + $first = $this->adapter->sliceWithCursor($source, null, 5, $order); + self::assertCount(5, $first->items); + self::assertNotNull($first->next); + + $second = $this->adapter->sliceWithCursor($source, $first->next, 5, $order); + self::assertCount(5, $second->items); + self::assertNotSame(array_column($first->items, 'id'), array_column($second->items, 'id')); + } + + public function testCursorRejectsUnsafeFieldNamesAndExistingOrder() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid DBAL cursor field'); + + $this->adapter->resolveCursorOrder($this->query(), ['id DESC; DELETE'], 'ASC'); + } + + public function testCursorRejectsInvalidDirection() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('direction must be "ASC" or "DESC"'); + $this->adapter->resolveCursorOrder($this->query(), ['id'], 'sideways'); + } + + public function testCursorRejectsAnEmptyFieldList() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one cursor field'); + $this->adapter->resolveCursorOrder($this->query(), [], 'ASC'); + } + + public function testCursorRejectsANonStringField() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be non-empty strings'); + + $this->adapter->resolveCursorFields($this->query(), [42]); // @phpstan-ignore argument.type + } + + public function testCursorOrderMustBeExplicit() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires an explicit orderBy()'); + + $this->adapter->resolveCursorOrder($this->query(), null, null); + } + + public function testCursorRejectsAnExistingOrder() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('owns ORDER BY'); + $this->adapter->sliceWithCursor($this->query()->orderBy('id'), null, 10, CursorOrder::byFields(['id'], 'ASC')); + } + + public function testCursorRejectsMismatchedBoundary() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor values count does not match'); + + $this->adapter->sliceWithCursor($this->query(), new CursorBoundary([1]), 10, CursorOrder::byFields(['category', 'id'], 'ASC')); + } + + public function testCursorRejectsAnOpaqueOrder() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires a field-based cursor order'); + + $this->adapter->sliceWithCursor($this->query(), null, 10, CursorOrder::byIdentity('remote-order')); + } + + private function query(): QueryBuilder + { + return $this->connection->createQueryBuilder() + ->select('id', 'category', 'name') + ->from('items'); + } +} + +enum DbalBackedContext: string +{ + case Books = 'books'; +} + +enum DbalUnitContext +{ + case Catalog; +} diff --git a/src/Pagination/tests/Adapter/DoctrineDbalCursorInvariantTest.php b/src/Pagination/tests/Adapter/DoctrineDbalCursorInvariantTest.php new file mode 100644 index 00000000000..36357bf5e9a --- /dev/null +++ b/src/Pagination/tests/Adapter/DoctrineDbalCursorInvariantTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Schema\Table; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\DoctrineDbalAdapter; +use Symfony\UX\Pagination\Cursor\CursorOrder; + +/** + * Cursor stability invariants for the Doctrine DBAL adapter. + */ +#[CoversClass(DoctrineDbalAdapter::class)] +final class DoctrineDbalCursorInvariantTest extends TestCase +{ + private Connection $connection; + private DoctrineDbalAdapter $adapter; + private CursorOrder $ascendingIdOrder; + + protected function setUp(): void + { + $this->connection = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]); + $this->adapter = new DoctrineDbalAdapter(); + $this->ascendingIdOrder = CursorOrder::byFields(['id'], 'ASC'); + + $table = new Table('items'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('name', 'string', ['length' => 255]); + $table->setPrimaryKey(['id']); + $this->connection->createSchemaManager()->createTable($table); + + for ($id = 1; $id <= 15; ++$id) { + $this->connection->insert('items', ['name' => 'Item '.$id]); + } + } + + protected function tearDown(): void + { + $this->connection->close(); + } + + public function testForwardWalkPartitionsDataset() + { + $seen = []; + $cursor = null; + do { + $page = $this->adapter->sliceWithCursor($this->query(), $cursor, 4, $this->ascendingIdOrder); + $seen = [...$seen, ...array_column($page->items, 'id')]; + $cursor = $page->next; + } while (null !== $cursor); + + self::assertSame(range(1, 15), $seen); + } + + public function testBackwardWalkIsExactInverseOfForwardWalk() + { + $forwardPages = []; + $cursor = null; + $guard = 0; + do { + $page = $this->adapter->sliceWithCursor($this->query(), $cursor, 4, $this->ascendingIdOrder); + $forwardPages[] = array_column($page->items, 'id'); + $cursor = $page->next; + $lastPage = $page; + } while (null !== $cursor && ++$guard < 50); + + self::assertCount(4, $forwardPages); + + $backwardPages = []; + $cursor = $lastPage->previous; + $guard = 0; + while (null !== $cursor && ++$guard < 50) { + $page = $this->adapter->sliceWithCursor($this->query(), $cursor, 4, $this->ascendingIdOrder); + $backwardPages[] = array_column($page->items, 'id'); + $cursor = $page->previous; + } + + self::assertSame(\array_slice($forwardPages, 0, -1), array_reverse($backwardPages)); + } + + public function testDeletionBetweenPagesDoesNotSkipSurvivingRows() + { + $first = $this->adapter->sliceWithCursor($this->query(), null, 5, $this->ascendingIdOrder); + self::assertSame(range(1, 5), array_column($first->items, 'id')); + + $this->connection->delete('items', ['id' => 3]); + $this->connection->delete('items', ['id' => 7]); + + $second = $this->adapter->sliceWithCursor($this->query(), $first->next, 5, $this->ascendingIdOrder); + + self::assertSame([6, 8, 9, 10, 11], array_column($second->items, 'id')); + } + + public function testInsertionBetweenPagesDoesNotDuplicateRows() + { + $first = $this->adapter->sliceWithCursor($this->query(), null, 5, $this->ascendingIdOrder); + $this->connection->insert('items', ['name' => 'Inserted']); + + $seen = array_column($first->items, 'id'); + $cursor = $first->next; + while (null !== $cursor) { + $page = $this->adapter->sliceWithCursor($this->query(), $cursor, 5, $this->ascendingIdOrder); + $seen = [...$seen, ...array_column($page->items, 'id')]; + $cursor = $page->next; + } + + self::assertSame($seen, array_unique($seen)); + self::assertSame(range(1, 16), $seen); + } + + public function testNonTotalOrderIsRejected() + { + $this->connection->executeStatement('UPDATE items SET name = ?', ['duplicate']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor order is not total'); + + $this->adapter->sliceWithCursor($this->query(), null, 5, CursorOrder::byFields(['name'], 'ASC')); + } + + private function query(): QueryBuilder + { + return $this->connection->createQueryBuilder() + ->select('id', 'name') + ->from('items'); + } +} diff --git a/src/Pagination/tests/Adapter/DoctrineOrmAdapterTest.php b/src/Pagination/tests/Adapter/DoctrineOrmAdapterTest.php new file mode 100644 index 00000000000..027c5b46588 --- /dev/null +++ b/src/Pagination/tests/Adapter/DoctrineOrmAdapterTest.php @@ -0,0 +1,1261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Adapter; + +use Doctrine\DBAL\Logging\Middleware; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Query\QueryException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; +use Symfony\UX\Pagination\Adapter\DoctrineOrmAdapter; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Exception\RuntimeException; +use Symfony\UX\Pagination\Exception\UnsupportedDoctrineQueryException; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Book; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Category; +use Symfony\UX\Pagination\Tests\Fixtures\EntityManagerFactory; + +#[CoversClass(DoctrineOrmAdapter::class)] +final class DoctrineOrmAdapterTest extends TestCase +{ + private EntityManager $entityManager; + private DoctrineOrmAdapter $adapter; + private QueryCollector $queryCollector; + + protected function setUp(): void + { + if (!class_exists(EntityManager::class)) { + self::markTestSkipped('Doctrine ORM is not installed.'); + } + + try { + $this->queryCollector = new QueryCollector(); + $this->entityManager = EntityManagerFactory::create( + [Author::class, Book::class, Category::class], + fn ($config) => $config->setMiddlewares([new Middleware($this->queryCollector)]), + ); + + $this->adapter = new DoctrineOrmAdapter(); + } catch (\Doctrine\ORM\ORMInvalidArgumentException $e) { + if (str_contains($e->getMessage(), 'LazyGhost')) { + self::markTestSkipped('Doctrine ORM requires symfony/var-exporter: '.$e->getMessage()); + } + throw $e; + } + } + + protected function tearDown(): void + { + if (isset($this->entityManager)) { + $this->entityManager->close(); + } + } + + public function testCountUsesDistinctForArbitraryClassJoins() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join(Book::class, 'b', 'WITH', 'b.author = a'); + + self::assertSame(0, $this->adapter->count($qb)); + } + + public function testCountFallsBackToDistinctForNonAssociationJoins() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('a.name', 'x'); + + // The join analysis must not block: it falls back to COUNT(DISTINCT) + // and lets Doctrine reject the invalid DQL itself. + $this->expectException(QueryException::class); + $this->adapter->count($qb); + } + + public function testCountFallsBackToDistinctForUnresolvableJoinAliases() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('x.books', 'b'); + + $this->expectException(QueryException::class); + $this->adapter->count($qb); + } + + public function testSupportsQueryBuilder() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + self::assertTrue($this->adapter->supports($qb)); + self::assertFalse($this->adapter->supports('string')); + self::assertFalse($this->adapter->supports([])); + self::assertFalse($this->adapter->supports(new \stdClass())); + } + + public function testCursorContextFingerprintsDqlParametersAndApplicationContext() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->where('a.name = :name') + ->andWhere('a.active = :filters') + ->setParameter('name', 'Alice', 'string') + ->setParameter('filters', [ + 'date' => new \DateTimeImmutable('2026-07-28 12:34:56.123456+02:00'), + 'backed' => OrmBackedContext::Books, + 'unit' => OrmUnitContext::Catalog, + 'scalar' => true, + 'null' => null, + ]); + + $context = json_decode($this->adapter->getCursorContext($queryBuilder, 'tenant-a'), true, flags: \JSON_THROW_ON_ERROR); + + self::assertSame(DoctrineOrmAdapter::class, $context['adapter']); + self::assertSame(Author::class, $context['entity']); + self::assertSame('tenant-a', $context['context']); + self::assertSame(['filters', 'name'], array_keys($context['parameters'])); + self::assertSame('2026-07-28 12:34:56.123456+02:00', $context['parameters']['filters']['value']['date']['value']); + self::assertSame('books', $context['parameters']['filters']['value']['backed']['value']); + self::assertSame('Catalog', $context['parameters']['filters']['value']['unit']['name']); + self::assertSame('string', $context['parameters']['name']['type']); + + self::assertNotSame( + $this->adapter->getCursorContext($queryBuilder, 'tenant-a'), + $this->adapter->getCursorContext($queryBuilder, 'tenant-b'), + ); + } + + public function testCursorContextNormalizesMappedEntityIdentifiers() + { + $author = new Author(); + $author->setName('Alice'); + $this->entityManager->persist($author); + $this->entityManager->flush(); + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->where('a = :author') + ->setParameter('author', $author); + $context = json_decode($this->adapter->getCursorContext($queryBuilder, null), true, flags: \JSON_THROW_ON_ERROR); + + self::assertSame( + ['id' => $author->getId()], + $context['parameters']['author']['value']['identifier'], + ); + } + + public function testCursorContextRejectsAnUnsavedMappedEntity() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->setParameter('author', new Author()->setName('Unsaved')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unsaved Doctrine object'); + $this->adapter->getCursorContext($queryBuilder, null); + } + + public function testCursorContextRejectsATransientObject() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->setParameter('object', new \stdClass()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('parameter object'); + $this->adapter->getCursorContext($queryBuilder, null); + } + + public function testCursorContextRejectsAnUnsupportedParameterType() + { + $resource = fopen('php://memory', 'r'); + \assert(false !== $resource); + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->setParameter('resource', $resource); + + try { + $this->adapter->getCursorContext($queryBuilder, null); + self::fail('A resource cannot produce a stable cursor context.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString('resource (stream)', $exception->getMessage()); + } finally { + fclose($resource); + } + } + + public function testCursorContextRejectsUnsupportedSourcesAndMultipleRoots() + { + try { + $this->adapter->getCursorContext([], null); + self::fail('A non-Doctrine source must be rejected.'); + } catch (InvalidArgumentException) { + } + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a', 'c') + ->from(Author::class, 'a') + ->from(Category::class, 'c'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('one Doctrine root entity'); + $this->adapter->getCursorContext($queryBuilder, null); + } + + public function testBasicCountWithoutJoin() + { + // Create test data + for ($i = 1; $i <= 5; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + self::assertSame(5, $this->adapter->count($qb)); + } + + public function testCountRejectsGroupByWithActionableAlternative() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a.name') + ->from(Author::class, 'a') + ->groupBy('a.name'); + + $this->expectException(UnsupportedDoctrineQueryException::class); + $this->expectExceptionMessage('Use total(), lookahead(), or a custom pagination adapter.'); + $this->adapter->count($qb); + } + + public function testCountRejectsHavingWithActionableAlternative() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('a.name') + ->from(Author::class, 'a') + ->groupBy('a.name') + ->having('COUNT(a.id) > 1'); + + $this->expectException(UnsupportedDoctrineQueryException::class); + $this->adapter->count($qb); + } + + public function testBasicSlice() + { + // Create test data + for ($i = 1; $i <= 20; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->orderBy('a.id', 'ASC'); + + $results = $this->adapter->slice($qb, 0, 5); + self::assertCount(5, $results); + + $results = $this->adapter->slice($qb, 5, 5); + self::assertCount(5, $results); + + $results = $this->adapter->slice($qb, 15, 10); + self::assertCount(5, $results); // Only 5 left + } + + public function testSliceWithToOneJoinExecutesOneQuery() + { + $author = new Author(); + $author->setName('Author'); + $this->entityManager->persist($author); + + for ($i = 1; $i <= 8; ++$i) { + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b', 'a') + ->from(Book::class, 'b') + ->leftJoin('b.author', 'a') + ->orderBy('b.id', 'ASC'); + + $this->queryCollector->reset(); + $results = $this->adapter->slice($qb, 0, 5); + + self::assertCount(5, $results); + self::assertCount(1, $this->queryCollector->queries()); + } + + public function testCountWithToOneJoinDoesNotUseDistinct() + { + $author = new Author(); + $author->setName('Author'); + $this->entityManager->persist($author); + + for ($i = 1; $i <= 5; ++$i) { + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b') + ->leftJoin('b.author', 'a'); + + $this->queryCollector->reset(); + + self::assertSame(5, $this->adapter->count($qb)); + self::assertCount(1, $this->queryCollector->queries()); + self::assertStringNotContainsString('DISTINCT', strtoupper($this->queryCollector->queries()[0])); + } + + /** + * Test COUNT with one-to-many JOIN. + * + * This is a tricky case because: + * - An author with 3 books appears 3 times in the result set after JOIN + * - Without DISTINCT, COUNT would return 15 (5 authors × 3 books each) + * - With DISTINCT, COUNT correctly returns 5 (unique authors) + */ + public function testCountWithOneToManyJoin() + { + // Create 5 authors, each with 3 books + for ($i = 1; $i <= 5; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + + for ($j = 1; $j <= 3; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + // Query authors with JOIN to books + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('a.books', 'b'); + + // Should count distinct authors, not the joined result rows + // Expected: 5 authors (not 15 which would be the joined rows) + $this->queryCollector->reset(); + + self::assertSame(5, $this->adapter->count($qb)); + self::assertCount(1, $this->queryCollector->queries()); + self::assertStringContainsString('DISTINCT', strtoupper($this->queryCollector->queries()[0])); + } + + /** + * Test COUNT with many-to-many JOIN. + * + * This tests the scenario where books have multiple categories and + * categories have multiple books. + */ + public function testCountWithManyToManyJoin() + { + // Create categories + $fiction = new Category(); + $fiction->setName('Fiction'); + $this->entityManager->persist($fiction); + + $sciFi = new Category(); + $sciFi->setName('Sci-Fi'); + $this->entityManager->persist($sciFi); + + // Create books with multiple categories + for ($i = 1; $i <= 10; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setAuthor($author); + $book->addCategory($fiction); + + if (0 === $i % 2) { + $book->addCategory($sciFi); // Even books are also Sci-Fi + } + + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + // Query books with JOIN to categories + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b') + ->join('b.categories', 'c'); + + // Should count distinct books, not the joined result rows + // Expected: 10 books (not 15 which would be 10 + 5 with double category) + self::assertSame(10, $this->adapter->count($qb)); + } + + /** + * Test slice with JOIN to ensure data integrity. + * + * When slicing with JOINs, we need to ensure that: + * - Pagination works correctly + * - The same entity doesn't appear multiple times due to JOIN + */ + public function testSliceWithJoin() + { + // Create authors with books + for ($i = 1; $i <= 10; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + + for ($j = 1; $j <= 3; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('a.books', 'b') + ->orderBy('a.id', 'ASC'); + + // First page: should get first 5 authors (not duplicate rows) + $results = $this->adapter->slice($qb, 0, 5); + + // Note: Without DISTINCT in the select, we might get duplicate authors + // This is a known issue with Doctrine pagination + // The actual behavior depends on Doctrine's result handling + self::assertIsArray($results); + } + + public function testSliceWithFetchJoinCollectionReturnsCompleteRootEntities() + { + for ($i = 1; $i <= 8; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + + for ($j = 1; $j <= 3; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a', 'b') + ->from(Author::class, 'a') + ->leftJoin('a.books', 'b') + ->orderBy('a.id', 'ASC'); + + $this->queryCollector->reset(); + $results = $this->adapter->slice($qb, 0, 5); + + self::assertCount(5, $results); + self::assertCount(5, array_unique(array_map(static fn (Author $item): int => $item->getId(), $results))); + self::assertCount(2, $this->queryCollector->queries()); + } + + /** + * Test lookahead pagination with JOINs. + */ + public function testLookaheadWithJoin() + { + // Create test data + for ($i = 1; $i <= 25; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + + for ($j = 1; $j <= 2; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('a.books', 'b') + ->orderBy('a.id', 'ASC'); + + [$items, $hasMore] = $this->adapter->sliceWithLookahead($qb, 0, 10); + self::assertIsArray($items); + self::assertIsBool($hasMore); + } + + /** + * Test cursor-based pagination. + */ + public function testCursorPagination() + { + // Create test data + for ($i = 1; $i <= 20; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC'); + + // First page (no cursor) + $result = $this->adapter->sliceWithCursor($qb, null, 10, $order); + + self::assertInstanceOf(\Symfony\UX\Pagination\Cursor\CursorSlice::class, $result); + self::assertIsArray($result->items); + self::assertIsBool($result->hasNext); + + self::assertCount(10, $result->items); + self::assertTrue($result->hasNext); + self::assertNotNull($result->next); + } + + public function testCursorDoesNotOverwriteApplicationParameters() + { + for ($i = 1; $i <= 12; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->where('a.id > :ux_pagination_cursor_0') + ->setParameter('ux_pagination_cursor_0', 8); + $order = $this->adapter->resolveCursorOrder( + $queryBuilder, + ['id'], + 'ASC', + ); + + $result = $this->adapter->sliceWithCursor( + $queryBuilder, + new CursorBoundary([3]), + 2, + $order, + ); + + self::assertSame( + [9, 10], + array_map(static fn (Author $author): int => $author->getId(), $result->items), + ); + } + + /** + * Test cursor pagination with JOINs. + * + * Note: JOIN queries with cursor pagination can have tricky behavior due to + * duplicate rows. This test verifies basic functionality works without errors. + * For precise pagination with JOINs, consider using DISTINCT in your query + * or using lookahead pagination. + */ + public function testCursorPaginationWithJoin() + { + // Create authors with books + for ($i = 1; $i <= 15; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + + for ($j = 1; $j <= 2; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('DISTINCT a') + ->from(Author::class, 'a') + ->join('a.books', 'b'); + $order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC'); + + $result = $this->adapter->sliceWithCursor($qb, null, 5, $order); + + self::assertInstanceOf(\Symfony\UX\Pagination\Cursor\CursorSlice::class, $result); + self::assertIsBool($result->hasNext); + self::assertCount(5, $result->items); + } + + public function testCountThrowsForNonQueryBuilder() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Source must be a Doctrine ORM QueryBuilder.'); + $this->adapter->count('not a query builder'); + } + + public function testCountRejectsMultipleRootEntities() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a', 'c') + ->from(Author::class, 'a') + ->from(Category::class, 'c'); + + $this->expectException(UnsupportedDoctrineQueryException::class); + $this->expectExceptionMessage('multiple root aliases'); + $this->adapter->count($queryBuilder); + } + + public function testSliceThrowsForNonQueryBuilder() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Source must be a Doctrine ORM QueryBuilder.'); + $this->adapter->slice('not a query builder', 0, 10); + } + + public function testLookaheadThrowsForNonQueryBuilder() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Source must be a Doctrine ORM QueryBuilder.'); + $this->adapter->sliceWithLookahead('not a query builder', 0, 10); + } + + public function testCursorThrowsForNonQueryBuilder() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Source must be a Doctrine ORM QueryBuilder.'); + $this->adapter->sliceWithCursor('not a query builder', null, 10, CursorOrder::byFields(['id'], 'ASC')); + } + + public function testCursorFieldResolutionRejectsNonQueryBuilders() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Source must be a Doctrine ORM QueryBuilder.'); + + $this->adapter->resolveCursorFields([], 'id'); + } + + public function testCursorFieldsMustBeNonEmptyStrings() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor fields must be non-empty strings.'); + + $this->adapter->resolveCursorFields($queryBuilder, []); + } + + public function testCursorOrderMustBeExplicit() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires an explicit orderBy()'); + + $this->adapter->resolveCursorOrder($queryBuilder, null, null); + } + + public function testCursorRejectsAnOpaqueOrder() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires a field-based cursor order'); + + $this->adapter->sliceWithCursor($queryBuilder, null, 10, CursorOrder::byIdentity('remote-order')); + } + + public function testCursorRejectsInvalidDirection() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('direction must be "ASC" or "DESC"'); + $this->adapter->resolveCursorOrder($queryBuilder, ['id'], 'sideways'); + } + + public function testCursorRejectsMissingFromClause() + { + $queryBuilder = $this->entityManager->createQueryBuilder()->select('1'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('QueryBuilder has no FROM clause.'); + $this->adapter->resolveCursorOrder($queryBuilder, ['id'], 'ASC'); + } + + public function testCursorRejectsUnknownField() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid cursor field "unknown"'); + $this->adapter->resolveCursorOrder($queryBuilder, ['unknown'], 'ASC'); + } + + /** + * Test that COUNT works with WHERE clauses. + */ + public function testCountWithWhereClause() + { + // Create test data + for ($i = 1; $i <= 10; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $author->setActive($i <= 5); // Only first 5 are active + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->where('a.active = :active') + ->setParameter('active', true); + + self::assertSame(5, $this->adapter->count($qb)); + } + + /** + * Test COUNT with complex WHERE and JOIN. + */ + public function testCountWithWhereAndJoin() + { + // Create authors, some active and some not + for ($i = 1; $i <= 10; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $author->setActive($i <= 5); // Only first 5 are active + + // Each author has 2 books + for ($j = 1; $j <= 2; ++$j) { + $book = new Book(); + $book->setTitle('Book '.$j.' by Author '.$i); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->join('a.books', 'b') + ->where('a.active = :active') + ->setParameter('active', true); + + // Should count 5 active authors (with DISTINCT) + self::assertSame(5, $this->adapter->count($qb)); + } + + /** + * Test single-field cursor pagination (backward compatibility). + */ + public function testSingleFieldCursorPagination() + { + // Create test data with sequential IDs + for ($i = 1; $i <= 20; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC'); + + // First page (no cursor) + $result = $this->adapter->sliceWithCursor($qb, null, 5, $order); + + self::assertCount(5, $result->items); + self::assertTrue($result->hasNext); + self::assertNotNull($result->next); + self::assertNull($result->previous); + + // Second page using nextCursor + $result2 = $this->adapter->sliceWithCursor($qb, $result->next, 5, $order); + + self::assertCount(5, $result2->items); + self::assertTrue($result2->hasNext); + self::assertNotNull($result2->next); + + // Verify no overlaps between pages + $firstIds = array_map(static fn (Author $item) => $item->getId(), $result->items); + $secondIds = array_map(static fn (Author $item) => $item->getId(), $result2->items); + self::assertEmpty(array_intersect($firstIds, $secondIds), 'Pages should not have overlapping items'); + } + + /** + * Test backward navigation: the previousCursor of page 2 must + * return the items of page 1, in display order. + */ + public function testCursorBackwardNavigationReturnsPreviousPage() + { + for ($i = 1; $i <= 20; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC'); + + $page1 = $this->adapter->sliceWithCursor($qb, null, 5, $order); + $page2 = $this->adapter->sliceWithCursor($qb, $page1->next, 5, $order); + + self::assertNotNull($page2->previous); + + $back = $this->adapter->sliceWithCursor($qb, $page2->previous, 5, $order); + + $page1Ids = array_map(static fn (Author $item) => $item->getId(), $page1->items); + $backIds = array_map(static fn (Author $item) => $item->getId(), $back->items); + + self::assertSame($page1Ids, $backIds, 'Backward navigation must return the previous page in display order'); + self::assertNull($back->previous, 'First page has no previous page'); + self::assertNotNull($back->next); + + // Going forward again returns page 2 + $forwardAgain = $this->adapter->sliceWithCursor($qb, $back->next, 5, $order); + $page2Ids = array_map(static fn (Author $item) => $item->getId(), $page2->items); + self::assertSame($page2Ids, array_map(static fn (Author $item) => $item->getId(), $forwardAgain->items)); + } + + /** + * Test backward navigation from a middle page keeps a previousCursor. + */ + public function testCursorBackwardNavigationFromMiddlePage() + { + for ($i = 1; $i <= 20; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC'); + + $page1 = $this->adapter->sliceWithCursor($qb, null, 5, $order); + $page2 = $this->adapter->sliceWithCursor($qb, $page1->next, 5, $order); + $page3 = $this->adapter->sliceWithCursor($qb, $page2->next, 5, $order); + + $back = $this->adapter->sliceWithCursor($qb, $page3->previous, 5, $order); + + $page2Ids = array_map(static fn (Author $item) => $item->getId(), $page2->items); + self::assertSame($page2Ids, array_map(static fn (Author $item) => $item->getId(), $back->items)); + self::assertNotNull($back->previous, 'Page 1 still exists before page 2'); + } + + /** + * Test composite cursor with 2 fields (price, id). + * + * This tests the critical case where sorting by a non-unique field + * (price) could cause duplicates or skips. Using a composite cursor + * with ID as tie-breaker ensures deterministic ordering. + */ + public function testCompositeCursorWithTwoFields() + { + // Create books with duplicate prices + $prices = [10.0, 10.0, 10.0, 20.0, 20.0, 30.0, 30.0, 30.0, 40.0, 50.0]; + + foreach ($prices as $i => $price) { + $author = new Author(); + $author->setName('Author '.($i + 1)); + $this->entityManager->persist($author); + + $book = new Book(); + $book->setTitle('Book '.($i + 1)); + $book->setPrice($price); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($qb, ['price', 'id'], 'ASC'); + + // First page with composite cursor + $result = $this->adapter->sliceWithCursor($qb, null, 3, $order); + + self::assertCount(3, $result->items); + self::assertTrue($result->hasNext); + self::assertNotNull($result->next); + + // Verify first page has books with price 10.0 + foreach ($result->items as $book) { + self::assertSame(10.0, $book->getPrice()); + } + + // Second page + $result2 = $this->adapter->sliceWithCursor($qb, $result->next, 3, $order); + + self::assertCount(3, $result2->items); + self::assertTrue($result2->hasNext); + + // Verify ordering and no duplicates + $firstIds = array_map(static fn (Book $item): int => $item->getId(), $result->items); + $secondIds = array_map(static fn (Book $item): int => $item->getId(), $result2->items); + self::assertEmpty(array_intersect($firstIds, $secondIds), 'Pages should not have overlapping items'); + + // Third page + $result3 = $this->adapter->sliceWithCursor($qb, $result2->next, 3, $order); + + self::assertCount(3, $result3->items); + self::assertTrue($result3->hasNext); + + // Collect all IDs across all pages + $thirdIds = array_map(static fn (Book $item): int => $item->getId(), $result3->items); + $allIds = array_merge($firstIds, $secondIds, $thirdIds); + + // Verify no duplicates across all pages + self::assertCount(9, $allIds); + self::assertCount(9, array_unique($allIds)); + } + + public function testSingleNonUniqueCursorFieldAutomaticallyUsesIdentifierTieBreaker() + { + $names = ['Alice', 'Alice', 'Alice', 'Bob', 'Bob', 'Charlie', 'Charlie', 'Charlie']; + + foreach ($names as $name) { + $author = new Author(); + $author->setName($name); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['name'], 'ASC'); + + $seenIds = []; + $cursor = null; + + do { + $result = $this->adapter->sliceWithCursor($qb, $cursor, 2, $order); + $pageIds = array_map(static fn (Author $item): int => $item->getId(), $result->items); + $seenIds = array_merge($seenIds, $pageIds); + $cursor = $result->next; + } while (null !== $cursor); + + self::assertCount(8, $seenIds); + self::assertCount(8, array_unique($seenIds)); + } + + /** + * Test composite cursor with DESC direction. + */ + public function testCompositeCursorWithDescDirection() + { + // Create books with prices in ascending order + for ($i = 1; $i <= 10; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setPrice((float) ($i * 10)); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($qb, ['price', 'id'], 'DESC'); + + // First page with DESC order + $result = $this->adapter->sliceWithCursor($qb, null, 3, $order); + + self::assertCount(3, $result->items); + self::assertTrue($result->hasNext); + + // Verify DESC ordering - highest prices first + $firstBook = $result->items[0]; + self::assertSame(100.0, $firstBook->getPrice()); + + // Second page + $result2 = $this->adapter->sliceWithCursor($qb, $result->next, 3, $order); + + self::assertCount(3, $result2->items); + self::assertTrue($result2->hasNext); + + // Verify no overlaps + $firstIds = array_map(static fn (Book $item): int => $item->getId(), $result->items); + $secondIds = array_map(static fn (Book $item): int => $item->getId(), $result2->items); + self::assertEmpty(array_intersect($firstIds, $secondIds)); + } + + /** + * Test composite cursor with 3 fields. + */ + public function testCompositeCursorWithThreeFields() + { + // Create authors with duplicate names for testing + $names = ['Alice', 'Alice', 'Alice', 'Bob', 'Bob', 'Charlie']; + + foreach ($names as $i => $name) { + $author = new Author(); + $author->setName($name); + $this->entityManager->persist($author); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a'); + $order = $this->adapter->resolveCursorOrder($qb, ['name', 'active', 'id'], 'ASC'); + + // Use composite cursor with 3 fields: [name, active, id] + $result = $this->adapter->sliceWithCursor($qb, null, 2, $order); + + self::assertCount(2, $result->items); + self::assertTrue($result->hasNext); + self::assertNotNull($result->next); + + // Second page + $result2 = $this->adapter->sliceWithCursor($qb, $result->next, 2, $order); + + self::assertCount(2, $result2->items); + self::assertTrue($result2->hasNext); + + // Verify no duplicates + $firstIds = array_map(static fn (Author $item): int => $item->getId(), $result->items); + $secondIds = array_map(static fn (Author $item): int => $item->getId(), $result2->items); + self::assertEmpty(array_intersect($firstIds, $secondIds)); + } + + /** + * Test encoding and decoding of composite cursors. + */ + public function testCompositeCursorBoundaryRoundTrip() + { + // Create test data + for ($i = 1; $i <= 5; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $this->entityManager->persist($author); + + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setPrice((float) ($i * 10)); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($qb, ['price', 'id'], 'ASC'); + + // Get first page + $result = $this->adapter->sliceWithCursor($qb, null, 2, $order); + + self::assertNotNull($result->next); + + self::assertCount(2, $result->next->values); + + // Use the cursor for next page - should work without errors + $result2 = $this->adapter->sliceWithCursor($qb, $result->next, 2, $order); + self::assertInstanceOf(\Symfony\UX\Pagination\Cursor\CursorSlice::class, $result2); + self::assertNotEmpty($result2->items); + } + + public function testCursorRejectsNullableScalarField() + { + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor field "rating" must be non-nullable.'); + $this->adapter->resolveCursorOrder($qb, ['rating'], 'ASC'); + } + + public function testCursorRejectsUnsupportedDoctrineFieldTypes() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($queryBuilder, ['metadata'], 'ASC'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Doctrine type "json" of cursor field "metadata" is not supported.'); + + $this->adapter->sliceWithCursor($queryBuilder, new CursorBoundary(['{}', 1]), 10, $order); + } + + public function testCursorNormalizesValidDateValues() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($queryBuilder, ['publishedAt'], 'ASC'); + + $result = $this->adapter->sliceWithCursor( + $queryBuilder, + new CursorBoundary(['1999-01-01T00:00:00+00:00', 0]), + 10, + $order, + ); + + self::assertSame([], $result->items); + } + + public function testCursorRejectsInvalidDateValues() + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $order = $this->adapter->resolveCursorOrder($queryBuilder, ['publishedAt'], 'ASC'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid date cursor value for field "publishedAt".'); + + $this->adapter->sliceWithCursor($queryBuilder, new CursorBoundary(['not-a-date', 1]), 10, $order); + } + + /** + * Test error handling: cursor values count mismatch. + */ + public function testCompositeCursorMismatchThrowsException() + { + $author = new Author(); + $author->setName('Author 1'); + $this->entityManager->persist($author); + + // Create multiple books so we have a nextCursor + for ($i = 1; $i <= 5; ++$i) { + $book = new Book(); + $book->setTitle('Book '.$i); + $book->setPrice((float) ($i * 10)); + $book->setAuthor($author); + $this->entityManager->persist($book); + } + $this->entityManager->flush(); + + $qb = $this->entityManager->createQueryBuilder() + ->select('b') + ->from(Book::class, 'b'); + $compositeOrder = $this->adapter->resolveCursorOrder($qb, ['price', 'id'], 'ASC'); + + // Get cursor with 2 fields + $result = $this->adapter->sliceWithCursor($qb, null, 2, $compositeOrder); + + self::assertNotNull($result->next, 'Expected nextCursor to be set'); + + // Try to use it with 1 field - should throw exception + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor values count does not match cursor fields count'); + + $this->adapter->sliceWithCursor($qb, $result->next, 2, $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC')); + } +} + +enum OrmBackedContext: string +{ + case Books = 'books'; +} + +enum OrmUnitContext +{ + case Catalog; +} + +final class QueryCollector extends AbstractLogger +{ + /** @var list */ + private array $queries = []; + + public function log($level, $message, array $context = []): void + { + if (str_starts_with((string) $message, 'Executing ') && isset($context['sql']) && \is_string($context['sql'])) { + $this->queries[] = $context['sql']; + } + } + + public function reset(): void + { + $this->queries = []; + } + + /** + * @return list + */ + public function queries(): array + { + return $this->queries; + } +} diff --git a/src/Pagination/tests/CursorCodecTest.php b/src/Pagination/tests/CursorCodecTest.php new file mode 100644 index 00000000000..ffc63c43241 --- /dev/null +++ b/src/Pagination/tests/CursorCodecTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\Exception\RuntimeException; + +#[CoversClass(CursorCodec::class)] +final class CursorCodecTest extends TestCase +{ + public function testConstructorMarksTheSecretAsSensitive() + { + $parameter = new \ReflectionMethod(CursorCodec::class, '__construct')->getParameters()[0]; + + self::assertNotEmpty($parameter->getAttributes(\SensitiveParameter::class)); + } + + public function testEmptySecretIsRejected() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('non-empty "ux_pagination.cursor.secret" or "kernel.secret"'); + new CursorCodec(''); + } + + public function testSignedVersionedRoundTrip() + { + $codec = new CursorCodec('application-secret'); + $token = $codec->encode([12, '2026-07-25'], true, 'order-a', 'products'); + + self::assertSame([ + 'values' => [12, '2026-07-25'], + 'forward' => true, + ], $codec->decode($token, 'order-a', 'products')); + } + + public function testTamperedTokenIsRejected() + { + $codec = new CursorCodec('application-secret'); + $token = $codec->encode([12], true, 'order-a', 'products'); + $token[-2] = 'A' === $token[-2] ? 'B' : 'A'; + + $this->expectException(\InvalidArgumentException::class); + $codec->decode($token, 'order-a', 'products'); + } + + public function testTokenCannotBeReusedForAnotherOrder() + { + $codec = new CursorCodec('application-secret'); + $token = $codec->encode([12], true, 'order-a', 'products'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not match this pagination order'); + $codec->decode($token, 'order-b', 'products'); + } + + public function testTokenCannotBeVerifiedWithAnotherSecret() + { + $token = new CursorCodec('first-secret')->encode([12], true, 'order-a', 'products'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('signature'); + new CursorCodec('second-secret')->decode($token, 'order-a', 'products'); + } + + public function testTokenCannotBeReusedForAnotherContext() + { + $codec = new CursorCodec('application-secret'); + $token = $codec->encode([12], true, 'order-a', 'products'); + + $this->expectException(\InvalidArgumentException::class); + $codec->decode($token, 'order-a', 'orders'); + } + + public function testUnsignedLegacyTokenIsRejected() + { + $token = base64_encode(json_encode(['v' => [12]], \JSON_THROW_ON_ERROR)); + + $this->expectException(\InvalidArgumentException::class); + new CursorCodec('application-secret')->decode($token, 'order-a', 'products'); + } + + public function testOversizedTokenIsRejectedBeforeDecoding() + { + $this->expectException(\InvalidArgumentException::class); + new CursorCodec('secret')->decode(str_repeat('a', 4097), 'order-a', 'products'); + } + + public function testTooManyOrderedValuesAreRejectedBeforeEncoding() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('at most 16 ordered values'); + + new CursorCodec('secret')->encode(range(1, 17), true, 'order-a', 'products'); + } + + public function testNonScalarOrderedValueIsRejectedBeforeEncoding() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid cursor value'); + new CursorCodec('secret')->encode([new \stdClass()], true, 'order-a', 'products'); + } + + public function testNonFiniteFloatIsRejectedBeforeEncoding() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be finite'); + + new CursorCodec('secret')->encode([\NAN], true, 'order-a', 'products'); + } + + public function testInvalidBase64IsRejectedBeforeDecoding() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid cursor value'); + new CursorCodec('secret')->decode('*', 'order-a', 'products'); + } + + public function testOversizedPayloadIsRejectedBeforeEncoding() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('too large to encode safely'); + + new CursorCodec('secret')->encode([str_repeat('a', 4000)], true, 'order-a', 'products'); + } +} diff --git a/src/Pagination/tests/CursorPaginationBuilderTest.php b/src/Pagination/tests/CursorPaginationBuilderTest.php new file mode 100644 index 00000000000..baaeb12ae7d --- /dev/null +++ b/src/Pagination/tests/CursorPaginationBuilderTest.php @@ -0,0 +1,443 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\CursorAdapterInterface; +use Symfony\UX\Pagination\Adapter\DoctrineDbalAdapter; +use Symfony\UX\Pagination\Adapter\PaginationAdapterInterface; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Cursor\CursorSlice; +use Symfony\UX\Pagination\CursorPaginationBuilder; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Exception\RuntimeException; +use Symfony\UX\Pagination\Paginator; + +#[CoversClass(CursorPaginationBuilder::class)] +final class CursorPaginationBuilderTest extends TestCase +{ + public function testArraySourceRequiresExplicitContext() + { + $this->expectException(RuntimeException::class); + $this->paginator()->cursor($this->source())->paginate(); + } + + public function testBuildsSignedBidirectionalPagination() + { + $page1 = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->paginate(); + + self::assertSame(range(1, 5), array_column($page1->getItems(), 'id')); + self::assertNotNull($page1->getNextCursor()); + + $page2 = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->cursor($page1->getNextCursor()) + ->paginate(); + + self::assertSame(range(6, 10), array_column($page2->getItems(), 'id')); + self::assertNotNull($page2->getPreviousCursor()); + } + + #[DataProvider('invalidOrderByArguments')] + public function testRejectsInvalidOrderByArguments(string|array $fields, string $direction) + { + $this->expectException(\InvalidArgumentException::class); + + $this->paginator()->cursor($this->source())->orderBy($fields, $direction); + } + + public static function invalidOrderByArguments(): iterable + { + yield 'no fields' => [[], 'ASC']; + yield 'empty field' => [['id', ''], 'ASC']; + yield 'invalid direction' => ['id', 'sideways']; + } + + public function testRejectsInvalidPageSize() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('perPage must be >= 1'); + + $this->paginator()->cursor($this->source())->perPage(0); + } + + public function testRejectsPageSizeThatWouldOverflowLookahead() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('less than PHP_INT_MAX'); + + $this->paginator()->cursor($this->source())->perPage(\PHP_INT_MAX); + } + + public function testRejectsEmptyCursorParameter() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor parameter name must not be empty'); + + $this->paginator()->cursor($this->source())->cursorParameter(''); + } + + public function testConstructorRejectsPerPageOverflow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('less than PHP_INT_MAX'); + + new CursorPaginationBuilder( + $this->source(), + [new ArrayPaginationAdapter()], + new CursorCodec('test-application-secret'), + defaultPerPage: \PHP_INT_MAX, + ); + } + + public function testRejectsEmptyContext() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor context must not be empty'); + + $this->paginator()->cursor($this->source())->context(''); + } + + public function testTokenIsBoundToBusinessContext() + { + $token = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('tenant-a:products') + ->paginate() + ->getNextCursor(); + + $builder = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('tenant-b:products') + ->cursor($token); + + $this->expectException(InvalidCursorException::class); + $builder->paginate(); + } + + public function testUrlCompositionPreservesFiltersAndCustomCursorParameter() + { + $stack = new RequestStack(); + $stack->push(Request::create('/catalog', 'GET', ['q' => 'phone', 'sort' => 'price'])); + + $pagination = $this->paginator($stack)->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->cursorParameter('after') + ->path('/products') + ->queryParameters(['sort' => 'name']) + ->fragment('results') + ->paginate(); + + $url = (string) $pagination->getNextUrl(); + self::assertStringStartsWith('/products?', $url); + self::assertStringContainsString('q=phone', $url); + self::assertStringContainsString('sort=name', $url); + self::assertStringContainsString('after=', $url); + self::assertStringEndsWith('#results', $url); + self::assertStringNotContainsString('cursor=', $url); + } + + public function testRouteAndQueryStringPoliciesAreImmutable() + { + $routes = new RouteCollection(); + $routes->add('catalog', new Route('/{section}')); + $urlGenerator = new UrlGenerator($routes, new RequestContext()); + $stack = new RequestStack(); + $stack->push(Request::create('/source', 'GET', ['q' => 'phone', 'debug' => '1'])); + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $stack, + urlGenerator: $urlGenerator, + cursorCodec: new CursorCodec('test-application-secret'), + ); + $builder = $paginator->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->route('catalog', ['section' => 'products']); + + $discardedUrl = (string) $builder + ->discardQueryString() + ->paginate() + ->getNextUrl(); + $preservedUrl = (string) $builder + ->discardQueryString() + ->preserveQueryString() + ->excludeQueryParameters('debug', 'debug') + ->paginate() + ->getNextUrl(); + + self::assertStringStartsWith('/products?', $discardedUrl); + self::assertStringNotContainsString('q=', $discardedUrl); + self::assertStringContainsString('q=phone', $preservedUrl); + self::assertStringNotContainsString('debug=', $preservedUrl); + } + + public function testReadsAValidCursorFromTheRequest() + { + $firstPage = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->paginate(); + $cursor = $firstPage->getNextCursor(); + self::assertNotNull($cursor); + + $stack = new RequestStack(); + $stack->push(Request::create('/catalog', 'GET', ['cursor' => $cursor])); + $secondPage = $this->paginator($stack)->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->paginate(); + + self::assertSame($cursor, $secondPage->getCursor()); + self::assertSame(range(6, 10), array_column($secondPage->getItems(), 'id')); + } + + #[DataProvider('invalidRequestCursors')] + public function testRejectsInvalidRequestCursor(mixed $value) + { + $stack = new RequestStack(); + $stack->push(new Request(['cursor' => $value])); + + $this->expectException(InvalidCursorException::class); + $this->paginator($stack)->cursor($this->source()) + ->context('products') + ->paginate(); + } + + public static function invalidRequestCursors(): iterable + { + yield 'empty' => ['']; + yield 'array' => [['token']]; + yield 'oversized' => [str_repeat('a', 4097)]; + } + + public function testTamperedRequestCursorFailsAtPaginateTime() + { + $token = $this->paginator()->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->paginate() + ->getNextCursor(); + self::assertNotNull($token); + + $stack = new RequestStack(); + $stack->push(Request::create('/catalog', 'GET', ['cursor' => substr($token, 0, -2)])); + + $this->expectException(InvalidCursorException::class); + $this->paginator($stack)->cursor($this->source()) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('products') + ->paginate(); + } + + public function testMalformedSignedTokenUsesDomainException() + { + $builder = $this->paginator()->cursor($this->source()) + ->orderBy('id') + ->context('products') + ->cursor('not-a-token'); + + $this->expectException(InvalidCursorException::class); + $builder->paginate(); + } + + public function testDoctrineDbalSourceDerivesContextFromSqlAndParameters() + { + if (!class_exists(\Doctrine\DBAL\DriverManager::class)) { + self::markTestSkipped('Doctrine DBAL is not installed.'); + } + + $connection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE product (id INTEGER PRIMARY KEY, category TEXT NOT NULL)'); + foreach (range(1, 6) as $id) { + $connection->insert('product', ['id' => $id, 'category' => $id < 5 ? 'books' : 'tools']); + } + + $query = static fn (string $category) => $connection->createQueryBuilder() + ->select('product.id', 'product.category') + ->from('product', 'product') + ->where('product.category = :category') + ->setParameter('category', $category); + + $paginator = new Paginator( + [new DoctrineDbalAdapter()], + cursorCodec: new CursorCodec('test-application-secret'), + ); + $first = $paginator->cursor($query('books')) + ->orderBy('product.id', 'ASC') + ->perPage(2) + ->paginate(); + + self::assertSame([1, 2], array_column($first->getItems(), 'id')); + $cursor = $first->getNextCursor(); + self::assertNotNull($cursor); + + $changedQuery = $paginator->cursor($query('tools')) + ->orderBy('product.id', 'ASC') + ->perPage(2) + ->cursor($cursor); + + $this->expectException(InvalidCursorException::class); + $changedQuery->paginate(); + } + + public function testExcludedQueryParameterNameCannotBeEmpty() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + + $this->paginator()->cursor($this->source())->excludeQueryParameters(''); + } + + public function testRejectsAnAdapterWithoutCursorSupport() + { + $adapter = $this->createStub(PaginationAdapterInterface::class); + $adapter->method('supports')->willReturn(true); + $paginator = new Paginator( + [$adapter], + cursorCodec: new CursorCodec('test-application-secret'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No cursor pagination adapter found'); + + $paginator->cursor($this->source())->context('products')->paginate(); + } + + public function testRejectsAnUnsupportedSource() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + cursorCodec: new CursorCodec('test-application-secret'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No cursor pagination adapter found'); + + $paginator->cursor(new \stdClass())->context('products')->paginate(); + } + + public function testAdapterOwnedOrderDoesNotRequireOrderBy() + { + $source = new \stdClass(); + $adapter = new class implements CursorAdapterInterface { + public function supports(mixed $source): bool + { + return $source instanceof \stdClass; + } + + public function resolveCursorOrder(mixed $source, ?array $fields, ?string $direction): CursorOrder + { + if (null !== $fields || null !== $direction) { + throw new \LogicException('The remote adapter must own its order.'); + } + + return CursorOrder::byIdentity('github:pull-requests:created-desc'); + } + + public function getCursorContext(mixed $source, ?string $context): string + { + return 'github:owner/repository:pull-requests'; + } + + public function sliceWithCursor(mixed $source, ?CursorBoundary $boundary, int $limit, CursorOrder $order): CursorSlice + { + return new CursorSlice([['number' => 1]], null, null, false); + } + }; + $paginator = new Paginator( + [$adapter], + cursorCodec: new CursorCodec('test-application-secret'), + ); + + $pagination = $paginator->cursor($source)->paginate(); + + self::assertSame([['number' => 1]], $pagination->getItems()); + } + + public function testCursorResolutionSkipsAMatchingAdapterWithoutCursorCapability() + { + $source = new \stdClass(); + $genericAdapter = $this->createStub(PaginationAdapterInterface::class); + $genericAdapter->method('supports')->willReturn(true); + $cursorAdapter = new class implements CursorAdapterInterface { + public function supports(mixed $source): bool + { + return $source instanceof \stdClass; + } + + public function resolveCursorOrder(mixed $source, ?array $fields, ?string $direction): CursorOrder + { + return CursorOrder::byIdentity('remote-order'); + } + + public function getCursorContext(mixed $source, ?string $context): string + { + return 'remote-context'; + } + + public function sliceWithCursor(mixed $source, ?CursorBoundary $boundary, int $limit, CursorOrder $order): CursorSlice + { + return new CursorSlice([['id' => 42]], null, null, false); + } + }; + $paginator = new Paginator( + [$genericAdapter, $cursorAdapter], + cursorCodec: new CursorCodec('test-application-secret'), + ); + + self::assertSame([['id' => 42]], $paginator->cursor($source)->paginate()->getItems()); + } + + private function paginator(?RequestStack $requestStack = null): Paginator + { + return new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + cursorCodec: new CursorCodec('test-application-secret'), + ); + } + + /** + * @return list + */ + private function source(): array + { + return array_map(static fn (int $id): array => ['id' => $id], range(1, 12)); + } +} diff --git a/src/Pagination/tests/CursorPaginationTest.php b/src/Pagination/tests/CursorPaginationTest.php new file mode 100644 index 00000000000..5e21ced07fe --- /dev/null +++ b/src/Pagination/tests/CursorPaginationTest.php @@ -0,0 +1,389 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\CursorAdapterInterface; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Cursor\CursorSlice; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; + +#[CoversClass(CursorPagination::class)] +final class CursorPaginationTest extends TestCase +{ + public function testItemsFirstPage() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + $items = $pagination->getItems(); + self::assertCount(10, $items); + self::assertSame(1, $items[0]['id']); + self::assertSame(10, $items[9]['id']); + } + + public function testIterateReturnsItems() + { + $source = $this->createSource(5); + $pagination = $this->createCursorPagination($source, null, 10); + + $items = []; + foreach ($pagination as $item) { + $items[] = $item; + } + + self::assertCount(5, $items); + } + + public function testCountReturnsItemsOnThisPage() + { + $source = $this->createSource(25); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertSame(10, $pagination->count()); + self::assertCount(10, $pagination); + } + + public function testThroughTransformsItems() + { + $source = $this->createSource(3); + $pagination = $this->createCursorPagination($source, null, 10); + $doubled = $pagination->map(static fn (array $item): array => array_merge($item, ['doubled' => true])); + + self::assertTrue($doubled->getItems()[0]['doubled']); + self::assertArrayNotHasKey('doubled', $pagination->getItems()[0]); + } + + public function testMapTransformsItemsToAnotherTypeAndFetchesOnlyTheClone() + { + $adapter = new class implements CursorAdapterInterface { + public int $calls = 0; + + public function supports(mixed $source): bool + { + return true; + } + + public function getCursorContext(mixed $source, ?string $context): string + { + return $context ?? 'test'; + } + + public function resolveCursorOrder(mixed $source, ?array $fields, ?string $direction): CursorOrder + { + return CursorOrder::byIdentity('test-order'); + } + + public function sliceWithCursor(mixed $source, ?CursorBoundary $boundary, int $limit, CursorOrder $order): CursorSlice + { + ++$this->calls; + + return new CursorSlice([1], null, null, false); + } + }; + $pagination = new CursorPagination( + source: new \stdClass(), + adapter: $adapter, + cursor: null, + perPage: 10, + order: CursorOrder::byIdentity('test-order'), + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + context: 'test', + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + + self::assertSame(['item-1'], $pagination->map(static fn (int $item): string => 'item-'.$item)->getItems()); + self::assertSame(1, $adapter->calls); + } + + public function testFailedFetchIsNotCachedAsAnEmptyPage() + { + $pagination = $this->createCursorPagination($this->createSource(5), 'invalid', 10); + $failures = 0; + + for ($attempt = 0; $attempt < 2; ++$attempt) { + try { + $pagination->getItems(); + self::fail('An invalid cursor must fail on every fetch attempt.'); + } catch (InvalidCursorException) { + ++$failures; + } + } + + self::assertSame(2, $failures); + } + + public function testPerPage() + { + $pagination = $this->createCursorPagination($this->createSource(50), null, 25); + + self::assertSame(25, $pagination->getItemsPerPage()); + } + + public function testPerPageMustBePositive() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('perPage must be >= 1.'); + + $this->createCursorPagination([], null, 0); + } + + public function testPerPageMustNotOverflowLookahead() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('less than PHP_INT_MAX'); + + $this->createCursorPagination([], null, \PHP_INT_MAX); + } + + public function testCursorIsNullForFirstPage() + { + $pagination = $this->createCursorPagination($this->createSource(50), null, 10); + + self::assertNull($pagination->getCursor()); + } + + public function testIsEmpty() + { + $pagination = $this->createCursorPagination([], null, 10); + + self::assertTrue($pagination->isEmpty()); + } + + public function testHasNextWhenMoreItemsExist() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertTrue($pagination->hasNext()); + } + + public function testHasNextIsFalseOnLastPage() + { + $source = $this->createSource(5); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertFalse($pagination->hasNext()); + } + + public function testHasPreviousOnFirstPage() + { + $pagination = $this->createCursorPagination($this->createSource(50), null, 10); + + self::assertFalse($pagination->hasPrevious()); + self::assertNull($pagination->getPreviousUrl()); + } + + public function testHasPreviousOnSecondPage() + { + $source = $this->createSource(50); + + // First page + $page1 = $this->createCursorPagination($source, null, 10); + $nextCursor = $page1->getNextCursor(); + self::assertNotNull($nextCursor); + + // Second page + $page2 = $this->createCursorPagination($source, $nextCursor, 10); + self::assertTrue($page2->hasPrevious()); + } + + public function testNextCursorProvided() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertNotNull($pagination->getNextCursor()); + } + + public function testNextCursorNullOnLastPage() + { + $source = $this->createSource(5); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertNull($pagination->getNextCursor()); + } + + public function testCursorPaginationFlow() + { + $source = $this->createSource(25); + + // Page 1: items 1-10 + $page1 = $this->createCursorPagination($source, null, 10); + self::assertSame(1, $page1->getItems()[0]['id']); + self::assertSame(10, $page1->getItems()[9]['id']); + self::assertTrue($page1->hasNext()); + + // Page 2: items 11-20 + $page2 = $this->createCursorPagination($source, $page1->getNextCursor(), 10); + self::assertSame(11, $page2->getItems()[0]['id']); + self::assertSame(20, $page2->getItems()[9]['id']); + self::assertTrue($page2->hasNext()); + + // Page 3: items 21-25 + $page3 = $this->createCursorPagination($source, $page2->getNextCursor(), 10); + self::assertCount(5, $page3->getItems()); + self::assertSame(21, $page3->getItems()[0]['id']); + self::assertFalse($page3->hasNext()); + self::assertNull($page3->getNextCursor()); + } + + public function testNextUrlProvided() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + $nextUrl = $pagination->getNextUrl(); + self::assertNotNull($nextUrl); + self::assertStringContainsString('cursor=', $nextUrl); + } + + public function testNextUrlNullOnLastPage() + { + $source = $this->createSource(5); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertNull($pagination->getNextUrl()); + } + + public function testPreviousUrlOnSecondPage() + { + $source = $this->createSource(50); + $page1 = $this->createCursorPagination($source, null, 10); + $page2 = $this->createCursorPagination($source, $page1->getNextCursor(), 10); + + self::assertNotNull($page2->getPreviousUrl()); + self::assertStringContainsString('cursor=', $page2->getPreviousUrl()); + } + + public function testJsonSerialize() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + $json = $pagination->jsonSerialize(); + + self::assertCount(10, $json['items']); + self::assertSame(10, $json['per_page']); + self::assertNull($json['cursor']); + self::assertNotNull($json['next_cursor']); + self::assertNull($json['previous_cursor']); + self::assertTrue($json['has_next']); + self::assertFalse($json['has_previous']); + self::assertArrayNotHasKey('has_more', $json); + self::assertNull($json['links']['prev']); + self::assertNotNull($json['links']['next']); + } + + public function testInfo() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertSame('Showing 10 items', $pagination->getInfo()); + } + + public function testInfoLastPage() + { + $source = $this->createSource(5); + $pagination = $this->createCursorPagination($source, null, 10); + + self::assertSame('Showing 5 items (last page)', $pagination->getInfo()); + } + + public function testInfoEmpty() + { + $pagination = $this->createCursorPagination([], null, 10); + + self::assertSame('No items', $pagination->getInfo()); + } + + public function testItemsAreLazyLoaded() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + // Items are not fetched at construction -- only when accessed + // We verify by checking count() triggers the fetch + self::assertSame(10, $pagination->count()); + } + + public function testCursorUrl() + { + $source = $this->createSource(50); + $pagination = $this->createCursorPagination($source, null, 10); + + $url = $pagination->getCursorUrl(cursor: 'abc123'); + + self::assertStringContainsString('cursor=abc123', $url); + } + + /** + * @return list + */ + public function testPerPageOfOneWalksItemByItem() + { + $first = $this->createCursorPagination($this->createSource(3), null, 1); + + self::assertSame([['id' => 1, 'name' => 'Item 1']], $first->getItems()); + self::assertTrue($first->hasNext()); + self::assertFalse($first->hasPrevious()); + + $second = $this->createCursorPagination($this->createSource(3), $first->getNextCursor(), 1); + + self::assertSame([['id' => 2, 'name' => 'Item 2']], $second->getItems()); + self::assertTrue($second->hasNext()); + self::assertTrue($second->hasPrevious()); + + $last = $this->createCursorPagination($this->createSource(3), $second->getNextCursor(), 1); + + self::assertSame([['id' => 3, 'name' => 'Item 3']], $last->getItems()); + self::assertFalse($last->hasNext()); + self::assertTrue($last->hasPrevious()); + } + + private function createSource(int $count): array + { + $items = []; + for ($i = 1; $i <= $count; ++$i) { + $items[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + return $items; + } + + /** + * @param list|array{} $source + */ + private function createCursorPagination(array $source, ?string $cursor, int $perPage): CursorPagination + { + $adapter = new ArrayPaginationAdapter(); + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + return new CursorPagination( + source: $source, + adapter: $adapter, + cursor: $cursor, + perPage: $perPage, + order: CursorOrder::byFields(['id'], 'ASC'), + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + context: 'test', + paginationUrlGenerator: $paginationUrlGenerator, + ); + } +} diff --git a/src/Pagination/tests/CursorValueObjectTest.php b/src/Pagination/tests/CursorValueObjectTest.php new file mode 100644 index 00000000000..5c1fc8659a4 --- /dev/null +++ b/src/Pagination/tests/CursorValueObjectTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Cursor\CursorSlice; + +#[CoversClass(CursorBoundary::class)] +#[CoversClass(CursorOrder::class)] +#[CoversClass(CursorSlice::class)] +final class CursorValueObjectTest extends TestCase +{ + public function testBoundaryExposesItsValuesAndDirection() + { + $boundary = new CursorBoundary([42, 'release'], false); + + self::assertSame([42, 'release'], $boundary->getValues()); + self::assertFalse($boundary->pointsForward()); + } + + public function testSliceExposesItemsAndBoundaries() + { + $next = new CursorBoundary([3]); + $previous = new CursorBoundary([1], false); + $slice = new CursorSlice([['id' => 1], ['id' => 2]], $next, $previous, true); + + self::assertSame([['id' => 1], ['id' => 2]], $slice->getItems()); + self::assertSame($next, $slice->getNextBoundary()); + self::assertSame($previous, $slice->getPreviousBoundary()); + self::assertTrue($slice->hasNext()); + } + + public function testFieldOrderExposesNormalizedFieldsAndStableFingerprint() + { + $order = CursorOrder::byFields(['createdAt', 'id'], 'desc'); + + self::assertSame(['createdAt', 'id'], $order->getFields()); + self::assertSame('DESC', $order->getDirection()); + self::assertSame( + $order->getFingerprint(), + CursorOrder::byFields(['createdAt', 'id'], 'DESC')->getFingerprint(), + ); + self::assertNotSame( + $order->getFingerprint(), + CursorOrder::byFields(['id', 'createdAt'], 'DESC')->getFingerprint(), + ); + } + + public function testOpaqueOrderIdentityIsStableAndNotFieldBased() + { + $order = CursorOrder::byIdentity('github:pull-requests:created-desc'); + + self::assertNull($order->getFields()); + self::assertNull($order->getDirection()); + self::assertSame( + $order->getFingerprint(), + CursorOrder::byIdentity('github:pull-requests:created-desc')->getFingerprint(), + ); + self::assertNotSame( + $order->getFingerprint(), + CursorOrder::byIdentity('github:pull-requests:updated-desc')->getFingerprint(), + ); + } + + public function testOrderRejectsInvalidDefinitions() + { + $this->expectException(\InvalidArgumentException::class); + CursorOrder::byFields([], 'ASC'); + } + + public function testOrderRejectsInvalidDirection() + { + $this->expectException(\InvalidArgumentException::class); + CursorOrder::byFields(['id'], 'sideways'); + } + + public function testOpaqueOrderRejectsEmptyIdentity() + { + $this->expectException(\InvalidArgumentException::class); + CursorOrder::byIdentity(''); + } +} diff --git a/src/Pagination/tests/DependencyInjection/ConfigurationTest.php b/src/Pagination/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000000..9ca63749347 --- /dev/null +++ b/src/Pagination/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\UX\Pagination\Navigation\NavigationMode; +use Symfony\UX\Pagination\UXPaginationBundle; + +#[CoversClass(UXPaginationBundle::class)] +final class ConfigurationTest extends TestCase +{ + public function testDefaultConfiguration() + { + $config = $this->processConfiguration([]); + + self::assertSame(20, $config['items_per_page']); + self::assertSame(100_000, $config['max_offset']); + self::assertSame('page', $config['page_parameter']); + self::assertSame('cursor', $config['cursor_parameter']); + self::assertSame(['mode' => NavigationMode::Sliding, 'size' => 5], $config['navigation']); + self::assertSame( + '@UXPagination/theme/default.html.twig', + $config['theme'], + ); + self::assertSame(['secret' => '%kernel.secret%'], $config['cursor']); + self::assertSame([], $config['paginators']); + } + + public function testCustomConfiguration() + { + $config = $this->processConfiguration([ + 'items_per_page' => 50, + 'max_offset' => 250_000, + 'page_parameter' => 'p', + 'cursor_parameter' => 'after', + 'navigation' => [ + 'mode' => 'fixed', + 'size' => 9, + ], + 'theme' => '@UXPagination/theme/bootstrap.html.twig', + 'cursor' => ['secret' => 'pagination-secret'], + 'paginators' => [ + 'blog' => [ + 'items_per_page' => 12, + 'navigation' => ['size' => 7], + ], + ], + ]); + + self::assertSame(50, $config['items_per_page']); + self::assertSame(250_000, $config['max_offset']); + self::assertSame('p', $config['page_parameter']); + self::assertSame('after', $config['cursor_parameter']); + self::assertSame(['mode' => NavigationMode::Fixed, 'size' => 9], $config['navigation']); + self::assertSame( + '@UXPagination/theme/bootstrap.html.twig', + $config['theme'], + ); + self::assertSame(['secret' => 'pagination-secret'], $config['cursor']); + self::assertSame([ + 'blog' => [ + 'items_per_page' => 12, + 'navigation' => ['size' => 7], + ], + ], $config['paginators']); + } + + public function testItemsPerPageMinimum() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['items_per_page' => 0]); + } + + public function testItemsPerPageMustBePositive() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['items_per_page' => -5]); + } + + public function testMaximumOffsetCannotBeNegative() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['max_offset' => -1]); + } + + #[DataProvider('emptyStringOptions')] + public function testStringOptionsCannotBeEmpty(array $config) + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration($config); + } + + public function testThemeMustBeASingleValue() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration([ + 'theme' => [ + '@UXPagination/theme/default.html.twig', + '@UXPagination/theme/bootstrap.html.twig', + ], + ]); + } + + public function testTemplateIsNotAConfigurationOption() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Unrecognized option "template"'); + + $this->processConfiguration([ + 'template' => '@UXPagination/theme/bootstrap.html.twig', + ]); + } + + public function testThemeIsNotNestedUnderTwig() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Unrecognized option "twig"'); + + $this->processConfiguration([ + 'twig' => [ + 'theme' => '@UXPagination/theme/bootstrap.html.twig', + ], + ]); + } + + public static function emptyStringOptions(): iterable + { + yield 'Twig theme' => [['theme' => '']]; + yield 'blank Twig theme' => [['theme' => ' ']]; + yield 'query parameter' => [['page_parameter' => '']]; + yield 'blank query parameter' => [['page_parameter' => ' ']]; + yield 'cursor parameter' => [['cursor_parameter' => '']]; + yield 'named paginator query parameter' => [['paginators' => ['blog' => ['page_parameter' => '']]]]; + yield 'blank named paginator query parameter' => [['paginators' => ['blog' => ['page_parameter' => ' ']]]]; + yield 'named paginator cursor parameter' => [['paginators' => ['blog' => ['cursor_parameter' => '']]]]; + } + + #[DataProvider('nonStringOptions')] + public function testStringOptionsRejectNonStringScalars(array $config) + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration($config); + } + + public static function nonStringOptions(): iterable + { + yield 'Twig theme' => [['theme' => 123]]; + yield 'query parameter' => [['page_parameter' => false]]; + yield 'cursor parameter' => [['cursor_parameter' => 123]]; + yield 'named query parameter' => [['paginators' => ['blog' => ['page_parameter' => 123]]]]; + yield 'named cursor parameter' => [['paginators' => ['blog' => ['cursor_parameter' => false]]]]; + } + + public function testItemsPerPageRejectsLookaheadOverflow() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['items_per_page' => \PHP_INT_MAX]); + } + + public function testNamedItemsPerPageRejectsLookaheadOverflow() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration([ + 'paginators' => [ + 'blog' => ['items_per_page' => \PHP_INT_MAX], + ], + ]); + } + + public function testNavigationModeMustBeSupported() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['navigation' => ['mode' => 'unknown']]); + } + + public function testNamedPaginatorValuesAreValidated() + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration(['paginators' => ['blog' => ['navigation' => ['size' => 0]]]]); + } + + public function testPaginatorNameMustBeAValidAutowiringTarget() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid paginator name "1blog"'); + + $this->processConfiguration(['paginators' => ['1blog' => []]]); + } + + public function testPaginatorNamesMustNotResolveToTheSameAutowiringTarget() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Paginator names "blog-posts" and "blog_posts" resolve to the same autowiring target "blogPosts".'); + + $this->processConfiguration([ + 'paginators' => [ + 'blog-posts' => [], + 'blog_posts' => [], + ], + ]); + } + + public function testTreeBuilderName() + { + $bundle = new UXPaginationBundle(); + $extension = $bundle->getContainerExtension(); + $container = new ContainerBuilder(); + + $configuration = $extension->getConfiguration([], $container); + + self::assertSame('ux_pagination', $configuration->getConfigTreeBuilder()->buildTree()->getName()); + } + + private function processConfiguration(array $input): array + { + $bundle = new UXPaginationBundle(); + $extension = $bundle->getContainerExtension(); + $container = new ContainerBuilder(); + + $configuration = $extension->getConfiguration([], $container); + $processor = new Processor(); + + return $processor->processConfiguration($configuration, ['ux_pagination' => $input]); + } +} diff --git a/src/Pagination/tests/DependencyInjection/CursorSecretLifecycleTest.php b/src/Pagination/tests/DependencyInjection/CursorSecretLifecycleTest.php new file mode 100644 index 00000000000..95faa665979 --- /dev/null +++ b/src/Pagination/tests/DependencyInjection/CursorSecretLifecycleTest.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\UX\Pagination\Cursor\CursorCodecInterface; +use Symfony\UX\Pagination\Exception\RuntimeException; +use Symfony\UX\Pagination\PaginatorInterface; +use Symfony\UX\Pagination\UXPaginationBundle; + +#[CoversNothing] +final class CursorSecretLifecycleTest extends TestCase +{ + public function testCursorCodecUsesAnInterfaceBasedLazyProxy() + { + $container = $this->loadContainer(kernelSecret: null); + $definition = $container->getDefinition('ux_pagination.cursor_codec'); + + self::assertTrue($definition->isLazy()); + self::assertSame( + [['interface' => CursorCodecInterface::class]], + $definition->getTag('proxy'), + ); + } + + public function testOffsetPaginationWorksWithoutAnySigningSecret() + { + $container = $this->loadContainer(kernelSecret: null); + $container->compile(); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + $pagination = $paginator->paginate(range(1, 5), page: 2, perPage: 2); + + self::assertSame([3, 4], $pagination->getItems()); + } + + public function testMissingSecretFailsOnlyWhenCursorSigningIsNeeded() + { + $container = $this->loadContainer(kernelSecret: null); + $container->compile(); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + $pagination = $paginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('non-empty "ux_pagination.cursor.secret" or "kernel.secret"'); + + $pagination->getItems(); + } + + public function testMissingSecretIsNotReportedAsAnInvalidClientCursor() + { + $container = $this->loadContainer(kernelSecret: null); + $container->compile(); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + $builder = $paginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->cursor('opaque-client-value') + ->perPage(2) + ->context('events'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('non-empty "ux_pagination.cursor.secret" or "kernel.secret"'); + + $builder->paginate(); + } + + public function testKernelSecretIsUsedAsTheDefaultCursorSecret() + { + $firstContainer = $this->loadContainer(kernelSecret: 'shared-kernel-secret'); + $firstContainer->compile(); + + /** @var PaginatorInterface $firstPaginator */ + $firstPaginator = $firstContainer->get(PaginatorInterface::class); + $firstPage = $firstPaginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + $cursor = $firstPage->getNextCursor(); + + self::assertNotNull($cursor); + + $secondContainer = $this->loadContainer(kernelSecret: 'shared-kernel-secret'); + $secondContainer->compile(); + + /** @var PaginatorInterface $secondPaginator */ + $secondPaginator = $secondContainer->get(PaginatorInterface::class); + $secondPage = $secondPaginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->cursor($cursor) + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([3, 4], array_column($secondPage->getItems(), 'id')); + } + + public function testDedicatedCursorSecretOverridesKernelSecret() + { + $firstContainer = $this->loadContainer( + kernelSecret: 'first-kernel-secret', + cursorSecret: 'dedicated-cursor-secret', + ); + $firstContainer->compile(); + + /** @var PaginatorInterface $firstPaginator */ + $firstPaginator = $firstContainer->get(PaginatorInterface::class); + $cursor = $firstPaginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate() + ->getNextCursor(); + + self::assertNotNull($cursor); + + $secondContainer = $this->loadContainer( + kernelSecret: 'another-kernel-secret', + cursorSecret: 'dedicated-cursor-secret', + ); + $secondContainer->compile(); + + /** @var PaginatorInterface $secondPaginator */ + $secondPaginator = $secondContainer->get(PaginatorInterface::class); + $secondPage = $secondPaginator + ->cursor($this->cursorSource()) + ->orderBy('id', 'ASC') + ->cursor($cursor) + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([3, 4], array_column($secondPage->getItems(), 'id')); + } + + private function loadContainer(?string $kernelSecret, ?string $cursorSecret = null): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', sys_get_temp_dir().'/build'); + $container->setParameter('kernel.debug', true); + $container->setParameter('kernel.bundles', []); + if (null !== $kernelSecret) { + $container->setParameter('kernel.secret', $kernelSecret); + } + + $bundle = new UXPaginationBundle(); + $bundle->build($container); + $config = null === $cursorSecret ? [] : [['cursor' => ['secret' => $cursorSecret]]]; + $bundle->getContainerExtension()->load($config, $container); + + $container->getAlias(PaginatorInterface::class)->setPublic(true); + + return $container; + } + + /** + * @return list + */ + private function cursorSource(): array + { + return array_map( + static fn (int $id): array => ['id' => $id, 'name' => 'Event '.$id], + range(1, 6), + ); + } +} diff --git a/src/Pagination/tests/DependencyInjection/UXPaginationExtensionTest.php b/src/Pagination/tests/DependencyInjection/UXPaginationExtensionTest.php new file mode 100644 index 00000000000..4af1eb2e924 --- /dev/null +++ b/src/Pagination/tests/DependencyInjection/UXPaginationExtensionTest.php @@ -0,0 +1,447 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\DependencyInjection; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; +use Symfony\UX\Pagination\Adapter\OffsetAdapterInterface; +use Symfony\UX\Pagination\Adapter\PaginationAdapterInterface; +use Symfony\UX\Pagination\Navigation\NavigationMode; +use Symfony\UX\Pagination\PaginatorInterface; +use Symfony\UX\Pagination\UXPaginationBundle; + +#[CoversClass(UXPaginationBundle::class)] +final class UXPaginationExtensionTest extends TestCase +{ + public function testLoadInjectsDefaultConfigurationWithoutExposingParameters() + { + $container = $this->createContainer(); + $extension = $this->getExtension(); + + $extension->load([], $container); + + foreach (array_keys($container->getParameterBag()->all()) as $parameter) { + self::assertStringStartsNotWith('ux_pagination.', $parameter, \sprintf('Parameter "%s" should not be exposed.', $parameter)); + } + + $paginator = $container->getDefinition('ux_pagination.paginator'); + self::assertSame(20, $paginator->getArgument('$defaultPerPage')); + self::assertSame(100_000, $paginator->getArgument('$defaultMaxOffset')); + self::assertSame('page', $paginator->getArgument('$defaultPageParam')); + self::assertSame('cursor', $paginator->getArgument('$defaultCursorParam')); + self::assertSame(NavigationMode::Sliding, $paginator->getArgument('$defaultNavigationMode')); + self::assertSame(5, $paginator->getArgument('$defaultNavigationSize')); + $cursorSecret = $container->getDefinition('ux_pagination.cursor_codec')->getArgument('$secret'); + self::assertSame('%kernel.secret%', $cursorSecret); + } + + public function testLoadInjectsCustomConfiguration() + { + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', ['TwigBundle' => TwigBundle::class]); + $extension = $this->getExtension(); + + $extension->load([ + [ + 'items_per_page' => 50, + 'max_offset' => 250_000, + 'page_parameter' => 'p', + 'cursor_parameter' => 'after', + 'navigation' => ['mode' => 'fixed', 'size' => 9], + 'theme' => '@UXPagination/theme/tailwind.html.twig', + 'cursor' => ['secret' => 'pagination-secret'], + ], + ], $container); + + $paginator = $container->getDefinition('ux_pagination.paginator'); + self::assertSame(50, $paginator->getArgument('$defaultPerPage')); + self::assertSame(250_000, $paginator->getArgument('$defaultMaxOffset')); + self::assertSame('p', $paginator->getArgument('$defaultPageParam')); + self::assertSame('after', $paginator->getArgument('$defaultCursorParam')); + self::assertSame(NavigationMode::Fixed, $paginator->getArgument('$defaultNavigationMode')); + self::assertSame(9, $paginator->getArgument('$defaultNavigationSize')); + self::assertSame('pagination-secret', $container->getDefinition('ux_pagination.cursor_codec')->getArgument('$secret')); + self::assertSame( + '@UXPagination/theme/tailwind.html.twig', + $container + ->getDefinition('ux_pagination.renderer') + ->getArgument('$defaultTheme'), + ); + } + + public function testLoadWithCustomThemeName() + { + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', ['TwigBundle' => TwigBundle::class]); + $extension = $this->getExtension(); + + $extension->load([ + ['theme' => '@App/pagination/my_theme.html.twig'], + ], $container); + + self::assertSame( + '@App/pagination/my_theme.html.twig', + $container + ->getDefinition('ux_pagination.renderer') + ->getArgument('$defaultTheme'), + ); + } + + public function testLoadWithApplicationThemePath() + { + $container = $this->createContainer(); + $container->setParameter( + 'kernel.bundles', + ['TwigBundle' => TwigBundle::class], + ); + + $this->getExtension()->load([ + ['theme' => 'pagination/application.html.twig'], + ], $container); + + self::assertSame( + 'pagination/application.html.twig', + $container + ->getDefinition('ux_pagination.renderer') + ->getArgument('$defaultTheme'), + ); + } + + public function testNamedPaginatorsInheritRootConfigurationAndOverrideSelectedValues() + { + $container = $this->createContainer(); + + $this->getExtension()->load([[ + 'items_per_page' => 30, + 'max_offset' => 200_000, + 'page_parameter' => 'p', + 'cursor_parameter' => 'after', + 'navigation' => ['mode' => 'fixed', 'size' => 8], + 'paginators' => [ + 'blog' => [ + 'items_per_page' => 12, + 'navigation' => ['size' => 7], + ], + ], + ]], $container); + + $paginator = $container->getDefinition('ux_pagination.paginator.blog'); + self::assertFalse($paginator->isPublic()); + self::assertSame(12, $paginator->getArgument('$defaultPerPage')); + self::assertSame(200_000, $paginator->getArgument('$defaultMaxOffset')); + self::assertSame('p', $paginator->getArgument('$defaultPageParam')); + self::assertSame('after', $paginator->getArgument('$defaultCursorParam')); + self::assertSame(NavigationMode::Fixed, $paginator->getArgument('$defaultNavigationMode')); + self::assertSame(7, $paginator->getArgument('$defaultNavigationSize')); + self::assertSame('ux_pagination.paginator.blog', (string) $container->getAlias(PaginatorInterface::class.' $blogPaginator')); + self::assertSame(PaginatorInterface::class.' $blogPaginator', (string) $container->getAlias('.'.PaginatorInterface::class.' $blog')); + } + + public function testNamedPaginatorInjectionSupportsEveryPublicEntryPoint() + { + $container = $this->createContainer(); + $bundle = new UXPaginationBundle(); + $bundle->build($container); + $bundle->getContainerExtension()->load([[ + 'paginators' => [ + 'blog' => [ + 'items_per_page' => 2, + ], + ], + ]], $container); + $container->register(NamedPaginatorConsumer::class) + ->setAutowired(true) + ->setPublic(true); + $container->compile(); + + /** @var NamedPaginatorConsumer $consumer */ + $consumer = $container->get(NamedPaginatorConsumer::class); + $paginator = $consumer->blogPaginator; + + $simple = $paginator->paginate(range(1, 5)); + self::assertSame([1, 2], $simple->getItems()); + + $numbered = $paginator->query(range(1, 5))->paginate(); + self::assertSame([1, 2], $numbered->getItems()); + + $items = range(1, 5); + $callbacks = $paginator + ->fromCallbacks( + static fn (int $offset, int $limit): array => \array_slice($items, $offset, $limit), + static fn (): int => \count($items), + ) + ->paginate(); + self::assertSame([1, 2], $callbacks->getItems()); + + $cursor = $paginator + ->cursor(array_map(static fn (int $id): array => ['id' => $id], $items)) + ->orderBy('id', 'ASC') + ->context('blog') + ->paginate(); + self::assertSame([1, 2], array_column($cursor->getItems(), 'id')); + } + + public function testAdapterIteratorUsesTheSymfonyDefaultPriorityConvention() + { + $container = $this->createContainer(); + + $this->getExtension()->load([], $container); + + $adapters = $container->getDefinition('ux_pagination.paginator')->getArgument('$adapters'); + self::assertInstanceOf(TaggedIteratorArgument::class, $adapters); + self::assertSame('getDefaultPriority', $adapters->getDefaultPriorityMethod()); + } + + public function testAdapterDefaultPriorityMethodControlsResolutionOrder() + { + $container = $this->createContainerWithAdapters([ + 'app.adapter.explicit' => [TaggedTestPaginationAdapter::class, ['explicit'], 10], + 'app.adapter.static' => [DefaultPriorityTestPaginationAdapter::class, [], null], + ]); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + + self::assertSame(['static'], $paginator->paginate(new PriorityTestSource())->getItems()); + } + + public function testExplicitAdapterTagPriorityTakesPrecedence() + { + $container = $this->createContainerWithAdapters([ + 'app.adapter.static' => [DefaultPriorityTestPaginationAdapter::class, [], null], + 'app.adapter.explicit' => [TaggedTestPaginationAdapter::class, ['explicit'], 100], + ]); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + + self::assertSame(['explicit'], $paginator->paginate(new PriorityTestSource())->getItems()); + } + + public function testEqualAdapterPrioritiesKeepRegistrationOrder() + { + $container = $this->createContainerWithAdapters([ + 'app.adapter.first' => [TaggedTestPaginationAdapter::class, ['first'], 10], + 'app.adapter.second' => [TaggedTestPaginationAdapter::class, ['second'], 10], + ]); + + /** @var PaginatorInterface $paginator */ + $paginator = $container->get(PaginatorInterface::class); + + self::assertSame(['first'], $paginator->paginate(new PriorityTestSource())->getItems()); + } + + public function testGetAlias() + { + $extension = $this->getExtension(); + + self::assertSame('ux_pagination', $extension->getAlias()); + } + + public function testLoadRegistersDoctrineOrmAdapter() + { + if (!class_exists(\Doctrine\ORM\QueryBuilder::class)) { + self::markTestSkipped('Doctrine ORM is not installed.'); + } + + $container = $this->createContainer(); + $extension = $this->getExtension(); + + $extension->load([], $container); + + self::assertTrue($container->hasDefinition('ux_pagination.adapter.doctrine_orm')); + } + + public function testLoadRegistersDoctrineDbalAdapter() + { + if (!class_exists(\Doctrine\DBAL\Query\QueryBuilder::class)) { + self::markTestSkipped('Doctrine DBAL is not installed.'); + } + + $container = $this->createContainer(); + + $this->getExtension()->load([], $container); + + self::assertTrue($container->hasDefinition('ux_pagination.adapter.doctrine_dbal')); + } + + public function testTwigServicesAreNotRegisteredWhenTwigBundleIsInstalledButInactive() + { + self::assertTrue(class_exists(TwigBundle::class)); + + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', []); + + $this->getExtension()->load([], $container); + + self::assertFalse($container->hasDefinition('ux_pagination.twig.extension')); + self::assertFalse($container->hasDefinition('ux_pagination.renderer')); + } + + public function testTwigServicesAreRegisteredWhenTwigBundleIsActive() + { + $container = $this->createContainer(); + $container->setParameter('kernel.bundles', ['TwigBundle' => TwigBundle::class]); + + $this->getExtension()->load([], $container); + + self::assertTrue($container->hasDefinition('ux_pagination.twig.extension')); + self::assertTrue($container->getDefinition('ux_pagination.twig.extension')->isAutoconfigured()); + self::assertTrue($container->hasDefinition('ux_pagination.renderer')); + } + + public function testCustomAdaptersAreAutoconfigured() + { + $container = $this->createContainer(); + new UXPaginationBundle()->build($container); + $container->register('app.pagination_adapter', TestPaginationAdapter::class) + ->setAutoconfigured(true) + ->setPublic(true); + $container->compile(); + + self::assertSame(['ux_pagination.adapter' => [[]]], $container->getDefinition('app.pagination_adapter')->getTags()); + } + + private function getExtension(): ExtensionInterface + { + $bundle = new UXPaginationBundle(); + + return $bundle->getContainerExtension(); + } + + public function testMissingKernelSecretFallsBackToAnEmptyCursorSecret() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', sys_get_temp_dir().'/build'); + $container->setParameter('kernel.debug', true); + + $this->getExtension()->load([], $container); + + self::assertSame('', $container->getDefinition('ux_pagination.cursor_codec')->getArgument('$secret')); + } + + private function createContainer(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', sys_get_temp_dir().'/build'); + $container->setParameter('kernel.debug', true); + $container->setParameter('kernel.secret', 'kernel-secret'); + + return $container; + } + + /** + * @param array, list, int|null}> $adapters + */ + private function createContainerWithAdapters(array $adapters): ContainerBuilder + { + $container = $this->createContainer(); + new UXPaginationBundle()->build($container); + $this->getExtension()->load([], $container); + + foreach ($adapters as $id => [$class, $arguments, $priority]) { + $attributes = null === $priority ? [] : ['priority' => $priority]; + $container->register($id, $class) + ->setArguments($arguments) + ->addTag('ux_pagination.adapter', $attributes); + } + + $container->getAlias(PaginatorInterface::class)->setPublic(true); + $container->compile(); + + return $container; + } +} + +final class TestPaginationAdapter implements OffsetAdapterInterface +{ + public function supports(mixed $source): bool + { + return false; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return []; + } + + public function count(mixed $source): int + { + return 0; + } +} + +final class PriorityTestSource +{ +} + +final class NamedPaginatorConsumer +{ + public function __construct( + public readonly PaginatorInterface $blogPaginator, + ) { + } +} + +final class TaggedTestPaginationAdapter implements OffsetAdapterInterface +{ + public function __construct( + private readonly string $value, + ) { + } + + public function supports(mixed $source): bool + { + return $source instanceof PriorityTestSource; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return [$this->value]; + } + + public function count(mixed $source): int + { + return 1; + } +} + +final class DefaultPriorityTestPaginationAdapter implements OffsetAdapterInterface +{ + public static function getDefaultPriority(): int + { + return 50; + } + + public function supports(mixed $source): bool + { + return $source instanceof PriorityTestSource; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return ['static']; + } + + public function count(mixed $source): int + { + return 1; + } +} diff --git a/src/Pagination/tests/Exception/ExceptionInterfaceTest.php b/src/Pagination/tests/Exception/ExceptionInterfaceTest.php new file mode 100644 index 00000000000..07f820c65d5 --- /dev/null +++ b/src/Pagination/tests/Exception/ExceptionInterfaceTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Exception; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Exception\ExceptionInterface; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Exception\NavigationTooLargeException; +use Symfony\UX\Pagination\Exception\OffsetLimitExceededException; +use Symfony\UX\Pagination\Exception\OutOfRangePageException; +use Symfony\UX\Pagination\Exception\RuntimeException; +use Symfony\UX\Pagination\Exception\UnsupportedDoctrineQueryException; +use Symfony\UX\Pagination\Navigation\Navigation; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; + +#[CoversNothing] +final class ExceptionInterfaceTest extends TestCase +{ + #[DataProvider('providePublicExceptions')] + public function testPublicExceptionImplementsPackageMarker(string $exception) + { + self::assertTrue(is_subclass_of($exception, ExceptionInterface::class)); + } + + public function testPackageErrorsCanBeCaughtByTheMarker() + { + try { + new Navigation(1, 10, new PaginationUrlGenerator(), modeParameter: -1); + self::fail('Expected a pagination exception.'); + } catch (ExceptionInterface $exception) { + self::assertInstanceOf(InvalidArgumentException::class, $exception); + } + } + + /** + * @return iterable}> + */ + public static function providePublicExceptions(): iterable + { + yield InvalidArgumentException::class => [InvalidArgumentException::class]; + yield InvalidCursorException::class => [InvalidCursorException::class]; + yield NavigationTooLargeException::class => [NavigationTooLargeException::class]; + yield OffsetLimitExceededException::class => [OffsetLimitExceededException::class]; + yield OutOfRangePageException::class => [OutOfRangePageException::class]; + yield RuntimeException::class => [RuntimeException::class]; + yield UnsupportedDoctrineQueryException::class => [UnsupportedDoctrineQueryException::class]; + } +} diff --git a/src/Pagination/tests/Exception/ExceptionValueTest.php b/src/Pagination/tests/Exception/ExceptionValueTest.php new file mode 100644 index 00000000000..929aecd9d20 --- /dev/null +++ b/src/Pagination/tests/Exception/ExceptionValueTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Exception\NavigationTooLargeException; +use Symfony\UX\Pagination\Exception\OffsetLimitExceededException; + +#[CoversClass(InvalidCursorException::class)] +#[CoversClass(NavigationTooLargeException::class)] +#[CoversClass(OffsetLimitExceededException::class)] +final class ExceptionValueTest extends TestCase +{ + public function testInvalidCursorPreservesMessageAndPreviousException() + { + $previous = new \RuntimeException('decoder failed'); + $exception = new InvalidCursorException('Bad cursor.', $previous); + + self::assertSame('Bad cursor.', $exception->getMessage()); + self::assertSame($previous, $exception->getPrevious()); + self::assertSame(400, $exception->getStatusCode()); + } + + public function testOffsetLimitPreservesContext() + { + $exception = new OffsetLimitExceededException(51, 20, 1000); + + self::assertSame(51, $exception->page); + self::assertSame(20, $exception->perPage); + self::assertSame(1000, $exception->maximumOffset); + self::assertStringContainsString('cursor pagination', $exception->getMessage()); + self::assertSame(400, $exception->getStatusCode()); + } + + public function testNavigationTooLargeExplainsTheLimit() + { + $exception = new NavigationTooLargeException(500, 100); + + self::assertStringContainsString('500 pagination links', $exception->getMessage()); + self::assertStringContainsString('limited to 100 pages', $exception->getMessage()); + } +} diff --git a/src/Pagination/tests/Exception/OutOfRangePageExceptionTest.php b/src/Pagination/tests/Exception/OutOfRangePageExceptionTest.php new file mode 100644 index 00000000000..d3a3f4796a3 --- /dev/null +++ b/src/Pagination/tests/Exception/OutOfRangePageExceptionTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\UX\Pagination\Exception\OutOfRangePageException; + +#[CoversClass(OutOfRangePageException::class)] +final class OutOfRangePageExceptionTest extends TestCase +{ + public function testPagesAreExposed() + { + $exception = new OutOfRangePageException(12, 5); + + self::assertSame(12, $exception->requestedPage); + self::assertSame(5, $exception->lastPage); + } + + public function testMessageContainsPages() + { + $exception = new OutOfRangePageException(12, 5); + + self::assertStringContainsString('12', $exception->getMessage()); + self::assertStringContainsString('5', $exception->getMessage()); + } + + public function testIsANotFoundHttpException() + { + $exception = new OutOfRangePageException(2, 1); + + self::assertInstanceOf(NotFoundHttpException::class, $exception); + } +} diff --git a/src/Pagination/tests/Fixtures/Entity/Author.php b/src/Pagination/tests/Fixtures/Entity/Author.php new file mode 100644 index 00000000000..75a3937ab6a --- /dev/null +++ b/src/Pagination/tests/Fixtures/Entity/Author.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ORM\Table(name: 'authors')] +class Author +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author', cascade: ['persist', 'remove'])] + private Collection $books; + + public function __construct() + { + $this->books = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + /** + * @return Collection + */ + public function getBooks(): Collection + { + return $this->books; + } + + public function addBook(Book $book): self + { + if (!$this->books->contains($book)) { + $this->books->add($book); + $book->setAuthor($this); + } + + return $this; + } + + public function removeBook(Book $book): self + { + if ($this->books->removeElement($book)) { + if ($book->getAuthor() === $this) { + $book->setAuthor(null); + } + } + + return $this; + } +} diff --git a/src/Pagination/tests/Fixtures/Entity/Book.php b/src/Pagination/tests/Fixtures/Entity/Book.php new file mode 100644 index 00000000000..e37dd10d159 --- /dev/null +++ b/src/Pagination/tests/Fixtures/Entity/Book.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ORM\Table(name: 'books')] +class Book +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $title; + + #[ORM\Column(type: 'float')] + private float $price = 0.0; + + #[ORM\Column(type: 'float', nullable: true)] + private ?float $rating = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $publishedAt; + + /** @var array */ + #[ORM\Column(type: 'json')] + private array $metadata = []; + + #[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')] + #[ORM\JoinColumn(nullable: true)] + private ?Author $author = null; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Category::class, inversedBy: 'books')] + #[ORM\JoinTable(name: 'book_categories')] + private Collection $categories; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->publishedAt = new \DateTimeImmutable('2000-01-01T00:00:00+00:00'); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getPrice(): float + { + return $this->price; + } + + public function setPrice(float $price): self + { + $this->price = $price; + + return $this; + } + + public function getRating(): ?float + { + return $this->rating; + } + + public function getPublishedAt(): \DateTimeImmutable + { + return $this->publishedAt; + } + + public function setPublishedAt(\DateTimeImmutable $publishedAt): self + { + $this->publishedAt = $publishedAt; + + return $this; + } + + /** @return array */ + public function getMetadata(): array + { + return $this->metadata; + } + + public function getAuthor(): ?Author + { + return $this->author; + } + + public function setAuthor(?Author $author): self + { + $this->author = $author; + + return $this; + } + + /** + * @return Collection + */ + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(Category $category): self + { + if (!$this->categories->contains($category)) { + $this->categories->add($category); + } + + return $this; + } + + public function removeCategory(Category $category): self + { + $this->categories->removeElement($category); + + return $this; + } +} diff --git a/src/Pagination/tests/Fixtures/Entity/Category.php b/src/Pagination/tests/Fixtures/Entity/Category.php new file mode 100644 index 00000000000..5320eef8b4a --- /dev/null +++ b/src/Pagination/tests/Fixtures/Entity/Category.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ORM\Table(name: 'categories')] +class Category +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Book::class, mappedBy: 'categories')] + private Collection $books; + + public function __construct() + { + $this->books = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getBooks(): Collection + { + return $this->books; + } +} diff --git a/src/Pagination/tests/Fixtures/EntityManagerFactory.php b/src/Pagination/tests/Fixtures/EntityManagerFactory.php new file mode 100644 index 00000000000..3853190101d --- /dev/null +++ b/src/Pagination/tests/Fixtures/EntityManagerFactory.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures; + +use Doctrine\DBAL\DriverManager; +use Doctrine\ORM\Configuration; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\ORMSetup; +use Doctrine\ORM\Tools\SchemaTool; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author; + +/** + * Creates in-memory sqlite EntityManagers (ORM 3 / ORM 4 compatible setup). + */ +final class EntityManagerFactory +{ + /** + * @param list $entities Entities to create the schema for + * @param (callable(Configuration): void)|null $configure Hook to adjust the ORM configuration before the connection is created + */ + public static function create(array $entities = [Author::class], ?callable $configure = null): EntityManager + { + $config = method_exists(ORMSetup::class, 'createAttributeMetadataConfig') + ? ORMSetup::createAttributeMetadataConfig( + paths: [__DIR__.'/Entity'], + isDevMode: true, + ) + : ORMSetup::createAttributeMetadataConfiguration( + paths: [__DIR__.'/Entity'], + isDevMode: true, + ); + + // Enable native lazy objects for PHP 8.4+ with ORM 3 (always on with ORM 4) + if (\PHP_VERSION_ID >= 80400 && method_exists($config, 'enableNativeLazyObjects')) { + $config->enableNativeLazyObjects(true); + } + + if (null !== $configure) { + $configure($config); + } + + $connection = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ], $config); + + $entityManager = new EntityManager($connection, $config); + + new SchemaTool($entityManager)->createSchema( + array_map($entityManager->getClassMetadata(...), $entities), + ); + + return $entityManager; + } +} diff --git a/src/Pagination/tests/Fixtures/LiveAutoRoutePaginationComponent.php b/src/Pagination/tests/Fixtures/LiveAutoRoutePaginationComponent.php new file mode 100644 index 00000000000..9b23d0b6ecc --- /dev/null +++ b/src/Pagination/tests/Fixtures/LiveAutoRoutePaginationComponent.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\Pagination\LiveComponent\ComponentWithPaginationTrait; +use Symfony\UX\Pagination\PaginationBuilder; +use Symfony\UX\Pagination\PaginatorInterface; + +#[AsLiveComponent('live_pagination_auto', template: 'components/live_pagination.html.twig')] +final class LiveAutoRoutePaginationComponent +{ + use ComponentWithPaginationTrait; + use DefaultActionTrait; + + public function __construct( + private readonly PaginatorInterface $paginator, + ) { + } + + protected function createPagination(): PaginationBuilder + { + return $this->paginator + ->query(range(1, 30)) + ->perPage(10); + } +} diff --git a/src/Pagination/tests/Fixtures/LivePaginationComponent.php b/src/Pagination/tests/Fixtures/LivePaginationComponent.php new file mode 100644 index 00000000000..f0677b92d20 --- /dev/null +++ b/src/Pagination/tests/Fixtures/LivePaginationComponent.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Fixtures; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\Pagination\LiveComponent\ComponentWithPaginationTrait; +use Symfony\UX\Pagination\PaginationBuilder; +use Symfony\UX\Pagination\PaginatorInterface; + +#[AsLiveComponent('live_pagination', template: 'components/live_pagination.html.twig')] +final class LivePaginationComponent +{ + use ComponentWithPaginationTrait; + use DefaultActionTrait; + + public function __construct( + private readonly PaginatorInterface $paginator, + ) { + } + + protected function createPagination(): PaginationBuilder + { + return $this->paginator + ->query(range(1, 30)) + ->perPage(10) + ->path('/products'); + } +} diff --git a/src/Pagination/tests/Fixtures/templates/bundles/UXPaginationBundle/theme/default.html.twig b/src/Pagination/tests/Fixtures/templates/bundles/UXPaginationBundle/theme/default.html.twig new file mode 100644 index 00000000000..900de254f2a --- /dev/null +++ b/src/Pagination/tests/Fixtures/templates/bundles/UXPaginationBundle/theme/default.html.twig @@ -0,0 +1,3 @@ +{% extends '@!UXPagination/theme/default.html.twig' %} + +{% block previous_label %}{{ parent() }}{% endblock %} diff --git a/src/Pagination/tests/Fixtures/templates/components/live_pagination.html.twig b/src/Pagination/tests/Fixtures/templates/components/live_pagination.html.twig new file mode 100644 index 00000000000..32005583c33 --- /dev/null +++ b/src/Pagination/tests/Fixtures/templates/components/live_pagination.html.twig @@ -0,0 +1,8 @@ +
+

{{ this.pagination.currentPage }}

+ + {{ ux_pagination( + this.pagination, + linkAttributes: this.paginationLinkAttributes, + ) }} +
diff --git a/src/Pagination/tests/Fixtures/templates/explicit.html.twig b/src/Pagination/tests/Fixtures/templates/explicit.html.twig new file mode 100644 index 00000000000..776b31cf727 --- /dev/null +++ b/src/Pagination/tests/Fixtures/templates/explicit.html.twig @@ -0,0 +1 @@ +explicit template diff --git a/src/Pagination/tests/Fixtures/templates/theme/custom.html.twig b/src/Pagination/tests/Fixtures/templates/theme/custom.html.twig new file mode 100644 index 00000000000..46d6cc4e1d5 --- /dev/null +++ b/src/Pagination/tests/Fixtures/templates/theme/custom.html.twig @@ -0,0 +1 @@ +custom template diff --git a/src/Pagination/tests/Integration/UXPaginationIntegrationTest.php b/src/Pagination/tests/Integration/UXPaginationIntegrationTest.php new file mode 100644 index 00000000000..839acc9644f --- /dev/null +++ b/src/Pagination/tests/Integration/UXPaginationIntegrationTest.php @@ -0,0 +1,583 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Integration; + +use PHPUnit\Framework\Attributes\CoversNothing; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\Target; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\UX\LiveComponent\LiveComponentBundle; +use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginatorInterface; +use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author; +use Symfony\UX\Pagination\Tests\Fixtures\EntityManagerFactory; +use Symfony\UX\Pagination\Tests\Fixtures\LiveAutoRoutePaginationComponent; +use Symfony\UX\Pagination\Tests\Fixtures\LivePaginationComponent; +use Symfony\UX\Pagination\Twig\PaginationExtension; +use Symfony\UX\Pagination\Twig\PaginationRenderer; +use Symfony\UX\Pagination\UXPaginationBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; +use Symfony\UX\TwigComponent\TwigComponentBundle; +use Twig\Environment; + +/** + * Boots a real kernel with FrameworkBundle + TwigBundle + UXPaginationBundle + * and exercises the whole chain: container wiring, adapter discovery, + * Twig rendering with translations. + */ +#[CoversNothing] +final class UXPaginationIntegrationTest extends KernelTestCase +{ + use InteractsWithLiveComponents; + + protected static function getKernelClass(): string + { + return UXPaginationTestKernel::class; + } + + public function testPaginatorServiceIsAutowirable() + { + self::bootKernel(); + + $paginator = self::getContainer()->get(PaginatorInterface::class); + + self::assertInstanceOf(PaginatorInterface::class, $paginator); + } + + public function testNamedPaginatorSupportsSymfonyAutowiringConventions() + { + self::bootKernel(); + + $consumer = self::getContainer()->get(NamedPaginatorConsumer::class); + + self::assertSame($consumer->blogPaginator, $consumer->targetedPaginator); + self::assertSame($consumer->blogPaginator, $consumer->explicitPaginator); + + $pagination = $consumer->blogPaginator->paginate(range(1, 120), page: 5); + self::assertSame(12, $pagination->getItemsPerPage()); + self::assertSame('p', $pagination->getPageParameterName()); + self::assertCount(8, $pagination->getPages()); + } + + public function testPaginatorIsInjectedIntoAControllerAction() + { + $kernel = self::bootKernel(['environment' => 'controller_injection']); + $request = Request::create('/_ux-pagination/controller'); + $response = $kernel->handle($request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('Symfony\UX\Pagination\Paginator', $response->getContent()); + + $kernel->terminate($request, $response); + } + + public function testRendererServiceIsConfigured() + { + self::bootKernel(); + + self::assertInstanceOf( + PaginationRenderer::class, + self::getContainer()->get('ux_pagination.renderer'), + ); + } + + public function testTwigRegistersThePaginationFunctions() + { + self::bootKernel(); + + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + self::assertNotNull($twig->getFunction('ux_pagination')); + self::assertNull($twig->getFunction('pagination_head')); + } + + public function testArrayAdapterIsWired() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + + $pagination = $paginator->paginate(range(1, 45), page: 2, perPage: 10); + + self::assertInstanceOf(Pagination::class, $pagination); + self::assertSame(range(11, 20), $pagination->getItems()); + self::assertSame(45, $pagination->getTotalItems()); + self::assertSame(5, $pagination->getTotalPages()); + } + + public function testDoctrineOrmAdapterIsTagged() + { + if (!class_exists(\Doctrine\ORM\QueryBuilder::class)) { + self::markTestSkipped('Doctrine ORM is not installed.'); + } + + self::bootKernel(); + + $container = self::getContainer(); + + self::assertTrue($container->has('ux_pagination.adapter.doctrine_orm')); + } + + public function testDoctrineDbalAdapterIsTagged() + { + if (!class_exists(\Doctrine\DBAL\Query\QueryBuilder::class)) { + self::markTestSkipped('Doctrine DBAL is not installed.'); + } + + self::bootKernel(); + + self::assertTrue(self::getContainer()->has('ux_pagination.adapter.doctrine_dbal')); + } + + public function testTwigFunctionRendersNavigationWithTranslations() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 100)) + ->perPage(10) + ->path('/items') + ->paginate(page: 3); + + $html = $twig->getRuntime(PaginationExtension::class)->renderPagination($pagination); + + self::assertMatchesRegularExpression('/]+class="ux-pagination/', $html); + self::assertStringNotContainsString('data-controller=', $html); + self::assertStringContainsString('aria-current="page"', $html); + self::assertStringContainsString('Previous', $html); + self::assertStringContainsString('Next', $html); + self::assertStringNotContainsString('pagination.previous', $html); + self::assertStringContainsString('21-30', $html); + self::assertStringContainsString('100', $html); + } + + public function testBootstrapThemeRenders() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 1); + + $html = $twig->getRuntime(PaginationExtension::class)->renderPagination( + $pagination, + theme: '@UXPagination/theme/bootstrap.html.twig', + ); + + self::assertStringContainsString('pagination', $html); + self::assertStringContainsString('ux-pagination-bootstrap', $html); + } + + public function testConfiguredThemeIsUsedByDefault() + { + self::bootKernel(['environment' => 'bootstrap_theme']); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 2); + + $html = $twig + ->getRuntime(PaginationExtension::class) + ->renderPagination($pagination); + + self::assertStringContainsString('ux-pagination-bootstrap', $html); + self::assertStringNotContainsString('ux-pagination-tailwind', $html); + } + + public function testExplicitThemeOverridesConfiguredTheme() + { + self::bootKernel(['environment' => 'bootstrap_theme']); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 2); + + $html = $twig + ->getRuntime(PaginationExtension::class) + ->renderPagination( + $pagination, + theme: '@UXPagination/theme/tailwind.html.twig', + ); + + self::assertStringContainsString('ux-pagination-tailwind', $html); + self::assertStringNotContainsString('ux-pagination-bootstrap', $html); + } + + public function testDocumentedTwigComponentRenders() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 2); + + $html = $twig->createTemplate(<<<'TWIG' + + TWIG) + ->render(['pagination' => $pagination]); + + self::assertStringContainsString(' 'bootstrap_theme']); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 2); + + $html = $twig->createTemplate( + '', + )->render(['pagination' => $pagination]); + + self::assertStringContainsString('ux-pagination-bootstrap', $html); + self::assertStringNotContainsString('ux-pagination-tailwind', $html); + } + + public function testTwigComponentThemeOverridesTheConfiguredTheme() + { + self::bootKernel(['environment' => 'bootstrap_theme']); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 50)) + ->perPage(10) + ->path('/items') + ->paginate(page: 2); + + $html = $twig->createTemplate(<<<'TWIG' + + TWIG) + ->render(['pagination' => $pagination]); + + self::assertStringContainsString('ux-pagination-tailwind', $html); + self::assertStringNotContainsString('ux-pagination-bootstrap', $html); + } + + public function testInfoFallsBackToEnglishWithoutTranslator() + { + self::bootKernel(['environment' => 'no_translator']); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + + $pagination = $paginator->paginate(range(1, 100), page: 3, perPage: 10); + + self::assertSame('Showing 21-30 of 100', $pagination->getInfo()); + } + + public function testPaginatesRealQueryBuilderThroughContainerService() + { + if (!class_exists(\Doctrine\ORM\EntityManager::class)) { + self::markTestSkipped('Doctrine ORM is not installed.'); + } + + self::bootKernel(); + + $entityManager = EntityManagerFactory::create(); + for ($i = 1; $i <= 12; ++$i) { + $author = new Author(); + $author->setName('Author '.$i); + $entityManager->persist($author); + } + $entityManager->flush(); + + $queryBuilder = $entityManager->createQueryBuilder() + ->select('a') + ->from(Author::class, 'a') + ->orderBy('a.id', 'ASC'); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + + $pagination = $paginator->paginate($queryBuilder, page: 2, perPage: 5); + + self::assertSame(12, $pagination->getTotalItems()); + self::assertSame(3, $pagination->getTotalPages()); + self::assertSame([6, 7, 8, 9, 10], array_map(static fn (Author $a) => $a->getId(), $pagination->getItems())); + } + + public function testDefaultTemplateRendersCursorPagination() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $source = array_map(static fn (int $i) => ['id' => $i], range(1, 30)); + $pagination = $paginator->cursor($source) + ->orderBy('id', 'ASC') + ->context('events') + ->perPage(10) + ->path('/items') + ->paginate(); + + $html = $twig->getRuntime(PaginationExtension::class)->renderPagination($pagination); + + self::assertStringContainsString('class="ux-pagination"', $html); + self::assertStringContainsString('cursor=', $html); + self::assertStringContainsString('Next', $html); + } + + public function testComponentWithPaginationTraitHandlesARealLiveComponentAction() + { + self::bootKernel(); + + $component = $this->createLiveComponent('live_pagination'); + $initialHtml = (string) $component->render(); + + self::assertStringContainsString('data-controller="live"', $initialHtml); + self::assertStringContainsString('

1

', $initialHtml); + self::assertStringContainsString('href="/products?page=2"', $initialHtml); + self::assertStringContainsString('data-live-page-param="2"', $initialHtml); + + $component->call('goToPage', ['page' => 2]); + $updatedHtml = (string) $component->render(); + + self::assertTrue($component->response()->isSuccessful()); + self::assertStringContainsString('

2

', $updatedHtml); + self::assertStringContainsString('href="/products"', $updatedHtml); + self::assertStringContainsString('aria-current="page">', $updatedHtml); + self::assertSame(2, $component->component()->page); + } + + public function testBundleTemplateOverrideAppliesToTheDefaultTheme() + { + self::bootKernel(); + + /** @var PaginatorInterface $paginator */ + $paginator = self::getContainer()->get(PaginatorInterface::class); + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + + $pagination = $paginator->query(range(1, 100)) + ->perPage(10) + ->path('/items') + ->paginate(page: 3); + + $html = $twig->getRuntime(PaginationExtension::class)->renderPagination($pagination); + + self::assertStringContainsString('', $html); + self::assertStringContainsString('Previous', $html); + } + + public function testCapturedRouteKeepsLinkUrlsStableAcrossLiveRerenders() + { + self::bootKernel(); + + $component = $this->createLiveComponent('live_pagination_auto', [ + 'paginationRoute' => 'ux_pagination_test_products', + ]); + + $initialHtml = (string) $component->render(); + self::assertStringContainsString('href="/products?page=2"', $initialHtml); + + $component->call('goToPage', ['page' => 2]); + $updatedHtml = (string) $component->render(); + + self::assertTrue($component->response()->isSuccessful()); + self::assertStringContainsString('href="/products?page=3"', $updatedHtml); + self::assertStringContainsString('href="/products"', $updatedHtml); + self::assertStringNotContainsString('href="/_components', $updatedHtml); + } +} + +final class UXPaginationTestKernel extends Kernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new StimulusBundle(); + yield new TwigComponentBundle(); + yield new LiveComponentBundle(); + yield new UXPaginationBundle(); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container) { + if (!$container->hasDefinition('kernel')) { + $container->register('kernel', static::class) + ->addTag('controller.service_arguments') + ->setAutoconfigured(true) + ->setSynthetic(true) + ->setPublic(true); + } + $container->getDefinition('kernel')->addTag('routing.route_loader'); + $container->loadFromExtension('framework', [ + 'secret' => 'test-secret', + 'test' => true, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'router' => [ + 'resource' => 'kernel::loadRoutes', + 'type' => 'service', + ], + 'translator' => 'no_translator' === $this->environment + ? ['enabled' => false] + : ['fallbacks' => ['en']], + ]); + $container->loadFromExtension('twig_component', [ + 'defaults' => [], + 'anonymous_template_directory' => 'components/', + ]); + $container->loadFromExtension('twig', [ + 'default_path' => __DIR__.'/../Fixtures/templates', + ]); + $paginationConfig = [ + 'items_per_page' => 30, + 'page_parameter' => 'page', + 'paginators' => [ + 'blog' => [ + 'items_per_page' => 12, + 'page_parameter' => 'p', + 'navigation' => [ + 'mode' => 'fixed', + 'size' => 2, + ], + ], + ], + ]; + if ('bootstrap_theme' === $this->environment) { + $paginationConfig['theme'] = + '@UXPagination/theme/bootstrap.html.twig'; + } + $container->loadFromExtension( + 'ux_pagination', + $paginationConfig, + ); + $container->register(TestPaginationController::class) + ->setAutoconfigured(true) + ->setAutowired(true) + ->setPublic(true) + ->addTag('controller.service_arguments'); + $container->register(NamedPaginatorConsumer::class) + ->setAutowired(true) + ->setPublic(true); + $container->register(LivePaginationComponent::class) + ->setAutoconfigured(true) + ->setAutowired(true); + $container->register(LiveAutoRoutePaginationComponent::class) + ->setAutoconfigured(true) + ->setAutowired(true); + }); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add('ux_pagination_test_controller', '/_ux-pagination/controller') + ->controller(TestPaginationController::class.'::__invoke'); + $routes->add('ux_pagination_test_products', '/products'); + $routes->import('@LiveComponentBundle/config/routes.php') + ->prefix('/_components'); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/ux_pagination_tests/cache/'.Kernel::VERSION_ID.'/'.$this->environment; + } + + public function getLogDir(): string + { + return sys_get_temp_dir().'/ux_pagination_tests/log/'.Kernel::VERSION_ID; + } +} + +final class TestPaginationController +{ + public function __invoke(PaginatorInterface $paginator): Response + { + return new Response($paginator::class); + } +} + +final class NamedPaginatorConsumer +{ + public function __construct( + public readonly PaginatorInterface $blogPaginator, + #[Target('blog')] + public readonly PaginatorInterface $targetedPaginator, + #[Autowire(service: 'ux_pagination.paginator.blog')] + public readonly PaginatorInterface $explicitPaginator, + ) { + } +} diff --git a/src/Pagination/tests/LiveComponent/ComponentWithPaginationTraitTest.php b/src/Pagination/tests/LiveComponent/ComponentWithPaginationTraitTest.php new file mode 100644 index 00000000000..03a66bebd69 --- /dev/null +++ b/src/Pagination/tests/LiveComponent/ComponentWithPaginationTraitTest.php @@ -0,0 +1,319 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\LiveComponent; + +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Metadata\UrlMapping; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\LiveComponent\ComponentWithPaginationTrait; +use Symfony\UX\Pagination\PaginationBuilder; +use Symfony\UX\Pagination\Paginator; + +#[CoversTrait(ComponentWithPaginationTrait::class)] +final class ComponentWithPaginationTraitTest extends TestCase +{ + public function testDefaultState() + { + self::assertSame(1, $this->createComponent([1, 2, 3])->page); + } + + public function testPageIsWritableAndSynchronizedWithTheUrl() + { + $property = new \ReflectionProperty(ComponentWithPaginationTrait::class, 'page'); + $attributes = $property->getAttributes(LiveProp::class); + + self::assertCount(1, $attributes); + + $liveProp = $attributes[0]->newInstance(); + + self::assertTrue($liveProp->isIdentityWritable()); + self::assertInstanceOf(UrlMapping::class, $liveProp->url()); + } + + #[DataProvider('liveActions')] + public function testNavigationMethodsAreLiveActions(string $method) + { + $attributes = new \ReflectionMethod(ComponentWithPaginationTrait::class, $method) + ->getAttributes(LiveAction::class); + + self::assertCount(1, $attributes); + } + + /** + * @return iterable + */ + public static function liveActions(): iterable + { + yield 'go to page' => ['goToPage']; + yield 'next page' => ['nextPage']; + yield 'previous page' => ['previousPage']; + } + + public function testBuilderOwnsTheSourceAndPageSize() + { + $component = $this->createComponent(range(1, 50)); + $component->page = 2; + + $pagination = $component->getPagination(); + + self::assertSame(range(11, 20), $pagination->getItems()); + self::assertSame(2, $pagination->getCurrentPage()); + self::assertSame(10, $pagination->getItemsPerPage()); + } + + public function testGetPaginationCachesResultForTheCurrentPage() + { + $callCount = 0; + $component = $this->createComponent(range(1, 50), $callCount); + + self::assertSame($component->getPagination(), $component->getPagination()); + self::assertSame(1, $callCount); + } + + public function testChangingThePublicPageInvalidatesTheCachedResult() + { + $callCount = 0; + $component = $this->createComponent(range(1, 100), $callCount); + + $component->getPagination(); + $component->page = 2; + $component->getPagination(); + + self::assertSame(2, $callCount); + } + + public function testGoToPageChangesStateAndClearsCache() + { + $callCount = 0; + $component = $this->createComponent(range(1, 100), $callCount); + $component->getPagination(); + + $component->goToPage(3); + + self::assertSame(3, $component->page); + self::assertSame(21, $component->getPagination()->getItems()[0]); + self::assertSame(2, $callCount); + } + + public function testGoToPageRejectsInvalidState() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Page must be greater than or equal to 1.'); + + $this->createComponent(range(1, 100))->goToPage(0); + } + + public function testNextAndPreviousPageRespectAvailableNavigation() + { + $component = $this->createComponent(range(1, 25)); + + $component->nextPage(); + self::assertSame(2, $component->page); + + $component->previousPage(); + self::assertSame(1, $component->page); + + $component->previousPage(); + self::assertSame(1, $component->page); + + $component->page = 3; + $component->nextPage(); + self::assertSame(3, $component->page); + } + + public function testResetPageIsAvailableForFilterChanges() + { + $component = $this->createComponent(range(1, 100)); + $component->page = 4; + + $component->resetPage(); + + self::assertSame(1, $component->page); + } + + public function testPaginationLinkAttributesBridgeLinksToTheLiveAction() + { + $attributes = $this->createComponent(range(1, 50)) + ->getPaginationLinkAttributes()([ + 'relation' => 'page', + 'url' => '/products/3', + 'page' => 3, + 'cursor' => null, + ]); + + self::assertSame([ + 'data-action' => 'live#action:prevent', + 'data-live-action-param' => 'goToPage', + 'data-live-page-param' => 3, + ], $attributes); + } + + public function testPagePropUrlParameterFollowsTheConfiguredPageParameter() + { + $paginator = new Paginator([new ArrayPaginationAdapter()]); + $component = new class($paginator) { + use ComponentWithPaginationTrait; + + public function __construct(private readonly Paginator $paginator) + { + } + + protected function createPagination(): PaginationBuilder + { + return $this->paginator->query(range(1, 10))->pageParameter('p'); + } + }; + + $liveProp = new LiveProp(writable: true, url: true, modifier: 'modifyPageProp'); + $url = $component->modifyPageProp($liveProp)->url(); + + self::assertInstanceOf(UrlMapping::class, $url); + self::assertSame('p', $url->as); + self::assertFalse($url->mapPath); + } + + public function testCapturesThePageRouteAndKeepsItDuringLiveRequests() + { + $routes = new RouteCollection(); + $routes->add('demo_page', new Route('/demo')); + $urlGenerator = new UrlGenerator($routes, new RequestContext()); + + $stack = new RequestStack(); + $pageRequest = Request::create('/demo'); + $pageRequest->attributes->set('_route', 'demo_page'); + $pageRequest->attributes->set('_route_params', []); + $stack->push($pageRequest); + + $component = $this->createRoutedComponent($stack, $urlGenerator); + $component->setPaginationRequestStack($stack); + $component->capturePaginationRoute(); + + self::assertSame('demo_page', $component->paginationRoute); + + // Live re-render: the current request now targets the internal component route + $stack->pop(); + $liveRequest = Request::create('/_components/foo'); + $liveRequest->attributes->set('_route', 'ux_live_component'); + $stack->push($liveRequest); + + $component->goToPage(2); + + self::assertSame('/demo?page=3', $component->getPagination()->getNextUrl()); + } + + public function testAnAlreadyCapturedRouteIsNeverOverwritten() + { + $stack = new RequestStack(); + $otherPageRequest = Request::create('/other'); + $otherPageRequest->attributes->set('_route', 'other_page'); + $stack->push($otherPageRequest); + + $component = $this->createRoutedComponent($stack, new UrlGenerator(new RouteCollection(), new RequestContext())); + $component->setPaginationRequestStack($stack); + $component->paginationRoute = 'captured_page'; + + $component->capturePaginationRoute(); + + self::assertSame('captured_page', $component->paginationRoute); + } + + public function testNeverCapturesTheInternalLiveComponentRoute() + { + $stack = new RequestStack(); + $liveRequest = Request::create('/_components/foo'); + $liveRequest->attributes->set('_route', 'ux_live_component'); + $stack->push($liveRequest); + + $component = $this->createRoutedComponent($stack, new UrlGenerator(new RouteCollection(), new RequestContext())); + $component->setPaginationRequestStack($stack); + $component->capturePaginationRoute(); + + self::assertNull($component->paginationRoute); + } + + private function createRoutedComponent(RequestStack $stack, UrlGenerator $urlGenerator): object + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $stack, + urlGenerator: $urlGenerator, + ); + + return new class($paginator) { + use ComponentWithPaginationTrait; + + public function __construct(private readonly Paginator $paginator) + { + } + + protected function createPagination(): PaginationBuilder + { + return $this->paginator + ->query(range(1, 30)) + ->perPage(10); + } + }; + } + + public function testGoToPageArgumentIsExplicitlyMapped() + { + $parameter = new \ReflectionMethod(ComponentWithPaginationTrait::class, 'goToPage')->getParameters()[0]; + $attributes = $parameter->getAttributes(LiveArg::class); + + self::assertCount(1, $attributes); + self::assertSame('page', $attributes[0]->newInstance()->name); + } + + /** + * @param list $source + */ + private function createComponent(array $source, ?int &$createCallCount = null): object + { + $createCallCount ??= 0; + $paginator = new Paginator([new ArrayPaginationAdapter()]); + + return new class($source, $paginator, $createCallCount) { + use ComponentWithPaginationTrait; + + /** + * @param list $source + */ + public function __construct( + private readonly array $source, + private readonly Paginator $paginator, + private int &$createCallCount, + ) { + } + + protected function createPagination(): PaginationBuilder + { + ++$this->createCallCount; + + return $this->paginator + ->query($this->source) + ->perPage(10); + } + }; + } +} diff --git a/src/Pagination/tests/NavigationTest.php b/src/Pagination/tests/NavigationTest.php new file mode 100644 index 00000000000..e6a39cceabc --- /dev/null +++ b/src/Pagination/tests/NavigationTest.php @@ -0,0 +1,337 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Exception\NavigationTooLargeException; +use Symfony\UX\Pagination\Navigation\Navigation; +use Symfony\UX\Pagination\Navigation\NavigationMode; +use Symfony\UX\Pagination\Navigation\PageLink; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; + +#[CoversClass(Navigation::class)] +final class NavigationTest extends TestCase +{ + public function testSlidingDefault() + { + $nav = $this->navigation(5, 20); + $links = $this->toArray($nav); + + // Should contain: 1, ..., 3, 4, [5], 6, 7, ..., 20 + $pages = array_map(static fn (PageLink $l) => $l->isGap ? '...' : (string) $l->page, $links); + + self::assertContains('1', $pages); + self::assertContains('5', $pages); + self::assertContains('20', $pages); + self::assertContains('...', $pages); + } + + public function testSlidingFirstPage() + { + $nav = $this->navigation(1, 20); + $links = $this->toArray($nav); + + // First page: no gap before, range starts at 1 + $first = $links[0]; + self::assertSame(1, $first->page); + self::assertTrue($first->isCurrent); + self::assertFalse($first->isGap); + } + + public function testSlidingLastPage() + { + $nav = $this->navigation(20, 20); + $links = $this->toArray($nav); + + $last = end($links); + self::assertInstanceOf(PageLink::class, $last); + self::assertSame(20, $last->page); + self::assertTrue($last->isCurrent); + } + + public function testSlidingSize() + { + $nav = $this->navigation(10, 20, NavigationMode::Sliding, 7); + $links = $this->toArray($nav); + + self::assertSame( + ['1', '...', '7', '8', '9', '10', '11', '12', '13', '...', '20'], + array_map(static fn (PageLink $link): string => $link->isGap ? '...' : (string) $link->page, $links), + ); + } + + public function testSlidingRejectsEmptySize() + { + $this->expectException(\InvalidArgumentException::class); + $this->navigation(10, 20, NavigationMode::Sliding, 0); + } + + public function testFixedMode() + { + $nav = $this->navigation(3, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + + self::assertNotEmpty($links); + + // All links should have URLs or be gaps + foreach ($links as $link) { + if (!$link->isGap) { + self::assertNotEmpty($link->url); + } + } + } + + public function testFixedRejectsSizeBelowOne() + { + $this->expectException(\InvalidArgumentException::class); + $this->navigation(3, 20, NavigationMode::Fixed, 0); + } + + public function testFullMode() + { + $nav = $this->navigation(3, 10, NavigationMode::Full, 500); + $links = $this->toArray($nav); + + // Should have exactly 10 links, no gaps + self::assertCount(10, $links); + + foreach ($links as $i => $link) { + self::assertSame($i + 1, $link->page); + self::assertFalse($link->isGap); + } + + // Page 3 should be current + self::assertTrue($links[2]->isCurrent); + self::assertFalse($links[0]->isCurrent); + } + + public function testFullModeRefusesAnUnboundedNumberOfLinks() + { + $nav = $this->navigation(3, 501, NavigationMode::Full, 500); + + $this->expectException(NavigationTooLargeException::class); + $this->expectExceptionMessage('limited to 500 pages'); + iterator_to_array($nav); + } + + public function testFullModeAcceptsAnExplicitHigherLimit() + { + $nav = $this->navigation(3, 501, NavigationMode::Full, 501); + + self::assertCount(501, $nav); + } + + public function testNullTotalPagesYieldsNothing() + { + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + $nav = new Navigation(1, null, $paginationUrlGenerator); + + self::assertCount(0, $nav); + } + + public function testZeroTotalPagesYieldsNothing() + { + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + $nav = new Navigation(1, 0, $paginationUrlGenerator); + + self::assertCount(0, $nav); + self::assertSame([], $this->toArray($nav)); + } + + public function testCountSlidingMode() + { + $nav = $this->navigation(5, 20); + + // Count should match the number of iterated links + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountSlidingModeFirstPage() + { + $nav = $this->navigation(1, 20); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountSlidingModeLastPage() + { + $nav = $this->navigation(20, 20); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountSlidingModeSmallRange() + { + // totalPages fits within the range, no gaps + $nav = $this->navigation(2, 3); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountFixedMode() + { + $nav = $this->navigation(7, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountFixedModeFirstBlock() + { + $nav = $this->navigation(1, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountFixedModeLastBlock() + { + $nav = $this->navigation(20, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + self::assertSame(\count($links), $nav->count()); + } + + public function testCountFullMode() + { + $nav = $this->navigation(5, 15, NavigationMode::Full, 500); + self::assertSame(15, $nav->count()); + } + + public function testCountNullTotalPages() + { + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + $nav = new Navigation(1, null, $paginationUrlGenerator); + self::assertSame(0, $nav->count()); + } + + public function testCountZeroTotalPages() + { + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + $nav = new Navigation(1, 0, $paginationUrlGenerator); + self::assertSame(0, $nav->count()); + } + + public function testFixedModeMiddleBlock() + { + // Page 12 of 30 with block size 5: current block is [11-15] + $nav = $this->navigation(12, 30, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + + self::assertNotEmpty($links); + + // First block [1-5] + gap + current block [11-15] + gap + last block [26-30] + $pages = array_map(static fn (PageLink $l) => $l->isGap ? '...' : (string) $l->page, $links); + self::assertContains('1', $pages); + self::assertContains('12', $pages); + self::assertContains('30', $pages); + self::assertContains('...', $pages); + } + + public function testFixedModeAdjacentBlocks() + { + // Page 6 of 20 with block size 5: current block is [6-10], adjacent to first block [1-5] + $nav = $this->navigation(6, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + + self::assertNotEmpty($links); + $pages = array_map(static fn (PageLink $l) => $l->isGap ? '...' : (string) $l->page, $links); + self::assertContains('1', $pages); + self::assertContains('6', $pages); + } + + public function testFixedModeLastBlock() + { + // Page 19 of 20 with block size 5: current block is [16-20] which IS the last block + $nav = $this->navigation(19, 20, NavigationMode::Fixed, 5); + $links = $this->toArray($nav); + + self::assertNotEmpty($links); + $last = end($links); + self::assertInstanceOf(PageLink::class, $last); + self::assertSame(20, $last->page); + } + + public function testSlidingNearStartNoGapBefore() + { + // Page 2 of 20 with default proximity: range starts at 1, no gap before + $nav = $this->navigation(2, 20); + $links = $this->toArray($nav); + + $first = $links[0]; + self::assertSame(1, $first->page); + self::assertFalse($first->isGap); + } + + public function testSlidingNearEndNoGapAfter() + { + // Page 19 of 20: range ends at 20, no gap after + $nav = $this->navigation(19, 20); + $links = $this->toArray($nav); + + $last = end($links); + self::assertInstanceOf(PageLink::class, $last); + self::assertSame(20, $last->page); + self::assertFalse($last->isGap); + } + + public function testSlidingGapOnBothSides() + { + $nav = $this->navigation(10, 20); + $links = $this->toArray($nav); + + $gaps = array_filter($links, static fn (PageLink $l) => $l->isGap); + // Should have gaps both before and after the range + self::assertCount(2, $gaps); + } + + public function testSlidingNoGapWhenRangeAdjacentToFirst() + { + // Page 3 of 20 with size 5: range [1..5], no gap before + $nav = $this->navigation(3, 20, NavigationMode::Sliding, 5); + $links = $this->toArray($nav); + + // First link should be page 1, not a gap + self::assertFalse($links[0]->isGap); + self::assertSame(1, $links[0]->page); + } + + public function testSlidingNoGapWhenRangeAdjacentToLast() + { + // Page 18 of 20 with size 5: range [16..20], no gap after + $nav = $this->navigation(18, 20, NavigationMode::Sliding, 5); + $links = $this->toArray($nav); + + $last = end($links); + self::assertInstanceOf(PageLink::class, $last); + self::assertFalse($last->isGap); + self::assertSame(20, $last->page); + } + + private function navigation( + int $current, + int $total, + NavigationMode $mode = NavigationMode::Sliding, + int $size = 5, + ): Navigation { + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + return new Navigation($current, $total, $paginationUrlGenerator, $mode, $size); + } + + /** + * @return list + */ + private function toArray(Navigation $nav): array + { + return array_values(iterator_to_array($nav)); + } +} diff --git a/src/Pagination/tests/PageLinkTest.php b/src/Pagination/tests/PageLinkTest.php new file mode 100644 index 00000000000..1a23bbbfa80 --- /dev/null +++ b/src/Pagination/tests/PageLinkTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Navigation\PageLink; + +#[CoversClass(PageLink::class)] +final class PageLinkTest extends TestCase +{ + public function testConstructorSetsProperties() + { + $link = new PageLink( + page: 5, + url: '/items?page=5', + isCurrent: true, + isGap: false, + ); + + self::assertSame(5, $link->page); + self::assertSame('/items?page=5', $link->url); + self::assertTrue($link->isCurrent); + self::assertFalse($link->isGap); + self::assertSame(5, $link->getPage()); + self::assertSame('/items?page=5', $link->getUrl()); + self::assertTrue($link->isCurrent()); + self::assertFalse($link->isGap()); + } + + public function testGapLink() + { + $link = new PageLink( + page: 0, + url: '', + isCurrent: false, + isGap: true, + ); + + self::assertTrue($link->isGap); + self::assertFalse($link->isCurrent); + } + + public function testRegularLink() + { + $link = new PageLink( + page: 3, + url: '/items?page=3', + isCurrent: false, + isGap: false, + ); + + self::assertSame(3, $link->page); + self::assertFalse($link->isCurrent); + self::assertFalse($link->isGap); + } +} diff --git a/src/Pagination/tests/PaginationBuilderTest.php b/src/Pagination/tests/PaginationBuilderTest.php new file mode 100644 index 00000000000..db31107ce54 --- /dev/null +++ b/src/Pagination/tests/PaginationBuilderTest.php @@ -0,0 +1,778 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\CursorAdapterInterface; +use Symfony\UX\Pagination\Adapter\OffsetAdapterInterface; +use Symfony\UX\Pagination\Cursor\CursorBoundary; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\Cursor\CursorSlice; +use Symfony\UX\Pagination\Exception\OffsetLimitExceededException; +use Symfony\UX\Pagination\Exception\OutOfRangePageException; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginationBuilder; + +#[CoversClass(PaginationBuilder::class)] +final class PaginationBuilderTest extends TestCase +{ + public function testBasicBuild() + { + $builder = $this->builder(range(1, 100)); + $result = $builder->paginate(page: 1); + + self::assertInstanceOf(Pagination::class, $result); + self::assertSame(1, $result->getCurrentPage()); + self::assertCount(20, $result); // default perPage + } + + #[DataProvider('invalidRequestPages')] + public function testInvalidRequestPageIsRejected(mixed $value) + { + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push(new \Symfony\Component\HttpFoundation\Request(['page' => $value])); + + $builder = new PaginationBuilder( + range(1, 100), + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class); + $this->expectExceptionMessage('pagination parameter "page" must be a positive integer'); + $builder->paginate(); + } + + public static function invalidRequestPages(): iterable + { + yield 'zero integer' => [0]; + yield 'negative string' => ['-1']; + yield 'float string' => ['1.5']; + yield 'letters' => ['foo']; + yield 'array' => [['2']]; + yield 'integer overflow' => [str_repeat('9', 100)]; + } + + public function testRoutePageHasPriorityOverQueryPage() + { + $request = new \Symfony\Component\HttpFoundation\Request(['page' => '9']); + $request->attributes->set('page', '3'); + $request->attributes->set('_route_params', ['page' => '3']); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $result = new PaginationBuilder( + range(1, 100), + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + )->paginate(); + + self::assertSame(3, $result->getCurrentPage()); + } + + public function testPerPage() + { + $result = $this->builder(range(1, 100))->perPage(5)->paginate(page: 2); + + self::assertSame(2, $result->getCurrentPage()); + self::assertSame(5, $result->getItemsPerPage()); + self::assertSame([6, 7, 8, 9, 10], $result->getItems()); + } + + public function testPerPageThrowsForInvalid() + { + $this->expectException(\InvalidArgumentException::class); + $this->builder([])->perPage(0); + } + + public function testPerPageRejectsIntegerOverflow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('less than PHP_INT_MAX'); + + $this->builder([])->perPage(\PHP_INT_MAX); + } + + public function testDeveloperPerPageIsNotSilentlyCapped() + { + $result = $this->builder(range(1, 2000))->perPage(1500)->paginate(); + + self::assertSame(1500, $result->getItemsPerPage()); + self::assertCount(1500, $result); + } + + public function testSlidingRejectsEmptyWindow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('size must be >= 1.'); + $this->builder([])->sliding(0); + } + + public function testFixedRejectsEmptyWindow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('size must be >= 1.'); + $this->builder([])->fixed(0); + } + + public function testSlidingMode() + { + $result = $this->builder(range(1, 200))->sliding(7)->paginate(page: 10); + + $links = iterator_to_array($result->getPages()); + self::assertNotEmpty($links); + } + + public function testFixedMode() + { + $result = $this->builder(range(1, 200))->fixed(5)->paginate(page: 3); + + $links = iterator_to_array($result->getPages()); + self::assertNotEmpty($links); + } + + public function testFullMode() + { + $result = $this->builder(range(1, 50))->perPage(10)->full()->paginate(page: 3); + + $links = iterator_to_array($result->getPages()); + // Full mode should show all 5 pages (50 items / 10 per page) + $nonGaps = array_filter($links, static fn ($l) => !$l->isGap); + self::assertCount(5, $nonGaps); + } + + public function testFullRejectsInvalidMaximum() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('maxPages must be >= 1.'); + $this->builder([])->full(0); + } + + public function testLookahead() + { + $result = $this->builder(range(1, 50))->lookahead()->paginate(page: 1); + + self::assertNull($result->getTotalItems()); + self::assertNull($result->getTotalPages()); + self::assertTrue($result->hasNext()); + self::assertCount(20, $result); + } + + public function testLookaheadCannotBeCombinedWithAnExactTotal() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('lookahead() cannot be combined with total()'); + + $this->builder(range(1, 50)) + ->lookahead() + ->total(50) + ->paginate(); + } + + public function testLookaheadCannotThrowOnAnUnknownLastPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('lookahead() cannot be combined with throwOnOutOfRange()'); + + $this->builder(range(1, 50)) + ->lookahead() + ->throwOnOutOfRange() + ->paginate(); + } + + public function testLookaheadRejectsAnAdapterWithoutLookaheadSupport() + { + $source = new \stdClass(); + $adapter = new class implements OffsetAdapterInterface { + public function supports(mixed $source): bool + { + return true; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return []; + } + + public function count(mixed $source): int + { + return 0; + } + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No "lookahead" pagination adapter found'); + + new PaginationBuilder($source, [$adapter])->lookahead()->paginate(); + } + + public function testExplicitAdapterMustSupportTheSelectedMode() + { + $adapter = new class implements OffsetAdapterInterface { + public function supports(mixed $source): bool + { + return true; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return []; + } + + public function count(mixed $source): int + { + return 0; + } + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not support "lookahead" pagination'); + + new PaginationBuilder(new \stdClass(), [], adapter: $adapter)->lookahead()->paginate(); + } + + public function testOffsetResolutionSkipsACursorOnlyAdapterForTheSameSource() + { + $source = new \stdClass(); + $cursorAdapter = new class implements CursorAdapterInterface { + public function supports(mixed $source): bool + { + return $source instanceof \stdClass; + } + + public function resolveCursorOrder(mixed $source, ?array $fields, ?string $direction): CursorOrder + { + return CursorOrder::byIdentity('remote-order'); + } + + public function getCursorContext(mixed $source, ?string $context): string + { + return 'remote-context'; + } + + public function sliceWithCursor(mixed $source, ?CursorBoundary $boundary, int $limit, CursorOrder $order): CursorSlice + { + return new CursorSlice([], null, null, false); + } + }; + $offsetAdapter = new class implements OffsetAdapterInterface { + public function supports(mixed $source): bool + { + return $source instanceof \stdClass; + } + + public function slice(mixed $source, int $offset, int $limit): array + { + return ['offset']; + } + + public function count(mixed $source): int + { + return 1; + } + }; + + $pagination = new PaginationBuilder($source, [$cursorAdapter, $offsetAdapter])->paginate(); + + self::assertSame(['offset'], $pagination->getItems()); + } + + public function testAppends() + { + $result = $this->builder(range(1, 100)) + ->queryParameters(['q' => 'test', 'sort' => 'name']) + ->paginate(page: 1); + + $url = $result->getUrl(2); + self::assertStringContainsString('q=test', $url); + self::assertStringContainsString('sort=name', $url); + } + + public function testFragment() + { + $result = $this->builder(range(1, 100)) + ->fragment('results') + ->paginate(page: 1); + + $url = $result->getUrl(2); + self::assertStringContainsString('#results', $url); + } + + public function testWithPath() + { + $result = $this->builder(range(1, 100)) + ->path('/admin/posts') + ->paginate(page: 1); + + $url = $result->getUrl(2); + self::assertStringStartsWith('/admin/posts', $url); + } + + public function testQueryParam() + { + $result = $this->builder(range(1, 100)) + ->pageParameter('p') + ->paginate(page: 1); + + $url = $result->getUrl(2); + self::assertStringContainsString('p=2', $url); + self::assertStringNotContainsString('page=', $url); + } + + public function testQueryParameterNameCannotBeEmpty() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + + $this->builder([])->pageParameter(''); + } + + public function testImmutability() + { + $builder = $this->builder(range(1, 100)); + $modified = $builder->perPage(5); + + // Builder should be immutable + self::assertNotSame($builder, $modified); + + $result1 = $builder->paginate(); + $result2 = $modified->paginate(); + + self::assertSame(20, $result1->getItemsPerPage()); + self::assertSame(5, $result2->getItemsPerPage()); + } + + public function testPaginateThrowsForInvalidPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->builder([])->paginate(page: 0); + } + + public function testDefaultMaximumOffsetAllowsItsBoundary() + { + $pagination = $this->builder([]) + ->perPage(20) + ->paginate(page: 5001); + + self::assertSame(5001, $pagination->getCurrentPage()); + } + + public function testDefaultMaximumOffsetRejectsLargerPagesWithCursorGuidance() + { + $this->expectException(OffsetLimitExceededException::class); + $this->expectExceptionMessage('maximum offset of 100000'); + $this->expectExceptionMessage('$paginator->cursor($source)->orderBy(...)->paginate()'); + + $this->builder([]) + ->perPage(20) + ->paginate(page: 5002); + } + + public function testMaximumOffsetCanBeRaisedExplicitly() + { + $pagination = $this->builder([]) + ->perPage(20) + ->maxOffset(120_000) + ->paginate(page: 5002); + + self::assertSame(5002, $pagination->getCurrentPage()); + } + + public function testMaximumOffsetRejectsIntegerOverflow() + { + $this->expectException(OffsetLimitExceededException::class); + + $this->builder([]) + ->perPage(2) + ->maxOffset(\PHP_INT_MAX) + ->paginate(page: \PHP_INT_MAX); + } + + public function testMaximumOffsetMustBePositiveOrZero() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('maxOffset must be >= 0.'); + + $this->builder([])->maxOffset(-1); + } + + public function testThrowsForUnsupportedSource() + { + $builder = new PaginationBuilder( + source: new \stdClass(), + adapters: [new ArrayPaginationAdapter()], + ); + + $this->expectException(\InvalidArgumentException::class); + $builder->paginate(); + } + + public function testWithTotalInt() + { + $result = $this->builder(range(1, 100)) + ->total(50) + ->paginate(page: 1); + + self::assertSame(50, $result->getTotalItems()); + self::assertSame(3, $result->getTotalPages()); // 50 items / 20 per page = ceil(2.5) = 3 pages + self::assertCount(20, $result); // Still returns 20 items on the page + } + + public function testWithTotalCallable() + { + $result = $this->builder(range(1, 100)) + ->total(static fn () => 42) + ->paginate(page: 1); + + self::assertSame(42, $result->getTotalItems()); + self::assertSame(3, $result->getTotalPages()); // 42 items / 20 per page = ceil(2.1) = 3 pages + } + + public function testWithTotalCallableThrowsForInvalidReturn() + { + $result = $this->builder(range(1, 100)) + ->total(static fn () => 'invalid') + ->paginate(page: 1); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Total callable must return an int'); + $result->getTotalItems(); + } + + public function testWithTotalRejectsNegativeInteger() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('greater than or equal to 0'); + + $this->builder([])->total(-1); + } + + public function testWithTotalAcceptsInvokableService() + { + $counter = new class { + public function __invoke(): int + { + return 73; + } + }; + + $result = $this->builder(range(1, 10))->total($counter)->paginate(); + + self::assertSame(73, $result->getTotalItems()); + } + + public function testWithTotalMaintainsImmutability() + { + $builder = $this->builder(range(1, 100)); + $modified = $builder->total(50); + + self::assertNotSame($builder, $modified); + + $result1 = $builder->paginate(); + $result2 = $modified->paginate(); + + self::assertSame(100, $result1->getTotalItems()); + self::assertSame(50, $result2->getTotalItems()); + } + + public function testWithTotalCaching() + { + $callCount = 0; + $callable = static function () use (&$callCount) { + ++$callCount; + + return 42; + }; + + $result = $this->builder(range(1, 100)) + ->total($callable) + ->paginate(page: 1); + + // Verify that the callable is only invoked once due to caching + $count1 = $result->getTotalItems(); + $count2 = $result->getTotalItems(); + + self::assertSame(42, $count1); + self::assertSame(42, $count2); + self::assertSame(1, $callCount); + } + + public function testConstructorRejectsPerPageOverflow() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('less than PHP_INT_MAX'); + + new PaginationBuilder(range(1, 3), [new ArrayPaginationAdapter()], defaultPerPage: \PHP_INT_MAX); + } + + public function testExposesTheConfiguredPageParameterName() + { + $builder = new PaginationBuilder(range(1, 3), [new ArrayPaginationAdapter()]); + + self::assertSame('page', $builder->getPageParameterName()); + self::assertSame('p', $builder->pageParameter('p')->getPageParameterName()); + } + + public function testExposesWhetherAnExplicitRouteWasConfigured() + { + $builder = new PaginationBuilder(range(1, 3), [new ArrayPaginationAdapter()]); + + self::assertFalse($builder->hasRoute()); + self::assertTrue($builder->route('items_list')->hasRoute()); + } + + public function testWithQueryStringPreservesRequestParams() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['q' => 'search', 'sort' => 'name']); + $request->attributes->set('_route', 'item_list'); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->preserveQueryString()->paginate(page: 1); + $url = $result->getUrl(2); + + self::assertStringContainsString('q=search', $url); + self::assertStringContainsString('sort=name', $url); + } + + public function testWithoutQueryStringDiscardsRequestParams() + { + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push(new \Symfony\Component\HttpFoundation\Request(query: ['q' => 'search'])); + + $result = $this->builderWithRequest(range(1, 100), $requestStack) + ->discardQueryString() + ->paginate(page: 1); + + self::assertSame('/?page=2', $result->getUrl(2)); + } + + public function testWithoutQueryParametersExcludesAndDeduplicatesNames() + { + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push(new \Symfony\Component\HttpFoundation\Request(query: [ + 'q' => 'search', + 'sort' => 'name', + ])); + + $builder = $this->builderWithRequest(range(1, 100), $requestStack); + $modified = $builder + ->excludeQueryParameters('q') + ->excludeQueryParameters('q'); + $result = $modified->paginate(page: 1); + + self::assertNotSame($builder, $modified); + self::assertSame('/?sort=name&page=2', $result->getUrl(2)); + } + + public function testWithoutQueryParametersRejectsEmptyName() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + $this->builder([])->excludeQueryParameters(''); + } + + public function testRouteWithUrlGenerator() + { + $urlGenerator = $this->createStub(\Symfony\Component\Routing\Generator\UrlGeneratorInterface::class); + $urlGenerator->method('generate') + ->willReturnCallback(static function (string $route, array $params): string { + return '/generated/'.$route.'?'.http_build_query($params); + }); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + urlGenerator: $urlGenerator, + ); + + $result = $builder->route('item_list', ['category' => 'books'])->paginate(page: 1); + $url = $result->getUrl(2); + + self::assertStringContainsString('item_list', $url); + self::assertStringContainsString('category=books', $url); + } + + public function testPaginateResolvesPageFromRequest() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['page' => '3']); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->paginate(); + + self::assertSame(3, $result->getCurrentPage()); + } + + public function testEmptyRequestDefaultsToFirstPage() + { + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push(new \Symfony\Component\HttpFoundation\Request()); + + $result = $this->builderWithRequest(range(1, 100), $requestStack)->paginate(); + + self::assertSame(1, $result->getCurrentPage()); + } + + public function testExplicitPageDoesNotReadTheRequestStack() + { + $requestStack = $this->createMock(\Symfony\Component\HttpFoundation\RequestStack::class); + $requestStack->expects(self::never())->method('getCurrentRequest'); + + $result = $this->builderWithRequest(range(1, 100), $requestStack) + ->paginate(page: 3); + + self::assertSame(3, $result->getCurrentPage()); + } + + public function testExplicitAdapterIsUsedWithoutDiscovery() + { + $adapter = new ArrayPaginationAdapter(); + $result = new PaginationBuilder( + source: range(1, 10), + adapters: [], + adapter: $adapter, + )->paginate(); + + self::assertSame(range(1, 10), $result->getItems()); + } + + public function testPaginateResolvesPageFromCustomParam() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['p' => '5']); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->pageParameter('p')->paginate(); + + self::assertSame(5, $result->getCurrentPage()); + } + + public function testPaginateResolvesPageFromPathParameter() + { + $request = new \Symfony\Component\HttpFoundation\Request(); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('page', 4); + $request->attributes->set('_route_params', ['page' => 4]); + + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->paginate(); + + self::assertSame(4, $result->getCurrentPage()); + } + + public function testPaginatePathParameterPriorityOverQuery() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['page' => '2']); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('page', 8); + $request->attributes->set('_route_params', ['page' => 8]); + + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->paginate(); + + self::assertSame(8, $result->getCurrentPage()); + } + + public function testPaginatePathParameterWithCustomQueryParam() + { + $request = new \Symfony\Component\HttpFoundation\Request(); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('p', 6); + $request->attributes->set('_route_params', ['p' => 6]); + + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $builder = new PaginationBuilder( + source: range(1, 100), + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $builder->pageParameter('p')->paginate(); + + self::assertSame(6, $result->getCurrentPage()); + } + + public function testThrowOnOutOfRangeThrowsFromBuilder() + { + $this->expectException(OutOfRangePageException::class); + + $this->builder(range(1, 30)) + ->throwOnOutOfRange() + ->paginate(page: 5); + } + + public function testThrowOnOutOfRangeDoesNotThrowWhenInRange() + { + $result = $this->builder(range(1, 100)) + ->throwOnOutOfRange() + ->paginate(page: 3); + + self::assertSame(3, $result->getCurrentPage()); + } + + private function builder(array $source): PaginationBuilder + { + return new PaginationBuilder( + source: $source, + adapters: [new ArrayPaginationAdapter()], + ); + } + + private function builderWithRequest(array $source, \Symfony\Component\HttpFoundation\RequestStack $requestStack): PaginationBuilder + { + return new PaginationBuilder( + source: $source, + adapters: [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + } +} diff --git a/src/Pagination/tests/PaginationInfoFormatterTest.php b/src/Pagination/tests/PaginationInfoFormatterTest.php new file mode 100644 index 00000000000..7d1d1294616 --- /dev/null +++ b/src/Pagination/tests/PaginationInfoFormatterTest.php @@ -0,0 +1,305 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\CursorAdapterInterface; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginationInfoFormatter; + +#[CoversClass(PaginationInfoFormatter::class)] +final class PaginationInfoFormatterTest extends TestCase +{ + public function testEnglishFallbackForEmptyNumberedPaginationWithTotal() + { + $formatter = new PaginationInfoFormatter(); + $pagination = $this->createPagination([], 1, 10); + + self::assertSame('No items', $formatter->format($pagination)); + } + + public function testEnglishFallbackForEmptyCursorPagination() + { + $formatter = new PaginationInfoFormatter(); + + self::assertSame('No items', $formatter->formatCursor($this->createCursorPagination([], false))); + } + + public function testEnglishFallbackForNumberedPaginationWithTotal() + { + $formatter = new PaginationInfoFormatter(); + + self::assertSame('Showing 11-20 of 30', $formatter->format($this->createPagination(range(1, 30), 2, 10))); + } + + public function testEnglishFallbackForNumberedPaginationWithoutTotal() + { + $formatter = new PaginationInfoFormatter(); + $pagination = new Pagination( + source: range(1, 30), + adapter: new ArrayPaginationAdapter(), + currentPage: 2, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(queryParam: 'page'), + lookahead: true, + ); + + self::assertSame('Showing 11-20', $formatter->format($pagination)); + } + + public function testEnglishFallbackForCursorPaginationWithMore() + { + $formatter = new PaginationInfoFormatter(); + + self::assertSame( + 'Showing 3 items', + $formatter->formatCursor($this->createCursorPagination([1, 2, 3], true)), + ); + } + + public function testEnglishFallbackPluralizesASingleItem() + { + $formatter = new PaginationInfoFormatter(); + + self::assertSame( + 'Showing 1 item', + $formatter->formatCursor($this->createCursorPagination([1], true)), + ); + self::assertSame( + 'Showing 1 item (last page)', + $formatter->formatCursor($this->createCursorPagination([1], false)), + ); + } + + public function testEnglishFallbackForLastCursorPage() + { + $formatter = new PaginationInfoFormatter(); + + self::assertSame( + 'Showing 3 items (last page)', + $formatter->formatCursor($this->createCursorPagination([1, 2, 3], false)), + ); + } + + public function testFormatWithTotal() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %start%-%end% of %total%', + ['%start%' => 11, '%end%' => 20, '%total%' => 100], + 'UXPaginationBundle' + ) + ->willReturn('Showing 11-20 of 100'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createPagination(range(1, 100), 2, 10); + + $result = $formatter->format($pagination); + + self::assertSame('Showing 11-20 of 100', $result); + } + + public function testFormatWithoutTotal() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %start%-%end%', + ['%start%' => 11, '%end%' => 20], + 'UXPaginationBundle' + ) + ->willReturn('Showing 11-20'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 2, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(queryParam: 'page'), + lookahead: true, + ); + + $result = $formatter->format($pagination); + + self::assertSame('Showing 11-20', $result); + } + + public function testFormatFirstPage() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %start%-%end% of %total%', + ['%start%' => 1, '%end%' => 10, '%total%' => 50], + 'UXPaginationBundle' + ) + ->willReturn('Showing 1-10 of 50'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createPagination(range(1, 50), 1, 10); + + $result = $formatter->format($pagination); + + self::assertSame('Showing 1-10 of 50', $result); + } + + public function testFormatLastPage() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %start%-%end% of %total%', + ['%start%' => 21, '%end%' => 25, '%total%' => 25], + 'UXPaginationBundle' + ) + ->willReturn('Showing 21-25 of 25'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createPagination(range(1, 25), 3, 10); + + $result = $formatter->format($pagination); + + self::assertSame('Showing 21-25 of 25', $result); + } + + public function testFormatCursorNoItems() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'No items', + [], + 'UXPaginationBundle' + ) + ->willReturn('No items'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createCursorPagination([], false); + + $result = $formatter->formatCursor($pagination); + + self::assertSame('No items', $result); + } + + public function testFormatCursorWithMore() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %count% item|Showing %count% items', + ['%count%' => 10], + 'UXPaginationBundle' + ) + ->willReturn('Showing 10 items'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createCursorPagination(range(1, 10), true); + + $result = $formatter->formatCursor($pagination); + + self::assertSame('Showing 10 items', $result); + } + + public function testFormatNoItems() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'No items', + [], + 'UXPaginationBundle' + ) + ->willReturn('No results'); + + $formatter = new PaginationInfoFormatter($translator); + // Page 5 of 3 pages = 0 items on this page + $pagination = $this->createPagination(range(1, 30), 5, 10); + + $result = $formatter->format($pagination); + + self::assertSame('No results', $result); + } + + public function testFormatCursorLastPage() + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::once()) + ->method('trans') + ->with( + 'Showing %count% item (last page)|Showing %count% items (last page)', + ['%count%' => 5], + 'UXPaginationBundle' + ) + ->willReturn('Showing 5 items (last page)'); + + $formatter = new PaginationInfoFormatter($translator); + $pagination = $this->createCursorPagination(range(1, 5), false); + + $result = $formatter->formatCursor($pagination); + + self::assertSame('Showing 5 items (last page)', $result); + } + + /** + * @param array $data + */ + private function createPagination(array $data, int $page, int $perPage): Pagination + { + return new Pagination( + source: $data, + adapter: new ArrayPaginationAdapter(), + currentPage: $page, + perPage: $perPage, + paginationUrlGenerator: new PaginationUrlGenerator(queryParam: 'page'), + ); + } + + /** + * @param array $items + */ + private function createCursorPagination(array $items, bool $hasMore): CursorPagination + { + $adapter = $this->createStub(CursorAdapterInterface::class); + $adapter->method('sliceWithCursor')->willReturn(new \Symfony\UX\Pagination\Cursor\CursorSlice( + $items, + $hasMore ? new \Symfony\UX\Pagination\Cursor\CursorBoundary([10]) : null, + null, + $hasMore, + )); + + return new CursorPagination( + source: $items, + adapter: $adapter, + cursor: null, + perPage: 10, + order: \Symfony\UX\Pagination\Cursor\CursorOrder::byFields(['id'], 'ASC'), + cursorCodec: new CursorCodec('test-secret'), + context: 'test', + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + } +} diff --git a/src/Pagination/tests/PaginationTest.php b/src/Pagination/tests/PaginationTest.php new file mode 100644 index 00000000000..eb29485863c --- /dev/null +++ b/src/Pagination/tests/PaginationTest.php @@ -0,0 +1,750 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\OffsetAdapterInterface; +use Symfony\UX\Pagination\Adapter\PaginationAdapterInterface; +use Symfony\UX\Pagination\Exception\OutOfRangePageException; +use Symfony\UX\Pagination\Navigation\Navigation; +use Symfony\UX\Pagination\Navigation\PageLink; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; +use Symfony\UX\Pagination\Pagination; + +#[CoversClass(Pagination::class)] +final class PaginationTest extends TestCase +{ + public function testConstructorRejectsInvalidArguments() + { + $arguments = [ + [0, 10, 100_000, 'currentPage'], + [1, 0, 100_000, 'perPage'], + [1, \PHP_INT_MAX, \PHP_INT_MAX, 'perPage'], + [1, 10, -1, 'maxOffset'], + ]; + + foreach ($arguments as [$page, $perPage, $maxOffset, $message]) { + try { + new Pagination( + source: [], + adapter: new ArrayPaginationAdapter(), + currentPage: $page, + perPage: $perPage, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + maxOffset: $maxOffset, + ); + self::fail('Invalid constructor arguments must be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString($message, $exception->getMessage()); + } + } + } + + public function testConstructorRejectsOffsetIntegerOverflow() + { + $this->expectException(\Symfony\UX\Pagination\Exception\OffsetLimitExceededException::class); + new Pagination( + source: [], + adapter: new ArrayPaginationAdapter(), + currentPage: \PHP_INT_MAX, + perPage: 2, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + maxOffset: \PHP_INT_MAX, + ); + } + + public function testConstructorRejectsOffsetAboveConfiguredLimit() + { + $this->expectException(\Symfony\UX\Pagination\Exception\OffsetLimitExceededException::class); + new Pagination( + source: [], + adapter: new ArrayPaginationAdapter(), + currentPage: 3, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + maxOffset: 10, + ); + } + + public function testConstructorRequiresTheCapabilityMatchingTheSelectedMode() + { + $adapter = new class implements PaginationAdapterInterface { + public function supports(mixed $source): bool + { + return true; + } + }; + + foreach ([[false, 'Offset'], [true, 'Lookahead']] as [$lookahead, $mode]) { + try { + new Pagination( + source: [], + adapter: $adapter, + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: $lookahead, + ); + self::fail($mode.' pagination must require its adapter capability.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString($mode.' pagination requires', $exception->getMessage()); + } + } + } + + public function testIterateReturnsItems() + { + $pagination = $this->paginate(range(1, 100), 1, 10); + + $items = []; + foreach ($pagination as $item) { + $items[] = $item; + } + + self::assertSame([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], $items); + } + + public function testItemsReturnsArray() + { + $pagination = $this->paginate(range(1, 50), 2, 10); + + self::assertSame([11, 12, 13, 14, 15, 16, 17, 18, 19, 20], $pagination->getItems()); + } + + public function testCountReturnsItemsOnThisPage() + { + $pagination = $this->paginate(range(1, 25), 3, 10); + + self::assertSame(5, $pagination->count()); + self::assertCount(5, $pagination); + } + + public function testMapTransformsItemsToAnotherType() + { + $pagination = $this->paginate([1, 2, 3], 1, 10); + $labels = $pagination->map(static fn (int $item): string => 'item-'.$item); + + self::assertSame(['item-1', 'item-2', 'item-3'], $labels->getItems()); + // Original unchanged + self::assertSame([1, 2, 3], $pagination->getItems()); + } + + public function testMetadata() + { + $pagination = $this->paginate(range(1, 100), 3, 10); + + self::assertSame(3, $pagination->getCurrentPage()); + self::assertSame(10, $pagination->getItemsPerPage()); + self::assertSame(100, $pagination->getTotalItems()); + self::assertSame(10, $pagination->getTotalPages()); + self::assertSame(21, $pagination->getFirstItemNumber()); + self::assertSame(30, $pagination->getLastItemNumber()); + self::assertFalse($pagination->isEmpty()); + } + + public function testEmptyResult() + { + $pagination = $this->paginate([], 1, 10); + + self::assertTrue($pagination->isEmpty()); + self::assertSame(0, $pagination->count()); + self::assertSame(0, $pagination->getTotalItems()); + self::assertSame(1, $pagination->getTotalPages()); + self::assertNull($pagination->getFirstItemNumber()); + self::assertNull($pagination->getLastItemNumber()); + } + + public function testItemNumbersUseTheActualLastPageSize() + { + $pagination = $this->paginate(range(1, 25), 3, 10); + + self::assertSame(21, $pagination->getFirstItemNumber()); + self::assertSame(25, $pagination->getLastItemNumber()); + } + + public function testNavigationStateFirstPage() + { + $pagination = $this->paginate(range(1, 100), 1, 10); + + self::assertTrue($pagination->hasNext()); + self::assertFalse($pagination->hasPrevious()); + self::assertTrue($pagination->isFirst()); + self::assertFalse($pagination->isLast()); + } + + public function testNavigationStateMiddlePage() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + + self::assertTrue($pagination->hasNext()); + self::assertTrue($pagination->hasPrevious()); + self::assertFalse($pagination->isFirst()); + self::assertFalse($pagination->isLast()); + } + + public function testNavigationStateLastPage() + { + $pagination = $this->paginate(range(1, 100), 10, 10); + + self::assertFalse($pagination->hasNext()); + self::assertTrue($pagination->hasPrevious()); + self::assertFalse($pagination->isFirst()); + self::assertTrue($pagination->isLast()); + } + + public function testSinglePage() + { + $pagination = $this->paginate([1, 2, 3], 1, 10); + + self::assertFalse($pagination->hasNext()); + self::assertFalse($pagination->hasPrevious()); + self::assertTrue($pagination->isFirst()); + self::assertTrue($pagination->isLast()); + } + + public function testUrls() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + + self::assertStringContainsString('page=5', $pagination->getUrl(5)); + self::assertStringContainsString('page=6', $pagination->getNextUrl() ?? ''); + self::assertStringContainsString('page=4', $pagination->getPreviousUrl() ?? ''); + self::assertNotNull($pagination->getFirstUrl()); + + $this->expectException(\Symfony\UX\Pagination\Exception\RuntimeException::class); + $pagination->getAbsoluteUrl(5); + } + + public function testUrlsOnFirstPage() + { + $pagination = $this->paginate(range(1, 100), 1, 10); + + self::assertNull($pagination->getPreviousUrl()); + self::assertNotNull($pagination->getNextUrl()); + } + + public function testUrlsOnLastPage() + { + $pagination = $this->paginate(range(1, 100), 10, 10); + + self::assertNull($pagination->getNextUrl()); + self::assertNotNull($pagination->getPreviousUrl()); + } + + public function testPagesReturnNavigation() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + $pages = $pagination->getPages(); + + self::assertInstanceOf(Navigation::class, $pages); + + $links = iterator_to_array($pages); + self::assertNotEmpty($links); + + foreach ($links as $link) { + self::assertInstanceOf(PageLink::class, $link); + } + } + + public function testSlidingNavigation() + { + $pagination = $this->paginate(range(1, 200), 10, 10); + $links = iterator_to_array($pagination->getPages()); + + // Should have first page, gap, range around current, gap, last page + $pageNumbers = array_map(static fn (PageLink $l) => $l->page, $links); + self::assertContains(1, $pageNumbers); + self::assertContains(10, $pageNumbers); + self::assertContains(20, $pageNumbers); + + // Should have gaps + $gaps = array_filter($links, static fn (PageLink $l) => $l->isGap); + self::assertNotEmpty($gaps); + + // Current page should be marked + $current = array_filter($links, static fn (PageLink $l) => $l->isCurrent); + self::assertCount(1, $current); + $currentLink = reset($current); + self::assertInstanceOf(PageLink::class, $currentLink); + self::assertSame(10, $currentLink->page); + } + + public function testLookaheadMode() + { + $source = range(1, 50); + $adapter = new ArrayPaginationAdapter(); + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + $pagination = new Pagination( + source: $source, + adapter: $adapter, + currentPage: 1, + perPage: 10, + paginationUrlGenerator: $paginationUrlGenerator, + lookahead: true, + ); + + // Total should be null in lookahead mode + self::assertNull($pagination->getTotalItems()); + self::assertNull($pagination->getTotalPages()); + + self::assertTrue($pagination->hasNext()); + + // Items should be correct count + self::assertCount(10, $pagination->getItems()); + } + + public function testLookaheadLastPage() + { + $source = range(1, 25); + $adapter = new ArrayPaginationAdapter(); + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + $pagination = new Pagination( + source: $source, + adapter: $adapter, + currentPage: 3, + perPage: 10, + paginationUrlGenerator: $paginationUrlGenerator, + lookahead: true, + ); + + self::assertFalse($pagination->hasNext()); + self::assertCount(5, $pagination->getItems()); + } + + public function testItemsAreLazyLoaded() + { + $sliceCallCount = 0; + $innerAdapter = new ArrayPaginationAdapter(); + $source = range(1, 100); + + $adapter = new class($innerAdapter, $sliceCallCount) implements OffsetAdapterInterface { + public function __construct( + private readonly ArrayPaginationAdapter $inner, + private int &$callCount, + ) { + } + + public function supports(mixed $source): bool + { + return $this->inner->supports($source); + } + + public function slice(mixed $source, int $offset, int $limit): array + { + ++$this->callCount; + + return $this->inner->slice($source, $offset, $limit); + } + + public function count(mixed $source): int + { + return $this->inner->count($source); + } + }; + + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + $pagination = new Pagination( + source: $source, + adapter: $adapter, + currentPage: 1, + perPage: 10, + paginationUrlGenerator: $paginationUrlGenerator, + ); + + // Not executed yet + self::assertSame(0, $sliceCallCount); + + // First access triggers query + $pagination->getItems(); + self::assertSame(1, $sliceCallCount); + + // Second access reuses cache + $pagination->getItems(); + self::assertSame(1, $sliceCallCount); + + // foreach also reuses cache + foreach ($pagination as $item) { + // iterate + } + self::assertSame(1, $sliceCallCount); + } + + public function testJsonSerialize() + { + $pagination = $this->paginate(range(1, 30), 2, 10); + $json = $pagination->jsonSerialize(); + + self::assertSame([11, 12, 13, 14, 15, 16, 17, 18, 19, 20], $json['items']); + self::assertSame(2, $json['current_page']); + self::assertSame(10, $json['per_page']); + self::assertTrue($json['has_next']); + self::assertTrue($json['has_previous']); + self::assertSame(30, $json['total_items']); + self::assertSame(3, $json['total_pages']); + } + + public function testInfo() + { + $pagination = $this->paginate(range(1, 100), 2, 10); + + self::assertSame('Showing 11-20 of 100', $pagination->getInfo()); + } + + public function testInfoLastPage() + { + $pagination = $this->paginate(range(1, 25), 3, 10); + + self::assertSame('Showing 21-25 of 25', $pagination->getInfo()); + } + + public function testQueryParamReturnsDefault() + { + $pagination = $this->paginate(range(1, 10), 1, 10); + + self::assertSame('page', $pagination->getPageParameterName()); + } + + public function testQueryParamReturnsCustomValue() + { + $paginationUrlGenerator = new PaginationUrlGenerator(queryParam: 'p', basePath: '/items'); + $pagination = new Pagination( + source: range(1, 10), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: $paginationUrlGenerator, + ); + + self::assertSame('p', $pagination->getPageParameterName()); + } + + public function testIsOutOfRangeTrue() + { + $pagination = $this->paginate(range(1, 30), 5, 10); + + self::assertTrue($pagination->isOutOfRange()); + } + + public function testIsOutOfRangeFalse() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + + self::assertFalse($pagination->isOutOfRange()); + } + + public function testIsOutOfRangeFalseInLookaheadMode() + { + $pagination = new Pagination( + source: range(1, 10), + adapter: new ArrayPaginationAdapter(), + currentPage: 100, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: true, + ); + + self::assertFalse($pagination->isOutOfRange()); + } + + public function testThrowOnOutOfRangeThrows() + { + $pagination = $this->paginate(range(1, 30), 5, 10); + + $this->expectException(OutOfRangePageException::class); + $this->expectExceptionMessage('Page 5 is out of range. Last page is 3.'); + + $pagination->throwOnOutOfRange(); + } + + public function testThrowOnOutOfRangeReturnsSelfWhenInRange() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + + $result = $pagination->throwOnOutOfRange(); + + self::assertSame($pagination, $result); + } + + public function testMeta() + { + $pagination = $this->paginate(range(1, 50), 2, 10); + $meta = $pagination->getMetadata(); + + self::assertSame(2, $meta['current_page']); + self::assertSame(10, $meta['per_page']); + self::assertTrue($meta['has_next']); + self::assertTrue($meta['has_previous']); + self::assertSame(50, $meta['total_items']); + self::assertSame(5, $meta['total_pages']); + } + + public function testMetaInLookaheadMode() + { + $pagination = new Pagination( + source: range(1, 50), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: true, + ); + + $meta = $pagination->getMetadata(); + + self::assertArrayNotHasKey('total_items', $meta); + self::assertArrayNotHasKey('total_pages', $meta); + self::assertTrue($meta['has_next']); + } + + public function testLinks() + { + $pagination = $this->paginate(range(1, 50), 2, 10); + $links = $pagination->getLinks(); + + self::assertArrayHasKey('first', $links); + self::assertArrayHasKey('last', $links); + self::assertArrayHasKey('prev', $links); + self::assertArrayHasKey('next', $links); + + self::assertNotNull($links['first']); + self::assertNotNull($links['last']); + self::assertNotNull($links['prev']); + self::assertNotNull($links['next']); + } + + public function testLinksFirstPage() + { + $pagination = $this->paginate(range(1, 50), 1, 10); + $links = $pagination->getLinks(); + + self::assertNull($links['prev']); + self::assertNotNull($links['next']); + } + + public function testLinksLastPage() + { + $pagination = $this->paginate(range(1, 50), 5, 10); + $links = $pagination->getLinks(); + + self::assertNotNull($links['prev']); + self::assertNull($links['next']); + } + + public function testLastUrl() + { + $pagination = $this->paginate(range(1, 50), 1, 10); + + $lastUrl = $pagination->getLastUrl(); + + self::assertNotNull($lastUrl); + self::assertStringContainsString('page=5', $lastUrl); + } + + public function testLastUrlIsUnknownWithLookahead() + { + $pagination = new Pagination( + source: range(1, 20), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: true, + ); + + self::assertNull($pagination->getLastUrl()); + } + + public function testFirstUrlOmitsPageParam() + { + $pagination = $this->paginate(range(1, 100), 5, 10); + + $firstUrl = $pagination->getFirstUrl(); + + self::assertStringNotContainsString('page=', $firstUrl); + } + + public function testTotalInt() + { + $pagination = new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + total: 42, + ); + + self::assertSame(42, $pagination->getTotalItems()); + self::assertSame(5, $pagination->getTotalPages()); + } + + public function testTotalPagesKeepsIntegerPrecisionForLargeCounts() + { + $pagination = new Pagination( + source: [], + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 2, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + total: \PHP_INT_MAX, + ); + + self::assertSame(intdiv(\PHP_INT_MAX, 2) + 1, $pagination->getTotalPages()); + } + + public function testNegativeCountIsRejected() + { + $adapter = $this->createStub(OffsetAdapterInterface::class); + $adapter->method('count')->willReturn(-1); + $pagination = new Pagination( + source: [], + adapter: $adapter, + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('greater than or equal to 0'); + + $pagination->getTotalItems(); + } + + public function testTotalCallable() + { + $callCount = 0; + $pagination = new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + total: static function () use (&$callCount) { + ++$callCount; + + return 55; + }, + ); + + self::assertSame(55, $pagination->getTotalItems()); + self::assertSame(55, $pagination->getTotalItems()); // cached + self::assertSame(1, $callCount); + } + + public function testInfoEmptyWithTotal() + { + // Page beyond the last page: count is 0, total is known + $pagination = $this->paginate(range(1, 10), 5, 10); + + self::assertSame('No items', $pagination->getInfo()); + } + + public function testInfoEmptyWithoutTotal() + { + $pagination = new Pagination( + source: [], + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: true, + ); + + self::assertSame('No items', $pagination->getInfo()); + } + + public function testInfoWithFormatter() + { + $translator = $this->createStub(\Symfony\Contracts\Translation\TranslatorInterface::class); + $translator->method('trans')->willReturn('Page 2 sur 5'); + $formatter = new \Symfony\UX\Pagination\PaginationInfoFormatter($translator); + + $pagination = new Pagination( + source: range(1, 50), + adapter: new ArrayPaginationAdapter(), + currentPage: 2, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + infoFormatter: $formatter, + ); + + self::assertSame('Page 2 sur 5', $pagination->getInfo()); + } + + public function testInfoWithoutTotal() + { + $pagination = new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 2, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + lookahead: true, + ); + + self::assertSame('Showing 11-20', $pagination->getInfo()); + } + + public function testTotalCallableReturningNonIntThrows() + { + $pagination = new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + total: static fn () => 'not an int', // @phpstan-ignore return.type + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Total callable must return an int'); + + $pagination->getTotalItems(); + } + + public function testPerPageOfOnePaginatesItemByItem() + { + $middle = $this->paginate(range(1, 3), 2, 1); + + self::assertSame([2], $middle->getItems()); + self::assertSame(3, $middle->getTotalPages()); + self::assertTrue($middle->hasPrevious()); + self::assertTrue($middle->hasNext()); + self::assertFalse($middle->isFirst()); + self::assertFalse($middle->isLast()); + + $last = $this->paginate(range(1, 3), 3, 1); + + self::assertSame([3], $last->getItems()); + self::assertTrue($last->isLast()); + self::assertFalse($last->hasNext()); + } + + private function paginate(array $source, int $page, int $perPage): Pagination + { + $adapter = new ArrayPaginationAdapter(); + $paginationUrlGenerator = new PaginationUrlGenerator(basePath: '/items'); + + return new Pagination( + source: $source, + adapter: $adapter, + currentPage: $page, + perPage: $perPage, + paginationUrlGenerator: $paginationUrlGenerator, + ); + } +} diff --git a/src/Pagination/tests/PaginationUrlGeneratorTest.php b/src/Pagination/tests/PaginationUrlGeneratorTest.php new file mode 100644 index 00000000000..e1a519a7100 --- /dev/null +++ b/src/Pagination/tests/PaginationUrlGeneratorTest.php @@ -0,0 +1,698 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; + +#[CoversClass(PaginationUrlGenerator::class)] +final class PaginationUrlGeneratorTest extends TestCase +{ + public function testParameterNamesCanBeConfiguredImmutably() + { + $generator = new PaginationUrlGenerator(basePath: '/items'); + $configured = $generator + ->withQueryParameter('p') + ->withCursorParameter('after'); + + self::assertSame('page', $generator->getQueryParameterName()); + self::assertSame('cursor', $generator->getCursorParameterName()); + self::assertSame('p', $configured->getQueryParameterName()); + self::assertSame('after', $configured->getCursorParameterName()); + self::assertSame('/items?p=2', $configured->getUrl(2)); + self::assertSame('/items?after=opaque', $configured->getCursorUrl('opaque')); + } + + public function testEmptyParameterAndRouteNamesAreRejected() + { + $generator = new PaginationUrlGenerator(); + + foreach ([ + static fn () => $generator->withQueryParameter(''), + static fn () => $generator->withCursorParameter(''), + static fn () => $generator->withRoute(''), + ] as $configure) { + try { + $configure(); + self::fail('Empty names must be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('must not be empty', $exception->getMessage()); + } + } + } + + public function testEmptyExcludedQueryParameterIsRejected() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + + new PaginationUrlGenerator()->withoutQueryParameters(''); + } + + public function testWithRouteReplacesPathConfiguration() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('catalog', ['category' => 'books', 'page' => 2], UrlGeneratorInterface::ABSOLUTE_PATH) + ->willReturn('/catalog/books?page=2'); + + $url = new PaginationUrlGenerator(basePath: '/old', urlGenerator: $urlGenerator) + ->withRoute('catalog', ['category' => 'books']) + ->getUrl(2); + + self::assertSame('/catalog/books?page=2', $url); + } + + public function testUrlWithBasePath() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + + self::assertSame('/items', $recipe->getUrl(1)); + self::assertSame('/items?page=2', $recipe->getUrl(2)); + self::assertSame('/items?page=10', $recipe->getUrl(10)); + } + + public function testPageUrlsRejectNonPositivePages() + { + $generator = new PaginationUrlGenerator(basePath: '/items'); + + foreach ([ + static fn () => $generator->getUrl(0), + static fn () => $generator->getAbsoluteUrl(-1), + ] as $generate) { + try { + $generate(); + self::fail('Non-positive pages must be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('greater than or equal to 1', $exception->getMessage()); + } + } + } + + public function testUrlOmitsPageParamForFirstPage() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + + $url = $recipe->getUrl(1); + + self::assertStringNotContainsString('page=', $url); + } + + public function testWithQueryParameters() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withQueryParameters = $recipe->withQueryParameters(['sort' => 'name', 'filter' => 'active']); + + $url = $withQueryParameters->getUrl(2); + + self::assertStringContainsString('sort=name', $url); + self::assertStringContainsString('filter=active', $url); + self::assertStringContainsString('page=2', $url); + } + + public function testWithQueryParametersImmutability() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withQueryParameters = $recipe->withQueryParameters(['sort' => 'name']); + + self::assertStringNotContainsString('sort=', $recipe->getUrl(2)); + self::assertStringContainsString('sort=name', $withQueryParameters->getUrl(2)); + } + + public function testWithFragment() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withFragment = $recipe->withFragment('results'); + + $url = $withFragment->getUrl(2); + + self::assertStringContainsString('#results', $url); + } + + public function testWithFragmentImmutability() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withFragment = $recipe->withFragment('results'); + + self::assertStringNotContainsString('#', $recipe->getUrl(2)); + self::assertStringContainsString('#results', $withFragment->getUrl(2)); + } + + public function testWithPath() + { + $recipe = new PaginationUrlGenerator(basePath: '/old-path'); + $withPath = $recipe->withPath('/new-path'); + + self::assertStringContainsString('/new-path', $withPath->getUrl(2)); + } + + public function testWithQueryString() + { + $request = new Request(['existing' => 'param']); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $recipe = new PaginationUrlGenerator( + basePath: '/items', + requestStack: $requestStack, + ); + $withQueryString = $recipe->withQueryString(); + + $url = $withQueryString->getUrl(2); + + self::assertStringContainsString('existing=param', $url); + self::assertStringContainsString('page=2', $url); + } + + public function testPreservesQueryStringByDefault() + { + $request = new Request([ + 'q' => 'phone', + 'sort' => 'price', + 'filter' => ['color' => ['red']], + 'page' => '2', + ]); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $url = new PaginationUrlGenerator(basePath: '/items', requestStack: $requestStack)->getUrl(3); + + parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); + self::assertSame([ + 'q' => 'phone', + 'sort' => 'price', + 'filter' => ['color' => ['red']], + 'page' => '3', + ], $query); + } + + public function testOffsetUrlDropsAnExistingCursorParameter() + { + $requestStack = new RequestStack(); + $requestStack->push(new Request(['cursor' => 'old', 'q' => 'phone'])); + + $url = new PaginationUrlGenerator(basePath: '/items', requestStack: $requestStack)->getUrl(2); + + self::assertSame('/items?q=phone&page=2', $url); + } + + public function testWithoutQueryStringDiscardsRequestParameters() + { + $requestStack = new RequestStack(); + $requestStack->push(new Request(['q' => 'phone'])); + + $url = new PaginationUrlGenerator(basePath: '/items', requestStack: $requestStack) + ->withoutQueryString() + ->withQueryParameters(['sort' => 'name']) + ->getUrl(2); + + self::assertSame('/items?sort=name&page=2', $url); + } + + public function testWithoutQueryParametersRemovesSelectedParametersBeforeExplicitParameters() + { + $requestStack = new RequestStack(); + $requestStack->push(new Request(['debug' => '1', 'token' => 'secret', 'sort' => 'price'])); + + $url = new PaginationUrlGenerator(basePath: '/items', requestStack: $requestStack) + ->withoutQueryParameters('debug', 'token') + ->withQueryParameters(['sort' => 'name']) + ->getUrl(2); + + self::assertSame('/items?sort=name&page=2', $url); + } + + public function testWithQueryStringExcludesPageParam() + { + $request = new Request(['page' => '5', 'sort' => 'name']); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $recipe = new PaginationUrlGenerator( + basePath: '/items', + requestStack: $requestStack, + ); + $withQueryString = $recipe->withQueryString(); + + $url = $withQueryString->getUrl(2); + + self::assertStringContainsString('sort=name', $url); + self::assertStringContainsString('page=2', $url); + self::assertStringNotContainsString('page=5', $url); + } + + public function testCursorUrl() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + + $url = $recipe->getCursorUrl('abc123'); + + self::assertStringContainsString('cursor=abc123', $url); + self::assertStringNotContainsString('page=', $url); + } + + public function testCursorUrlRejectsAnEmptyCursor() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor value must not be empty'); + + new PaginationUrlGenerator(basePath: '/items')->getCursorUrl(''); + } + + public function testCursorUrlWithFragment() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withFragment = $recipe->withFragment('results'); + + $url = $withFragment->getCursorUrl('abc123'); + + self::assertStringContainsString('cursor=abc123', $url); + self::assertStringContainsString('#results', $url); + } + + public function testCustomQueryParam() + { + $recipe = new PaginationUrlGenerator( + queryParam: 'p', + basePath: '/items', + ); + + self::assertSame('/items?p=2', $recipe->getUrl(2)); + self::assertStringNotContainsString('page=', $recipe->getUrl(2)); + } + + public function testUrlWithRouteAndGenerator() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['page' => 2, 'category' => 'books']) + ->willReturn('/items/books?page=2'); + + $recipe = new PaginationUrlGenerator( + route: 'app_items', + routeParams: ['category' => 'books'], + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getUrl(2); + + self::assertSame('/items/books?page=2', $url); + } + + public function testUrlWithRouteOmitsPageOneFromParams() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['category' => 'books']) + ->willReturn('/items/books'); + + $recipe = new PaginationUrlGenerator( + route: 'app_items', + routeParams: ['category' => 'books'], + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getUrl(1); + + self::assertSame('/items/books', $url); + } + + public function testChainedModifiers() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + + $modified = $recipe + ->withQueryParameters(['sort' => 'date']) + ->withFragment('list'); + + $url = $modified->getUrl(3); + + self::assertStringContainsString('sort=date', $url); + self::assertStringContainsString('page=3', $url); + self::assertStringContainsString('#list', $url); + } + + public function testUrlWithNoRequestStackReturnsEmptyPath() + { + $recipe = new PaginationUrlGenerator(); + + $url = $recipe->getUrl(2); + + self::assertSame('?page=2', $url); + } + + public function testUrlGeneratorWithoutRequestFallsBackToQueryString() + { + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + + self::assertSame('?page=2', new PaginationUrlGenerator(urlGenerator: $urlGenerator)->getUrl(2)); + } + + public function testRequestWithoutRouteNameFallsBackToPath() + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('/items')); + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + + self::assertSame('/items?page=2', new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + )->getUrl(2)); + } + + public function testExposesTheConfiguredRouteName() + { + $recipe = new PaginationUrlGenerator(); + + self::assertNull($recipe->getRouteName()); + self::assertSame('items_list', $recipe->withRoute('items_list')->getRouteName()); + } + + public function testAutoDetectedRouteReceivesMergedParams() + { + $request = new Request(); + $request->attributes->set('_route', 'app_items'); + $request->attributes->set('_route_params', ['category' => 'books']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['category' => 'books', 'page' => 2]) + ->willReturn('/items/books?page=2'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getUrl(2); + + self::assertSame('/items/books?page=2', $url); + } + + public function testPreservedQueryParametersCannotOverrideRouteParameters() + { + $request = Request::create('/articles/php?slug=spoofed&filter=recent'); + $request->attributes->set('_route', 'article_show'); + $request->attributes->set('_route_params', ['slug' => 'php']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('article_show', ['slug' => 'php', 'filter' => 'recent', 'page' => 2]) + ->willReturn('/articles/php?filter=recent&page=2'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + self::assertSame('/articles/php?filter=recent&page=2', $recipe->getUrl(2)); + } + + public function testGetCurrentPathFallsBackToPathInfo() + { + $request = Request::create('/my-path'); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + ); + + $url = $recipe->getUrl(2); + + self::assertStringContainsString('/my-path', $url); + self::assertStringContainsString('page=2', $url); + } + + public function testCursorUrlWithRoute() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['cursor' => 'abc123']) + ->willReturn('/items?cursor=abc123'); + + $recipe = new PaginationUrlGenerator( + route: 'app_items', + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getCursorUrl('abc123'); + + self::assertSame('/items?cursor=abc123', $url); + } + + public function testCursorUrlWithRouteAndFragment() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['cursor' => 'abc123']) + ->willReturn('/items?cursor=abc123'); + + $recipe = new PaginationUrlGenerator( + route: 'app_items', + urlGenerator: $urlGenerator, + ); + + $withFragment = $recipe->withFragment('results'); + $url = $withFragment->getCursorUrl('abc123'); + + self::assertSame('/items?cursor=abc123#results', $url); + } + + public function testCursorUrlWithQueryString() + { + $request = new Request(['sort' => 'name']); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $recipe = new PaginationUrlGenerator( + basePath: '/items', + requestStack: $requestStack, + ); + + $withQs = $recipe->withQueryString(); + $url = $withQs->getCursorUrl('abc123'); + + self::assertStringContainsString('sort=name', $url); + self::assertStringContainsString('cursor=abc123', $url); + } + + public function testWithQueryStringNoRequest() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + $withQs = $recipe->withQueryString(); + + $url = $withQs->getUrl(2); + + self::assertSame('/items?page=2', $url); + } + + public function testCursorUrlWithNoPath() + { + $recipe = new PaginationUrlGenerator(); + + $url = $recipe->getCursorUrl('abc123'); + + self::assertSame('?cursor=abc123', $url); + } + + public function testUrlWithRouteAndFragment() + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('app_items', ['page' => 2]) + ->willReturn('/items?page=2'); + + $recipe = new PaginationUrlGenerator( + route: 'app_items', + urlGenerator: $urlGenerator, + ); + + $withFragment = $recipe->withFragment('results'); + $url = $withFragment->getUrl(2); + + self::assertSame('/items?page=2#results', $url); + } + + // ── Path-based page parameter tests ────────────────────── + + public function testAutoDetectedRouteWithPageInPath() + { + $request = new Request(); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('_route_params', ['page' => 2]); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('blog_list', ['page' => 3]) + ->willReturn('/blog/3'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getUrl(3); + + self::assertSame('/blog/3', $url); + } + + public function testAutoDetectedRoutePageOneOmitsPageParam() + { + $request = new Request(); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('_route_params', ['page' => 3]); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('blog_list', []) + ->willReturn('/blog'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + // Page 1 should omit page param entirely, route default handles it + $url = $recipe->getUrl(1); + + self::assertSame('/blog', $url); + } + + public function testAutoDetectedRoutePreservesOtherRouteParams() + { + $request = new Request(); + $request->attributes->set('_route', 'category_list'); + $request->attributes->set('_route_params', ['category' => 'php', 'page' => 1]); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('category_list', ['category' => 'php', 'page' => 5]) + ->willReturn('/blog/php/5'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getUrl(5); + + self::assertSame('/blog/php/5', $url); + } + + public function testCursorUrlWithAutoDetectedRoute() + { + $request = new Request(); + $request->attributes->set('_route', 'item_list'); + $request->attributes->set('_route_params', ['category' => 'books']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects(self::once()) + ->method('generate') + ->with('item_list', ['category' => 'books', 'cursor' => 'abc123']) + ->willReturn('/items/books?cursor=abc123'); + + $recipe = new PaginationUrlGenerator( + requestStack: $requestStack, + urlGenerator: $urlGenerator, + ); + + $url = $recipe->getCursorUrl('abc123'); + + self::assertSame('/items/books?cursor=abc123', $url); + } + + public function testAbsoluteUrlUsesRequestSchemeAndHost() + { + $request = Request::create('https://example.com/items?page=2'); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $recipe = new PaginationUrlGenerator(requestStack: $requestStack); + + self::assertSame('https://example.com/items?page=3', $recipe->getAbsoluteUrl(3)); + } + + public function testAbsoluteUrlRequiresRequestOrRouterRoute() + { + $recipe = new PaginationUrlGenerator(basePath: '/items'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot generate an absolute pagination URL'); + + $recipe->getAbsoluteUrl(3); + } + + public function testPageOneFallsBackToExplicitParamWhenRouteRequiresIt() + { + $generator = $this->createMock(UrlGeneratorInterface::class); + $generator->expects(self::exactly(2)) + ->method('generate') + ->willReturnCallback(static function (string $route, array $params) { + if (!isset($params['page'])) { + throw new \Symfony\Component\Routing\Exception\MissingMandatoryParametersException($route, ['page']); + } + + return '/blog/'.$params['page']; + }); + + $recipe = new PaginationUrlGenerator(route: 'blog', urlGenerator: $generator); + + self::assertSame('/blog/1', $recipe->getUrl(1)); + } + + public function testMissingParamOtherThanPageStillThrows() + { + $generator = $this->createStub(UrlGeneratorInterface::class); + $generator->method('generate') + ->willThrowException(new \Symfony\Component\Routing\Exception\MissingMandatoryParametersException('blog', ['slug'])); + + $recipe = new PaginationUrlGenerator(route: 'blog', urlGenerator: $generator); + + $this->expectException(\Symfony\Component\Routing\Exception\MissingMandatoryParametersException::class); + + $recipe->getUrl(2); + } +} diff --git a/src/Pagination/tests/PaginatorInterfaceTest.php b/src/Pagination/tests/PaginatorInterfaceTest.php new file mode 100644 index 00000000000..fdb4d7636ba --- /dev/null +++ b/src/Pagination/tests/PaginatorInterfaceTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\CursorPaginationBuilder; +use Symfony\UX\Pagination\CursorPaginationInterface; +use Symfony\UX\Pagination\NumberedPaginationInterface; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginationBuilder; +use Symfony\UX\Pagination\PaginationInterface; +use Symfony\UX\Pagination\PaginatorInterface; + +#[CoversNothing] +final class PaginatorInterfaceTest extends TestCase +{ + public function testPublicMethodsExposeTheSimpleAndBuilderContracts() + { + self::assertSame( + NumberedPaginationInterface::class, + self::getReturnType('paginate'), + ); + self::assertSame( + PaginationBuilder::class, + self::getReturnType('query'), + ); + self::assertSame( + PaginationBuilder::class, + self::getReturnType('fromCallbacks'), + ); + self::assertSame(CursorPaginationBuilder::class, self::getReturnType('cursor')); + + self::assertNotContains('paginateCallable', get_class_methods(PaginatorInterface::class)); + self::assertNotContains('cursorPaginate', get_class_methods(PaginatorInterface::class)); + } + + public function testCommonResultContractDoesNotExposeConfigurationOrTransformation() + { + $methods = get_class_methods(PaginationInterface::class); + + self::assertNotContains('map', $methods); + self::assertTrue(method_exists(Pagination::class, 'map')); + self::assertTrue(method_exists(CursorPagination::class, 'map')); + + foreach ([ + 'queryParameters', + 'preserveQueryString', + 'discardQueryString', + 'excludeQueryParameters', + 'fragment', + 'path', + ] as $method) { + self::assertNotContains($method, $methods); + self::assertFalse(method_exists(Pagination::class, $method)); + self::assertFalse(method_exists(CursorPagination::class, $method)); + } + } + + public function testNumberedResultContractDoesNotExposeFlowPolicies() + { + $methods = get_class_methods(NumberedPaginationInterface::class); + + self::assertNotContains('throwOnOutOfRange', $methods); + self::assertNotContains('throwOnCanonicalPage', $methods); + self::assertContains('throwOnOutOfRange', get_class_methods(Pagination::class)); + self::assertNotContains('throwOnCanonicalPage', get_class_methods(Pagination::class)); + } + + public function testSerializationShapesStayOutOfTheResultInterfaces() + { + $numbered = get_class_methods(NumberedPaginationInterface::class); + foreach (['getMetadata', 'getLinks', 'getAbsoluteUrl'] as $method) { + self::assertNotContains($method, $numbered); + self::assertTrue(method_exists(Pagination::class, $method)); + } + + self::assertNotContains('getLinks', get_class_methods(CursorPaginationInterface::class)); + self::assertTrue(method_exists(CursorPagination::class, 'getLinks')); + } + + private static function getReturnType(string $method): string + { + $type = new \ReflectionMethod(PaginatorInterface::class, $method)->getReturnType(); + self::assertInstanceOf(\ReflectionNamedType::class, $type); + + return $type->getName(); + } +} diff --git a/src/Pagination/tests/PaginatorTest.php b/src/Pagination/tests/PaginatorTest.php new file mode 100644 index 00000000000..d1a53e035bc --- /dev/null +++ b/src/Pagination/tests/PaginatorTest.php @@ -0,0 +1,644 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\Exception\OffsetLimitExceededException; +use Symfony\UX\Pagination\Exception\RuntimeException; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginationBuilder; +use Symfony\UX\Pagination\Paginator; +use Symfony\UX\Pagination\PaginatorInterface; + +#[CoversClass(Paginator::class)] +final class PaginatorTest extends TestCase +{ + private Paginator $paginator; + + protected function setUp(): void + { + $this->paginator = new Paginator([new ArrayPaginationAdapter()], cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret')); + } + + public function testImplementsInterface() + { + self::assertInstanceOf(PaginatorInterface::class, $this->paginator); + } + + public function testPaginateArray() + { + $result = $this->paginator->paginate(range(1, 100), 1, 10); + + self::assertInstanceOf(Pagination::class, $result); + self::assertSame(1, $result->getCurrentPage()); + self::assertSame(10, $result->getItemsPerPage()); + self::assertSame(100, $result->getTotalItems()); + self::assertSame(10, $result->getTotalPages()); + self::assertCount(10, $result); + } + + public function testPaginateSecondPage() + { + $result = $this->paginator->paginate(range(1, 100), 3, 20); + + self::assertSame(3, $result->getCurrentPage()); + self::assertSame(20, $result->getItemsPerPage()); + self::assertSame([41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60], $result->getItems()); + } + + public function testPaginateUsesConfiguredDefaultPerPage() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + defaultPerPage: 50, + ); + + $result = $paginator->paginate(range(1, 200), 1); + + self::assertSame(50, $result->getItemsPerPage()); + self::assertCount(50, $result); + self::assertSame(4, $result->getTotalPages()); + } + + public function testPaginateUsesConfiguredMaximumOffset() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + defaultMaxOffset: 20, + ); + + $this->expectException(OffsetLimitExceededException::class); + + $paginator->paginate(range(1, 100), page: 3, perPage: 20); + } + + public function testFromCallbacksUsesConfiguredDefaultPerPage() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + defaultPerPage: 15, + ); + + $items = range(1, 100); + $result = $paginator + ->fromCallbacks( + static fn (int $offset, int $limit) => \array_slice($items, $offset, $limit), + static fn () => \count($items), + ) + ->paginate(1); + + self::assertSame(15, $result->getItemsPerPage()); + self::assertCount(15, $result); + } + + public function testFromCallbacksExposesTheCompleteNumberedBuilder() + { + $items = range(1, 5); + $countCalls = 0; + + $result = $this->paginator + ->fromCallbacks( + static fn (int $offset, int $limit): array => \array_slice($items, $offset, $limit), + static function () use (&$countCalls, $items): int { + ++$countCalls; + + return \count($items); + }, + ) + ->perPage(2) + ->lookahead() + ->paginate(); + + self::assertSame([1, 2], $result->getItems()); + self::assertTrue($result->hasNext()); + self::assertSame(0, $countCalls); + } + + public function testFromCallbacksAcceptsInvokableServices() + { + $items = range(1, 10); + $slicer = new class($items) { + /** @param list $items */ + public function __construct(private readonly array $items) + { + } + + /** @return list */ + public function __invoke(int $offset, int $limit): array + { + return array_values(\array_slice($this->items, $offset, $limit)); + } + }; + $counter = new class($items) { + /** @param list $items */ + public function __construct(private readonly array $items) + { + } + + public function __invoke(): int + { + return \count($this->items); + } + }; + + $result = $this->paginator + ->fromCallbacks($slicer, $counter) + ->perPage(4) + ->paginate(); + + self::assertSame([1, 2, 3, 4], $result->getItems()); + self::assertSame(10, $result->getTotalItems()); + } + + public function testCursorBuilderUsesConfiguredDefaultPerPage() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + defaultPerPage: 5, + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + ); + + $source = []; + for ($i = 1; $i <= 50; ++$i) { + $source[] = ['id' => $i]; + } + + $result = $paginator + ->cursor($source) + ->orderBy('id') + ->context('items') + ->paginate(); + + self::assertSame(5, $result->getItemsPerPage()); + self::assertCount(5, $result); + } + + public function testPaginateExplicitPerPageOverridesDefault() + { + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + defaultPerPage: 50, + ); + + $result = $paginator->paginate(range(1, 200), 1, 10); + + self::assertSame(10, $result->getItemsPerPage()); + self::assertCount(10, $result); + } + + public function testPaginateThrowsForInvalidPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->paginator->paginate(range(1, 10), 0); + } + + public function testPaginateThrowsForInvalidPerPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->paginator->paginate(range(1, 10), 1, 0); + } + + public function testPaginateThrowsForUnsupportedSource() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/No "offset" pagination adapter/'); + $this->paginator->paginate(new \stdClass()); + } + + public function testQueryReturnsBuilder() + { + $builder = $this->paginator->query(range(1, 50)); + + self::assertInstanceOf(PaginationBuilder::class, $builder); + } + + public function testCursorRequiresConfiguredCodec() + { + $paginator = new Paginator([new ArrayPaginationAdapter()]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires a CursorCodecInterface'); + $paginator->cursor([]); + } + + public function testCursorBuilderPaginatesAnArray() + { + $source = []; + for ($i = 1; $i <= 50; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $result = $this->paginator + ->cursor($source) + ->orderBy('id') + ->perPage(10) + ->context('items') + ->paginate(); + + self::assertInstanceOf(CursorPagination::class, $result); + self::assertCount(10, $result); + self::assertTrue($result->hasNext()); + self::assertNotNull($result->getNextCursor()); + } + + public function testCursorBuilderRequiresAStableApplicationContextForArrays() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires an explicit context()'); + + $this->paginator + ->cursor([['id' => 1]]) + ->orderBy('id') + ->paginate(); + } + + public function testCursorBuilderContextRemainsValidWhenTheSourceMutates() + { + $source = array_map(static fn (int $id): array => ['id' => $id], range(1, 15)); + + $first = $this->paginator + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('tenant-a:items') + ->paginate(); + + $mutated = array_values(array_filter( + $source, + static fn (array $item): bool => !\in_array($item['id'], [3, 7], true), + )); + $second = $this->paginator + ->cursor($mutated) + ->orderBy('id', 'ASC') + ->cursor($first->getNextCursor()) + ->perPage(5) + ->context('tenant-a:items') + ->paginate(); + + self::assertSame([6, 8, 9, 10, 11], array_column($second->getItems(), 'id')); + } + + public function testCursorBuilderSupportsACustomField() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i, 'position' => $i * 10]; + } + + $result = $this->paginator + ->cursor($source) + ->orderBy('position') + ->perPage(10) + ->context('items') + ->paginate(); + + self::assertInstanceOf(CursorPagination::class, $result); + self::assertCount(10, $result); + self::assertTrue($result->hasNext()); + self::assertNotNull($result->getNextCursor()); + + // Navigate to next page using the cursor + $nextCursor = $result->getNextCursor(); + \assert(\is_string($nextCursor)); + $page2 = $this->paginator + ->cursor($source) + ->orderBy('position') + ->cursor($nextCursor) + ->perPage(10) + ->context('items') + ->paginate(); + self::assertCount(10, $page2); + self::assertTrue($page2->hasNext()); + } + + public function testCursorBuilderSupportsADirection() + { + $source = []; + for ($i = 1; $i <= 25; ++$i) { + $source[] = ['id' => $i, 'name' => 'Item '.$i]; + } + + $result = $this->paginator + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(10) + ->context('items') + ->paginate(); + + self::assertCount(10, $result); + self::assertTrue($result->hasNext()); + } + + public function testCursorBuilderAllowsAnAdapterOwnedOrder() + { + $source = new \stdClass(); + $adapter = new class implements \Symfony\UX\Pagination\Adapter\CursorAdapterInterface { + public function supports(mixed $source): bool + { + return $source instanceof \stdClass; + } + + public function resolveCursorOrder(mixed $source, ?array $fields, ?string $direction): \Symfony\UX\Pagination\Cursor\CursorOrder + { + if (null !== $fields || null !== $direction) { + throw new \LogicException('The remote adapter must own its order.'); + } + + return \Symfony\UX\Pagination\Cursor\CursorOrder::byIdentity('remote-order'); + } + + public function getCursorContext(mixed $source, ?string $context): string + { + return 'remote-context'; + } + + public function sliceWithCursor( + mixed $source, + ?\Symfony\UX\Pagination\Cursor\CursorBoundary $boundary, + int $limit, + \Symfony\UX\Pagination\Cursor\CursorOrder $order, + ): \Symfony\UX\Pagination\Cursor\CursorSlice { + return new \Symfony\UX\Pagination\Cursor\CursorSlice([42], null, null, false); + } + }; + $paginator = new Paginator( + [$adapter], + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + ); + + $pagination = $paginator->cursor($source)->paginate(); + + self::assertSame([42], $pagination->getItems()); + } + + public function testCursorBuilderThrowsForInvalidDirection() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('direction must be "ASC" or "DESC"'); + $this->paginator->cursor([['id' => 1]])->orderBy('id', 'INVALID'); + } + + public function testCursorBuilderThrowsForInvalidPerPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->paginator->cursor([['id' => 1]])->perPage(0); + } + + public function testCursorBuilderThrowsForUnsupportedSource() + { + $this->expectException(\InvalidArgumentException::class); + $this->paginator->cursor(new \stdClass())->paginate(); + } + + public function testFromCallbacksThrowsForInvalidPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Page must be >= 1'); + + $this->paginator + ->fromCallbacks( + static fn (int $offset, int $limit) => [], + static fn () => 0, + ) + ->paginate(0); + } + + public function testFromCallbacksThrowsForInvalidPerPage() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('perPage must be >= 1'); + + $this->paginator + ->fromCallbacks( + static fn (int $offset, int $limit) => [], + static fn () => 0, + ) + ->perPage(0); + } + + public function testCursorBuilderThrowsForNonCursorAdapter() + { + $adapter = $this->createStub(\Symfony\UX\Pagination\Adapter\PaginationAdapterInterface::class); + $adapter->method('supports')->willReturn(true); + + $paginator = new Paginator([$adapter], cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret')); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No cursor pagination adapter found'); + + $paginator->cursor('some source')->paginate(); + } + + public function testPaginateDefaultsToPage1WithoutRequest() + { + $result = $this->paginator->paginate(range(1, 50)); + + self::assertSame(1, $result->getCurrentPage()); + } + + public function testPaginateDefaultsToPage1WhenRequestHasNoPageParam() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['sort' => 'name']); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $paginator->paginate(range(1, 50)); + + self::assertSame(1, $result->getCurrentPage()); + } + + public function testPaginateResolvesPageFromRequest() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['page' => '3']); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $paginator->paginate(range(1, 100)); + + self::assertSame(3, $result->getCurrentPage()); + } + + public function testPaginateResolvesPageFromPathParameter() + { + $request = new \Symfony\Component\HttpFoundation\Request(); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('page', 5); + $request->attributes->set('_route_params', ['page' => 5]); + + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $paginator->paginate(range(1, 100)); + + self::assertSame(5, $result->getCurrentPage()); + } + + public function testPaginatePathParameterTakesPriorityOverQueryParameter() + { + $request = new \Symfony\Component\HttpFoundation\Request(query: ['page' => '2']); + $request->attributes->set('_route', 'blog_list'); + $request->attributes->set('page', 7); + $request->attributes->set('_route_params', ['page' => 7]); + + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $paginator->paginate(range(1, 100)); + + self::assertSame(7, $result->getCurrentPage()); + } + + public function testPaginateExplicitPageOverridesPathParameter() + { + $requestStack = $this->createMock(\Symfony\Component\HttpFoundation\RequestStack::class); + $requestStack->expects(self::never())->method('getCurrentRequest'); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + ); + + $result = $paginator->paginate(range(1, 100), 3); + + self::assertSame(3, $result->getCurrentPage()); + } + + public function testCursorBuilderReadsCursorFromRequest() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i]; + } + + $cursor = $this->signedCursor(10); + $request = new \Symfony\Component\HttpFoundation\Request(query: ['cursor' => $cursor]); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + ); + + $result = $paginator + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('items') + ->paginate(); + + self::assertSame($cursor, $result->getCursor()); + self::assertSame(11, $result->getItems()[0]['id']); + } + + public function testCursorBuilderExplicitCursorWinsOverRequest() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i]; + } + + $requestCursor = $this->signedCursor(10); + $explicitCursor = $this->signedCursor(20); + $request = new \Symfony\Component\HttpFoundation\Request(query: ['cursor' => $requestCursor]); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + ); + + $result = $paginator + ->cursor($source) + ->orderBy('id', 'ASC') + ->cursor($explicitCursor) + ->perPage(5) + ->context('items') + ->paginate(); + + self::assertSame(21, $result->getItems()[0]['id']); + } + + public function testCursorBuilderUsesConfiguredCursorParameter() + { + $source = []; + for ($i = 1; $i <= 30; ++$i) { + $source[] = ['id' => $i]; + } + + $cursor = $this->signedCursor(10); + $request = new \Symfony\Component\HttpFoundation\Request(query: ['after' => $cursor]); + $requestStack = new \Symfony\Component\HttpFoundation\RequestStack(); + $requestStack->push($request); + + $paginator = new Paginator( + [new ArrayPaginationAdapter()], + requestStack: $requestStack, + defaultCursorParam: 'after', + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + ); + + $result = $paginator + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(5) + ->context('items') + ->paginate(); + + // Read from ?after=... and generated URLs use the same parameter + self::assertSame(11, $result->getItems()[0]['id']); + self::assertStringContainsString('after=', (string) $result->getNextUrl()); + } + + public function testPaginateAllowsExplicitPerPageAboveBuilderGuard() + { + $paginator = new Paginator([new ArrayPaginationAdapter()]); + + $result = $paginator->paginate(range(1, 5000), page: 1, perPage: 2000); + + self::assertSame(2000, $result->getItemsPerPage()); + self::assertCount(2000, $result->getItems()); + } + + private function signedCursor(int $value): string + { + $fingerprint = \Symfony\UX\Pagination\Cursor\CursorOrder::byFields(['id'], 'ASC')->getFingerprint(); + + return new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret')->encode([$value], true, $fingerprint, 'items'); + } +} diff --git a/src/Pagination/tests/Test/PaginatorFactoryTest.php b/src/Pagination/tests/Test/PaginatorFactoryTest.php new file mode 100644 index 00000000000..63daf045d61 --- /dev/null +++ b/src/Pagination/tests/Test/PaginatorFactoryTest.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Test; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\UX\Pagination\Exception\InvalidCursorException; +use Symfony\UX\Pagination\Exception\OffsetLimitExceededException; +use Symfony\UX\Pagination\Test\PaginatorFactory; + +#[CoversClass(PaginatorFactory::class)] +final class PaginatorFactoryTest extends TestCase +{ + public function testCursorSecretIsMarkedAsSensitive() + { + $parameters = new \ReflectionMethod(PaginatorFactory::class, 'create')->getParameters(); + $cursorSecret = array_find($parameters, static fn (\ReflectionParameter $parameter): bool => 'cursorSecret' === $parameter->getName()); + + self::assertInstanceOf(\ReflectionParameter::class, $cursorSecret); + self::assertNotEmpty($cursorSecret->getAttributes(\SensitiveParameter::class)); + } + + public function testCreatesDeterministicOffsetPagination() + { + $paginator = PaginatorFactory::create(defaultPerPage: 2); + + $pagination = $paginator->paginate(range(1, 5), page: 2); + + self::assertSame([3, 4], $pagination->getItems()); + self::assertSame(2, $pagination->getCurrentPage()); + self::assertSame(2, $pagination->getItemsPerPage()); + self::assertSame(5, $pagination->getTotalItems()); + self::assertSame('/?page=3', $pagination->getNextUrl()); + self::assertSame('/', $pagination->getPreviousUrl()); + } + + public function testUsesARequestForPageResolutionAndRealisticUrls() + { + $request = Request::create('https://example.test/products?category=books&page=2'); + $paginator = PaginatorFactory::create(request: $request, defaultPerPage: 2); + + $pagination = $paginator->paginate(range(1, 6)); + + self::assertSame([3, 4], $pagination->getItems()); + self::assertSame('/products?category=books', $pagination->getPreviousUrl()); + self::assertSame('/products?category=books&page=3', $pagination->getNextUrl()); + self::assertSame('https://example.test/products?category=books&page=3', $pagination->getAbsoluteUrl(3)); + } + + public function testUsesAProvidedRequestStackAndCustomParameterNames() + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('/catalog?filter=active&offset=2')); + + $paginator = PaginatorFactory::create( + requestStack: $requestStack, + defaultPerPage: 2, + defaultPageParam: 'offset', + defaultCursorParam: 'after', + ); + + $pagination = $paginator->paginate(range(1, 6)); + + self::assertSame([3, 4], $pagination->getItems()); + self::assertSame('/catalog?filter=active&offset=3', $pagination->getNextUrl()); + } + + public function testExplicitRequestBecomesCurrentOnAProvidedStack() + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('/old?page=3')); + + $paginator = PaginatorFactory::create( + request: Request::create('/new?page=2'), + requestStack: $requestStack, + defaultPerPage: 2, + ); + + $pagination = $paginator->paginate(range(1, 6)); + + self::assertSame([3, 4], $pagination->getItems()); + self::assertSame('/new?page=3', $pagination->getNextUrl()); + self::assertSame('/new?page=2', $requestStack->getCurrentRequest()?->getRequestUri()); + } + + public function testUsesARealUrlGeneratorForRoutes() + { + $routes = new RouteCollection(); + $routes->add('product_list', new Route('/catalog/{section}')); + $urlGenerator = new UrlGenerator($routes, new RequestContext()); + + $request = Request::create('/catalog/books?filter=active'); + $request->attributes->set('_route', 'product_list'); + $request->attributes->set('_route_params', ['section' => 'books']); + + $paginator = PaginatorFactory::create( + request: $request, + urlGenerator: $urlGenerator, + defaultPerPage: 2, + ); + + $pagination = $paginator->paginate(range(1, 6)); + + self::assertSame('/catalog/books?filter=active&page=2', $pagination->getNextUrl()); + } + + public function testForwardsTheMaximumOffsetGuard() + { + $paginator = PaginatorFactory::create( + defaultPerPage: 2, + defaultMaxOffset: 1, + ); + + $this->expectException(OffsetLimitExceededException::class); + + $paginator->paginate(range(1, 6), page: 2); + } + + public function testSignedCursorUrlsRoundTripForwardAndBackward() + { + $source = $this->cursorSource(); + $first = PaginatorFactory::create(request: Request::create('/events?type=release')) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([1, 2], array_column($first->getItems(), 'id')); + $nextUrl = $first->getNextUrl(); + self::assertNotNull($nextUrl); + + $sameFirstPage = PaginatorFactory::create(request: Request::create('/events?type=release')) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + self::assertSame($first->getNextCursor(), $sameFirstPage->getNextCursor()); + + $secondRequest = Request::create('https://example.test'.$nextUrl); + $second = PaginatorFactory::create(request: $secondRequest) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([3, 4], array_column($second->getItems(), 'id')); + $previousUrl = $second->getPreviousUrl(); + self::assertNotNull($previousUrl); + self::assertStringStartsWith('/events?type=release&cursor=', $previousUrl); + + $previousRequest = Request::create('https://example.test'.$previousUrl); + $again = PaginatorFactory::create(request: $previousRequest) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([1, 2], array_column($again->getItems(), 'id')); + } + + public function testUsesACustomCursorParameterInRequestsAndUrls() + { + $source = $this->cursorSource(); + $first = PaginatorFactory::create( + request: Request::create('/events?type=release'), + defaultCursorParam: 'after', + ) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + $nextUrl = $first->getNextUrl(); + self::assertNotNull($nextUrl); + self::assertStringContainsString('after=', $nextUrl); + self::assertStringNotContainsString('cursor=', $nextUrl); + + $second = PaginatorFactory::create( + request: Request::create('https://example.test'.$nextUrl), + defaultCursorParam: 'after', + ) + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + + self::assertSame([3, 4], array_column($second->getItems(), 'id')); + } + + public function testCursorSecretCanBeOverridden() + { + $source = $this->cursorSource(); + $first = PaginatorFactory::create(cursorSecret: 'first-test-secret') + ->cursor($source) + ->orderBy('id', 'ASC') + ->perPage(2) + ->context('events') + ->paginate(); + $cursor = $first->getNextCursor(); + \assert(null !== $cursor); + + $builder = PaginatorFactory::create(cursorSecret: 'another-test-secret') + ->cursor($source) + ->orderBy('id', 'ASC') + ->cursor($cursor) + ->perPage(2) + ->context('events'); + + $this->expectException(InvalidCursorException::class); + $this->expectExceptionMessage('Invalid cursor signature.'); + + $builder->paginate(); + } + + /** + * @return list + */ + private function cursorSource(): array + { + return array_map( + static fn (int $id): array => ['id' => $id, 'name' => 'Event '.$id], + range(1, 6), + ); + } +} diff --git a/src/Pagination/tests/Twig/PaginationExtensionTest.php b/src/Pagination/tests/Twig/PaginationExtensionTest.php new file mode 100644 index 00000000000..600e3551efc --- /dev/null +++ b/src/Pagination/tests/Twig/PaginationExtensionTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Twig; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\Twig\PaginationExtension; +use Symfony\UX\Pagination\Twig\PaginationRenderer; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +#[CoversClass(PaginationExtension::class)] +final class PaginationExtensionTest extends TestCase +{ + public function testRenderPaginationUsesDefaultTheme() + { + $pagination = $this->createPagination(); + + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme:{{ pagination.info }}', + ]))); + + self::assertSame('default-theme:'.$pagination->getInfo(), $extension->renderPagination($pagination)); + } + + public function testRenderPaginationPassesExplicitArgumentsToTheme() + { + $pagination = $this->createPagination(); + + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/bootstrap.html.twig' => 'bootstrap-theme:{{ showInfo ? "info-on" : "info-off" }}:{{ attributes.class|default("no-class") }}', + ]))); + + $result = $extension->renderPagination( + $pagination, + ['class' => 'my-nav'], + theme: '@UXPagination/theme/bootstrap.html.twig', + showInfo: false, + ); + + self::assertSame('bootstrap-theme:info-off:my-nav', $result); + } + + public function testRenderDispatchesToRenderPagination() + { + $pagination = $this->createPagination(); + + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/tailwind.html.twig' => "tailwind-theme:{{ attributes.theme is defined ? 'leaked-theme' : 'no-theme' }}", + ]))); + + $result = $extension->render([ + 'pagination' => $pagination, + 'theme' => '@UXPagination/theme/tailwind.html.twig', + ]); + + self::assertSame('tailwind-theme:no-theme', $result); + } + + public function testRenderRequiresPaginationArgument() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "pagination" argument must be a PaginationInterface instance.'); + + $extension->render(); + } + + public function testRenderRejectsInvalidTheme() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('theme'); + + $extension->render(['pagination' => $this->createPagination(), 'theme' => false]); + } + + public function testRenderRejectsEmptyTheme() + { + $extension = new PaginationExtension( + new PaginationRenderer($this->createTwig()), + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The "theme" argument must be a non-empty string or null.', + ); + + $extension->render([ + 'pagination' => $this->createPagination(), + 'theme' => '', + ]); + } + + public function testRenderRejectsInvalidShowInfo() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('showInfo'); + + $extension->render(['pagination' => $this->createPagination(), 'showInfo' => 'yes']); + } + + public function testRenderAcceptsAttributeGroups() + { + $pagination = $this->createPagination(); + $linkAttributes = static fn (array $link): array => ['data-page' => $link['page']]; + + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => "{{ attributes.id }}:{{ attributes.class }}:{{ navigationAttributes.class }}:{{ linkAttributes.pages[2]['data-page'] }}", + ]))); + + $result = $extension->render([ + 'pagination' => $pagination, + 'attributes' => ['id' => 'product-pages'], + 'class' => 'product-pagination', + 'navigationAttributes' => ['class' => 'controls'], + 'linkAttributes' => $linkAttributes, + ]); + + self::assertSame('product-pages:product-pagination:controls:2', $result); + } + + public function testRenderRejectsInvalidLinkAttributes() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('linkAttributes'); + + $extension->render(['pagination' => $this->createPagination(), 'linkAttributes' => 'unsafe']); + } + + public function testRenderRejectsInvalidRootAttributes() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"attributes" argument must be an array'); + + $extension->render(['pagination' => $this->createPagination(), 'attributes' => 'invalid']); + } + + public function testRenderRejectsInvalidNavigationAttributes() + { + $extension = new PaginationExtension(new PaginationRenderer($this->createTwig())); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"navigationAttributes" argument must be an array'); + + $extension->render([ + 'pagination' => $this->createPagination(), + 'navigationAttributes' => 'invalid', + ]); + } + + /** + * @param array $templates + */ + private function createTwig(array $templates = []): Environment + { + return new Environment(new ArrayLoader($templates), ['strict_variables' => true]); + } + + /** + * @param array $source + */ + private function createPagination(array $source = [], int $page = 1, int $perPage = 10): Pagination + { + if (empty($source)) { + $source = range(1, 100); + } + + return new Pagination( + source: $source, + adapter: new ArrayPaginationAdapter(), + currentPage: $page, + perPage: $perPage, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + } +} diff --git a/src/Pagination/tests/Twig/PaginationRendererTest.php b/src/Pagination/tests/Twig/PaginationRendererTest.php new file mode 100644 index 00000000000..3588303daf7 --- /dev/null +++ b/src/Pagination/tests/Twig/PaginationRendererTest.php @@ -0,0 +1,414 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Twig; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Adapter\CursorAdapterInterface; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\Exception\InvalidArgumentException; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; +use Symfony\UX\Pagination\NumberedPaginationInterface; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\PaginationInterface; +use Symfony\UX\Pagination\Twig\PaginationRenderer; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +#[CoversClass(PaginationRenderer::class)] +final class PaginationRendererTest extends TestCase +{ + public function testRenderUsesDefaultTheme() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme:{{ pagination.info }}', + ])); + + $result = $renderer->renderPagination($pagination); + + self::assertSame('default-theme:'.$pagination->getInfo(), $result); + } + + public function testRenderWithBootstrapTheme() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/bootstrap.html.twig' => 'bootstrap-theme:{{ pagination.info }}', + ])); + + $result = $renderer->renderPagination( + $pagination, + theme: '@UXPagination/theme/bootstrap.html.twig', + ); + + self::assertSame('bootstrap-theme:'.$pagination->getInfo(), $result); + } + + public function testRenderWithTailwindTheme() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/tailwind.html.twig' => 'tailwind-theme:{{ pagination.info }}', + ])); + + $result = $renderer->renderPagination( + $pagination, + theme: '@UXPagination/theme/tailwind.html.twig', + ); + + self::assertSame('tailwind-theme:'.$pagination->getInfo(), $result); + } + + public function testRenderWithCustomTheme() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + 'custom/pagination.html.twig' => 'custom-theme:{{ pagination.info }}', + ])); + + $result = $renderer->renderPagination($pagination, theme: 'custom/pagination.html.twig'); + + self::assertSame('custom-theme:'.$pagination->getInfo(), $result); + } + + public function testRootLevelTwigPathIsPreserved() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + 'pagination.html.twig' => 'root-theme:{{ pagination.info }}', + ])); + + $result = $renderer->renderPagination( + $pagination, + theme: 'pagination.html.twig', + ); + + self::assertSame('root-theme:'.$pagination->getInfo(), $result); + } + + public function testExplicitThemeMustNotBeEmpty() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The "theme" argument must be a non-empty string or null.', + ); + + $renderer->renderPagination($this->createPagination(), theme: ' '); + } + + public function testConfiguredDefaultThemeMustNotBeEmpty() + { + $renderer = new PaginationRenderer( + $this->createTwig(), + '', + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The "theme" argument must be a non-empty string or null.', + ); + + $renderer->renderPagination($this->createPagination()); + } + + public function testRenderPassesOptionsAsThemeVariables() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => '{{ showInfo ? "info-on" : "info-off" }}:{{ attributes.class|default("no-class") }}', + ])); + + $result = $renderer->renderPagination($pagination, ['class' => 'my-nav'], showInfo: false); + + self::assertSame('info-off:my-nav', $result); + } + + public function testEmptyLinkAttributesDoNotTraverseNumberedLinks() + { + $pagination = $this->createMock(NumberedPaginationInterface::class); + $pagination->expects(self::never())->method('getPages'); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => '{{ linkAttributes.previous|length }}:{{ linkAttributes.pages|length }}:{{ linkAttributes.next|length }}', + ])); + + self::assertSame('0:0:0', $renderer->renderPagination($pagination)); + } + + public function testRenderWithConfiguredDefaultTheme() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer( + $this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/bootstrap.html.twig' => 'bootstrap-theme', + ]), + '@UXPagination/theme/bootstrap.html.twig', + ); + + $result = $renderer->renderPagination($pagination); + + self::assertSame('bootstrap-theme', $result); + } + + public function testExplicitThemeOverridesConfiguredDefault() + { + $pagination = $this->createPagination(); + + $renderer = new PaginationRenderer( + $this->createTwig([ + '@UXPagination/theme/bootstrap.html.twig' => 'bootstrap-theme', + '@UXPagination/theme/tailwind.html.twig' => 'tailwind-theme', + ]), + '@UXPagination/theme/bootstrap.html.twig', + ); + + $result = $renderer->renderPagination( + $pagination, + theme: '@UXPagination/theme/tailwind.html.twig', + ); + + self::assertSame('tailwind-theme', $result); + } + + public function testRenderCursorPaginationUsesDefaultTheme() + { + $pagination = $this->createCursorPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme:{{ numbered ? "numbered" : "plain" }}', + ])); + + $result = $renderer->renderPagination($pagination); + + self::assertSame('default-theme:plain', $result); + } + + public function testBootstrapThemeSupportsCursorPagination() + { + $pagination = $this->createCursorPagination(); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/bootstrap.html.twig' => 'bootstrap-theme:{{ numbered ? "numbered" : "plain" }}', + ])); + + self::assertSame( + 'bootstrap-theme:plain', + $renderer->renderPagination( + $pagination, + theme: '@UXPagination/theme/bootstrap.html.twig', + ), + ); + } + + public function testBuiltInThemeSupportsThirdPartyPaginationContract() + { + $pagination = $this->createStub(PaginationInterface::class); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + '@UXPagination/theme/tailwind.html.twig' => 'tailwind-theme:{{ numbered ? "numbered" : "plain" }}', + ])); + + self::assertSame( + 'tailwind-theme:plain', + $renderer->renderPagination( + $pagination, + theme: '@UXPagination/theme/tailwind.html.twig', + ), + ); + } + + public function testCursorPaginationUsesConfiguredCustomTemplate() + { + $pagination = $this->createCursorPagination(); + + $renderer = new PaginationRenderer( + $this->createTwig([ + '@UXPagination/theme/default.html.twig' => 'default-theme', + 'app/pagination.html.twig' => 'custom-cursor-theme', + ]), + 'app/pagination.html.twig', + ); + + self::assertSame('custom-cursor-theme', $renderer->renderPagination($pagination)); + } + + public function testThemeWithPaginationBlockRendersOnlyThatBlock() + { + $renderer = new PaginationRenderer($this->createTwig([ + 'block.html.twig' => '{% block pagination %}probe-block{% endblock %}{% block leak %}LEAK{% endblock %}', + 'standalone.html.twig' => 'standalone-body', + ])); + $pagination = $this->createPagination(); + + self::assertSame('probe-block', $renderer->renderPagination($pagination, theme: 'block.html.twig')); + self::assertSame('standalone-body', $renderer->renderPagination($pagination, theme: 'standalone.html.twig')); + } + + public function testRenderResolvesLinkAttributeClosureWithNavigationContext() + { + $pagination = new Pagination( + source: range(1, 50), + adapter: new ArrayPaginationAdapter(), + currentPage: 2, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => "{{ linkAttributes.previous['data-page'] }}:{{ linkAttributes.pages[3]['data-page'] }}:{{ linkAttributes.next['data-page'] }}", + ])); + $html = $renderer->renderPagination( + $pagination, + linkAttributes: static fn (array $link): array => ['data-page' => $link['page']], + ); + + self::assertSame('1:3:3', $html); + } + + public function testRenderRejectsInvalidAttributeName() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid attribute name'); + + $renderer->renderPagination($this->createPagination(), ['onload x' => 'alert(1)']); + } + + public function testRenderRejectsNonScalarAttributeValue() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('not scalar or Stringable'); + + $renderer->renderPagination($this->createPagination(), ['data-context' => []]); + } + + public function testRenderRejectsNonStringClass() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "class" value'); + + $renderer->renderPagination($this->createPagination(), ['CLASS' => true]); + } + + public function testRenderRejectsHrefOverrideFromLinkAttributes() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cannot override the "href"'); + + $renderer->renderPagination($this->createPagination(), linkAttributes: ['href' => 'javascript:alert(1)']); + } + + public function testCursorLinkContextContainsTheOpaqueCursor() + { + $renderer = new PaginationRenderer($this->createTwig([ + '@UXPagination/theme/default.html.twig' => "{{ linkAttributes.next['data-cursor'] is null ? 'missing-cursor' : 'opaque-cursor' }}", + ])); + $html = $renderer->renderPagination( + $this->createCursorPagination(), + linkAttributes: static fn (array $link): array => ['data-cursor' => $link['cursor']], + ); + + self::assertSame('opaque-cursor', $html); + } + + public function testLinkContextRejectsAnInconsistentPaginationResult() + { + $pagination = $this->createStub(PaginationInterface::class); + $pagination->method('hasNext')->willReturn(true); + $pagination->method('getNextUrl')->willReturn(null); + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('reports a "next" link without a URL'); + + $renderer->renderPagination($pagination, linkAttributes: static fn (array $link): array => []); + } + + public function testRenderRejectsInvalidClosureReturnValue() + { + $renderer = new PaginationRenderer($this->createTwig()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('callable must return an array'); + + $renderer->renderPagination( + $this->createPagination(), + linkAttributes: static fn (): string => 'invalid', + ); + } + + /** + * @param array $templates + */ + private function createTwig(array $templates = []): Environment + { + return new Environment(new ArrayLoader($templates), ['strict_variables' => true]); + } + + private function createPagination(): Pagination + { + return new Pagination( + source: range(1, 100), + adapter: new ArrayPaginationAdapter(), + currentPage: 1, + perPage: 10, + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + } + + private function createCursorPagination(): CursorPagination + { + $adapter = $this->createStub(CursorAdapterInterface::class); + $adapter->method('sliceWithCursor')->willReturn(new \Symfony\UX\Pagination\Cursor\CursorSlice( + range(1, 10), + new \Symfony\UX\Pagination\Cursor\CursorBoundary([10]), + null, + true, + )); + + return new CursorPagination( + source: range(1, 100), + adapter: $adapter, + cursor: null, + perPage: 10, + order: \Symfony\UX\Pagination\Cursor\CursorOrder::byFields(['id'], 'ASC'), + cursorCodec: new \Symfony\UX\Pagination\Cursor\CursorCodec('test-secret'), + context: 'test', + paginationUrlGenerator: new PaginationUrlGenerator(basePath: '/items'), + ); + } +} diff --git a/src/Pagination/tests/Twig/TemplateIntegrationTest.php b/src/Pagination/tests/Twig/TemplateIntegrationTest.php new file mode 100644 index 00000000000..86774d59794 --- /dev/null +++ b/src/Pagination/tests/Twig/TemplateIntegrationTest.php @@ -0,0 +1,901 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Pagination\Tests\Twig; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Pagination\Adapter\ArrayPaginationAdapter; +use Symfony\UX\Pagination\Cursor\CursorCodec; +use Symfony\UX\Pagination\Cursor\CursorOrder; +use Symfony\UX\Pagination\CursorPagination; +use Symfony\UX\Pagination\Navigation\PaginationUrlGenerator; +use Symfony\UX\Pagination\Pagination; +use Symfony\UX\Pagination\Twig\PaginationRenderer; +use Twig\Environment; +use Twig\Extension\AbstractExtension; +use Twig\Loader\ArrayLoader; +use Twig\Loader\ChainLoader; +use Twig\Loader\FilesystemLoader; +use Twig\Markup; +use Twig\TwigFilter; + +/** + * Integration test: renders templates through a real Twig environment + * and asserts the actual HTML output. + */ +final class TemplateIntegrationTest extends TestCase +{ + private Environment $twig; + + protected function setUp(): void + { + $loader = new FilesystemLoader(); + $loader->addPath(\dirname(__DIR__, 2).'/templates', 'UXPagination'); + + $this->twig = new Environment($loader, [ + 'strict_variables' => true, + ]); + // Stub |trans filter: returns the key as-is (no real translator in tests) + $this->twig->addExtension(new class extends AbstractExtension { + public function getFilters(): array + { + return [ + new TwigFilter('trans', static fn (string $key) => $key), + ]; + } + }); + } + + public function testDefaultTemplateRendersNativeNavigationWithoutRuntimeAttributes() + { + $html = $this->renderTheme( + '@UXPagination/theme/default.html.twig', + range(1, 50), + 2, + 10, + ); + + self::assertStringNotContainsString('data-controller=', $html); + self::assertStringNotContainsString('data-current-page=', $html); + self::assertStringNotContainsString('data-total-pages=', $html); + self::assertStringNotContainsString('role="navigation"', $html); + } + + #[DataProvider('themes')] + public function testEveryThemeKeepsTheSharedServerRenderedContract(string $theme) + { + $html = $this->renderTheme($theme, range(1, 50), 2, 10); + + self::assertStringContainsString('