diff --git a/composer.json b/composer.json
index 8159b5b6..532e472c 100644
--- a/composer.json
+++ b/composer.json
@@ -6,10 +6,11 @@
"license": "MIT",
"require": {
"php": "^8.1",
- "pagerfanta/core": "^3.7 || ^4.0",
+ "pagerfanta/core": "^4.10",
"psr/container": "^1.0 || ^2.0",
"symfony/config": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.4 || ^7.3 || ^8.0",
+ "symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/http-foundation": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/property-access": "^5.4 || ^6.4 || ^7.3 || ^8.0",
@@ -19,7 +20,7 @@
"jms/serializer": "^3.18",
"jms/serializer-bundle": "^4.2 || ^5.0",
"matthiasnoback/symfony-dependency-injection-test": "^6.2",
- "pagerfanta/twig": "^3.7 || ^4.0",
+ "pagerfanta/twig": "^4.10",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan": "2.2.16",
"phpstan/phpstan-phpunit": "2.0.19",
@@ -34,7 +35,7 @@
"conflict": {
"jms/serializer": "<3.18",
"jms/serializer-bundle": "<4.2",
- "pagerfanta/twig": "<3.7",
+ "pagerfanta/twig": "<4.10",
"symfony/serializer": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
"symfony/translation": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
"symfony/twig-bridge": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
diff --git a/config/jms_serializer.php b/config/jms_serializer.php
index ce305133..e2afa4b9 100644
--- a/config/jms_serializer.php
+++ b/config/jms_serializer.php
@@ -2,6 +2,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+use BabDev\PagerfantaBundle\Serializer\Handler\CursorPagerHandler;
use BabDev\PagerfantaBundle\Serializer\Handler\PagerfantaHandler;
return static function (ContainerConfigurator $container): void {
@@ -10,4 +11,11 @@
$services->set('pagerfanta.serializer.handler', PagerfantaHandler::class)
->tag('jms_serializer.subscribing_handler')
;
+
+ $services->set('pagerfanta.serializer.cursor_handler', CursorPagerHandler::class)
+ ->args([
+ service('pagerfanta.cursor_encoder'),
+ ])
+ ->tag('jms_serializer.subscribing_handler')
+ ;
};
diff --git a/config/pagerfanta.php b/config/pagerfanta.php
index 820efd5f..360e21c7 100644
--- a/config/pagerfanta.php
+++ b/config/pagerfanta.php
@@ -2,12 +2,26 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder;
+use BabDev\PagerfantaBundle\Position\PositionResolver;
+use BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory;
use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory;
use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory;
+use Pagerfanta\Cursor\Base64JsonCursorEncoder;
+use Pagerfanta\Cursor\CursorEncoderInterface;
+use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface;
use Pagerfanta\View\DefaultView;
use Pagerfanta\View\Foundation6View;
use Pagerfanta\View\SemanticUiView;
+use Pagerfanta\View\SequentialView;
+use Pagerfanta\View\Template\DefaultTemplate;
+use Pagerfanta\View\Template\Foundation6Template;
+use Pagerfanta\View\Template\SemanticUiTemplate;
+use Pagerfanta\View\Template\TwitterBootstrap3Template;
+use Pagerfanta\View\Template\TwitterBootstrap4Template;
+use Pagerfanta\View\Template\TwitterBootstrap5Template;
+use Pagerfanta\View\Template\TwitterBootstrapTemplate;
use Pagerfanta\View\TwitterBootstrap3View;
use Pagerfanta\View\TwitterBootstrap4View;
use Pagerfanta\View\TwitterBootstrap5View;
@@ -17,14 +31,48 @@
return static function (ContainerConfigurator $container): void {
$services = $container->services();
+ $services->set('pagerfanta.cursor_encoder.base64_json', Base64JsonCursorEncoder::class);
+
+ $services->set('pagerfanta.cursor_encoder.signed', SignedCursorEncoder::class)
+ ->args([
+ service('pagerfanta.cursor_encoder.base64_json'),
+ param('kernel.secret'),
+ ])
+ ;
+
+ $services->alias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed');
+ $services->alias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder');
+
+ $services->set('pagerfanta.position_resolver', PositionResolver::class)
+ ->args([
+ service('property_accessor'),
+ service('pagerfanta.cursor_encoder'),
+ ])
+ ;
+ $services->alias(PositionResolver::class, 'pagerfanta.position_resolver');
+
$services->set('pagerfanta.route_generator_factory', RequestAwareRouteGeneratorFactory::class)
->args([
service('router'),
service('request_stack'),
service('property_accessor'),
+ service('pagerfanta.cursor_encoder'),
+ ])
+ ->deprecate('babdev/pagerfanta-bundle', '4.7', 'The "%service_id%" service is deprecated, use the "pagerfanta.position_route_generator_factory" service instead.')
+ ;
+ $services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory')
+ ->deprecate('babdev/pagerfanta-bundle', '4.7', \sprintf('The "%%alias_id%%" alias is deprecated, use the "%s" alias instead.', PositionRouteGeneratorFactoryInterface::class))
+ ;
+
+ $services->set('pagerfanta.position_route_generator_factory', RequestAwarePositionRouteGeneratorFactory::class)
+ ->args([
+ service('router'),
+ service('request_stack'),
+ service('property_accessor'),
+ service('pagerfanta.cursor_encoder'),
])
;
- $services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory');
+ $services->alias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.position_route_generator_factory');
$services->set('pagerfanta.view.default', DefaultView::class)
->tag('pagerfanta.view', ['alias' => 'default'])
@@ -54,6 +102,24 @@
->tag('pagerfanta.view', ['alias' => 'twitter_bootstrap5'])
;
+ foreach ([
+ 'default' => DefaultTemplate::class,
+ 'foundation6' => Foundation6Template::class,
+ 'semantic_ui' => SemanticUiTemplate::class,
+ 'twitter_bootstrap' => TwitterBootstrapTemplate::class,
+ 'twitter_bootstrap3' => TwitterBootstrap3Template::class,
+ 'twitter_bootstrap4' => TwitterBootstrap4Template::class,
+ 'twitter_bootstrap5' => TwitterBootstrap5Template::class,
+ ] as $name => $templateClass) {
+ $services->set(\sprintf('pagerfanta.view.%s_sequential', $name), SequentialView::class)
+ ->args([
+ inline_service($templateClass),
+ \sprintf('%s_sequential', $name),
+ ])
+ ->tag('pagerfanta.view', ['alias' => \sprintf('%s_sequential', $name)])
+ ;
+ }
+
$services->set('pagerfanta.view_factory', ContainerBackedImmutableViewFactory::class)
->args([
abstract_arg('service locator'),
diff --git a/config/serializer.php b/config/serializer.php
index afba863a..9e2275d8 100644
--- a/config/serializer.php
+++ b/config/serializer.php
@@ -2,6 +2,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+use BabDev\PagerfantaBundle\Serializer\Normalizer\CursorPagerNormalizer;
use BabDev\PagerfantaBundle\Serializer\Normalizer\PagerfantaNormalizer;
return static function (ContainerConfigurator $container): void {
@@ -10,4 +11,11 @@
$services->set('pagerfanta.serializer.normalizer', PagerfantaNormalizer::class)
->tag('serializer.normalizer')
;
+
+ $services->set('pagerfanta.serializer.cursor_normalizer', CursorPagerNormalizer::class)
+ ->args([
+ service('pagerfanta.cursor_encoder'),
+ ])
+ ->tag('serializer.normalizer')
+ ;
};
diff --git a/config/twig.php b/config/twig.php
index 4e189fd8..23ed181a 100644
--- a/config/twig.php
+++ b/config/twig.php
@@ -3,6 +3,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use BabDev\PagerfantaBundle\Twig\UndefinedCallableHandler;
+use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
use Pagerfanta\Twig\Extension\PagerfantaExtension;
use Pagerfanta\Twig\Extension\PagerfantaRuntime;
use Pagerfanta\Twig\View\TwigView;
@@ -18,7 +19,8 @@
->args([
abstract_arg('default view'),
service('pagerfanta.view_factory'),
- service('pagerfanta.route_generator_factory'),
+ service(PositionRouteGeneratorFactoryInterface::class),
+ abstract_arg('default sequential view'),
])
->tag('twig.runtime')
;
diff --git a/docs/configuring-the-bundle.md b/docs/configuring-the-bundle.md
index b85f0272..b7943cbd 100644
--- a/docs/configuring-the-bundle.md
+++ b/docs/configuring-the-bundle.md
@@ -10,6 +10,21 @@ babdev_pagerfanta:
default_view: my_view
```
+## Default Sequential View
+
+
The default sequential view was introduced in PagerfantaBundle 4.7.
+
+Views with numbered page links can only render offset pagers. When the default view (or the view given to the `pagerfanta()` Twig function) cannot render a pager, such as a [cursor pager](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination), the Twig function renders it with the default sequential view instead.
+
+The default sequential view can be set with the `default_sequential_view` configuration node. When not set, the sequential variant of the default view is used if one exists (i.e. `twitter_bootstrap5_sequential` for the `twitter_bootstrap5` view). The Twig view can render any pager, so it does not need a default sequential view.
+
+```yaml
+# config/packages/babdev_pagerfanta.yaml
+babdev_pagerfanta:
+ default_view: twitter_bootstrap5
+ default_sequential_view: twitter_bootstrap5_sequential
+```
+
## Default Twig Template
The default Twig template for Twig views in your application can be set with the `default_twig_template` configuration node. This defaults to "`@BabDevPagerfanta/default.html.twig`".
@@ -23,7 +38,7 @@ babdev_pagerfanta:
## Exception Strategies
-By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change.
+By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses, and `Pagerfanta\Exception\InvalidCursorException` exceptions into 400 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change.
```yaml
# config/packages/babdev_pagerfanta.yaml
@@ -31,4 +46,7 @@ babdev_pagerfanta:
exceptions_strategy:
out_of_range_page: custom # Disables converting `Pagerfanta\Exception\NotValidMaxPerPageException` to a 404 response
not_valid_current_page: to_http_not_found # Default behavior converting `Pagerfanta\Exception\NotValidCurrentPageException` to a 404 response
+ invalid_cursor: to_http_bad_request # Default behavior converting `Pagerfanta\Exception\InvalidCursorException` to a 400 response
```
+
+The invalid_cursor exception strategy was introduced in PagerfantaBundle 4.7.
diff --git a/docs/cursor-pagination.md b/docs/cursor-pagination.md
new file mode 100644
index 00000000..c42480ea
--- /dev/null
+++ b/docs/cursor-pagination.md
@@ -0,0 +1,108 @@
+# Cursor Pagination
+
+Cursor pagination support was introduced in PagerfantaBundle 4.7.
+
+The bundle integrates the [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) support from Pagerfanta with your Symfony application:
+
+- Cursors in URLs are signed, so they cannot be tampered with
+- The position of the current page is resolved from the request with the `BabDev\PagerfantaBundle\Position\PositionResolver`
+- Invalid cursors are converted into 400 responses
+- Cursor pagers are rendered with the [sequential views](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views) and serialized with their cursors
+
+## Resolving The Current Page
+
+The `BabDev\PagerfantaBundle\Position\PositionResolver` service reads the position of the current page from the request. It has a method for each kind of pager, which ignores the parameters for the other kind:
+
+- `resolveCursorPosition()` returns the `Pagerfanta\Position\CursorPosition` from the `cursor` parameter, or null for the first page
+- `resolvePagePosition()` returns the `Pagerfanta\Position\PagePosition` from the `page` parameter, or null for the first page
+- `resolve()` returns either position, preferring the cursor when both parameters are given
+
+Below is an example of paginating blog posts with a cursor pager.
+
+```php
+createQueryBuilder('p')
+ ->orderBy('p.publishedAt', 'DESC')
+ ->addOrderBy('p.id', 'DESC');
+
+ $pager = CursorPagerfantaFactory::create(
+ new CursorQueryAdapter($queryBuilder),
+ 10,
+ $positionResolver->resolveCursorPosition($request),
+ );
+
+ return $this->render(
+ 'blog/list.html.twig',
+ [
+ 'pager' => $pager,
+ ],
+ );
+ }
+}
+```
+
+The parameters are read from the query string and the route parameters of the request. If your application uses different parameters, set the `cursorParameter` and `pageParameter` options, which use the same format as the [route generator options](/open-source/packages/pagerfantabundle/docs/4.x/generating-paginated-routes#position-route-generator-options).
+
+```php
+$position = $positionResolver->resolveCursorPosition($request, ['cursorParameter' => '[after]']);
+```
+
+When the page parameter is not a positive integer, a `Pagerfanta\Exception\NotValidCurrentPageException` is thrown, which is handled by the `not_valid_current_page` [exception strategy](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies).
+
+## Signed Cursors
+
+The bundle encodes cursors with the `BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder`, which appends a HMAC-SHA256 signature using the `kernel.secret` parameter to the cursors encoded by the `Pagerfanta\Cursor\Base64JsonCursorEncoder`. The signature is verified before a cursor is decoded, so a cursor which has been altered (or was not created by your application) is rejected with a `Pagerfanta\Exception\InvalidCursorException` and never reaches your query.
+
+The encoder is available as the `pagerfanta.cursor_encoder` service, and can be autowired with the `Pagerfanta\Cursor\CursorEncoderInterface`. It is used by the position resolver, the route generators, and the serializers.
+
+Changing the kernel.secret parameter invalidates all previously generated cursors, clients using a cursor from before the change will receive a 400 response.
+
+To use another encoder, such as one which encrypts the cursors, point the `pagerfanta.cursor_encoder` alias to your service.
+
+```yaml
+# config/services.yaml
+services:
+ pagerfanta.cursor_encoder:
+ alias: App\Pagination\EncryptedCursorEncoder
+```
+
+## Invalid Cursors
+
+By default, the bundle converts a `Pagerfanta\Exception\InvalidCursorException` into a 400 response. This exception is thrown for cursors which cannot be decoded, fail signature verification, or do not match the sort fields of the adapter. See the [exception strategies](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies) to change this behavior.
+
+## Rendering Cursor Pagers
+
+Cursor pagers are rendered with the `pagerfanta()` Twig function, the same as offset pagers. As cursor pagers can only link to the previous and next pages, they are rendered with a [sequential view](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views).
+
+```twig
+{{ pagerfanta(pager) }}
+```
+
+The URLs are generated for the current route, setting the signed cursor to the `cursor` parameter and removing the `page` parameter. The `pagerfanta_position_url()` Twig function generates the URL for a single position, such as a "Load more" link.
+
+```twig
+{% if pager.hasNextPage() %}
+ Load more
+{% endif %}
+```
+
+## APIs
+
+Cursor pagers are [serialized](/open-source/packages/pagerfantabundle/docs/4.x/serializer#cursor-pagers) with the signed cursors for the previous and next pages, which clients pass back in the `cursor` parameter to request those pages.
diff --git a/docs/default-configuration.md b/docs/default-configuration.md
index 57d769c1..22d9499b 100644
--- a/docs/default-configuration.md
+++ b/docs/default-configuration.md
@@ -5,6 +5,9 @@ babdev_pagerfanta:
# The default Pagerfanta view to use in your application
default_view: default
+ # The view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view), defaults to the "_sequential" view when one exists
+ default_sequential_view: null
+
# The default Twig template to use when using the Twig Pagerfanta view
default_twig_template: '@BabDevPagerfanta/default.html.twig'
@@ -14,4 +17,7 @@ babdev_pagerfanta:
# The exception strategy if the current page is not an allowed value in a paginated list; valid options are "custom" or "to_http_not_found"
not_valid_current_page: to_http_not_found
+
+ # The exception strategy if a cursor cannot be decoded or fails validation (i.e. a tampered cursor); valid options are "custom" or "to_http_bad_request"
+ invalid_cursor: to_http_bad_request
```
diff --git a/docs/generating-paginated-routes.md b/docs/generating-paginated-routes.md
index 329b0ab7..0599c022 100644
--- a/docs/generating-paginated-routes.md
+++ b/docs/generating-paginated-routes.md
@@ -2,23 +2,52 @@
When rendering a Pagerfanta view, a route generator callable is required to generate the URLs for each item in the pagination list. The route generator can be customized for use within your application if you need to adjust the routing logic.
+## Position Route Generators
+
+Position route generators were introduced in PagerfantaBundle 4.7.
+
+Pagerfanta describes the pages a pager links to with [positions](/open-source/packages/pagerfanta/docs/4.x/route-generator#position-route-generators), which support both offset and [cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) pagination.
+
The route generators are defined by two interfaces, with their default implementations noted below:
-- `Pagerfanta\RouteGenerator\RouteGeneratorInterface` - The class type that is used to generate routes
- - `BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator` is used by default, which uses the Symfony Routing component to generate routes
-- `Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface` - A factory service that is used to generate a `RouteGeneratorInterface` at runtime
- - `BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory` is used by default, which uses the `Symfony\Component\HttpFoundation\Request` object to attempt to set the default route name and route parameters, this creates a `RouterAwareRouteGenerator`
+- `Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface` - The class type that is used to generate routes
+ - `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` is used by default, which uses the Symfony Routing component to generate routes
+- `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` - A factory service that is used to generate a `PositionRouteGeneratorInterface` at runtime
+ - `BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory` is used by default, which uses the `Symfony\Component\HttpFoundation\Request` object to attempt to set the default route name and route parameters, this creates a `RouterAwarePositionRouteGenerator`
+
+The `RouterAwarePositionRouteGenerator` generates the URLs as follows:
+
+- For a page position, the page number is set to the page parameter and the cursor parameter is removed
+- For a cursor position, the [signed cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination#signed-cursors) is set to the cursor parameter and the page parameter is removed
-The Twig integration uses a `RouteGeneratorFactoryInterface` instance to create the route generator used when rendering a Pagerfanta view.
+The Twig integration uses the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` service to create the route generator used when rendering a Pagerfanta view.
-The `pagerfanta.route_generator_factory` service is available for use in your application if you need to create a route generator. You may use a compiler pass to change this service to any class meeting the interface requirements.
+The `pagerfanta.position_route_generator_factory` service is available for use in your application if you need to create a route generator, and is aliased to the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`. To use another factory, including with the Twig integration, point this alias to your service.
-## `RouterAwareRouteGenerator` Options
+```yaml
+# config/services.yaml
+services:
+ Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface:
+ alias: App\Pagination\RouteGeneratorFactory
+```
-The following options may be passed through a route generator factory when using the `BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator` in order to customize the generated URLs:
+### Position Route Generator Options
-- `routeName` - Required option (generated by the `RequestAwareRouteGeneratorFactory` if not given), the name of the route to use for the paginated URLs
+The following options may be passed through a route generator factory when using the `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` in order to customize the generated URLs:
+
+- `routeName` - Required option (generated by the `RequestAwarePositionRouteGeneratorFactory` if not given), the name of the route to use for the paginated URLs
- `pageParameter` - Defaults to "`[page]`", specifies the name of the routing parameter to use for the page, note that the page parameter *MUST* be wrapped in brackets (i.e. `[other_page]`) for the route generator to correctly function
+- `cursorParameter` - Defaults to "`[cursor]`", specifies the name of the routing parameter to use for the cursor, note that the cursor parameter *MUST* be wrapped in brackets (i.e. `[after]`) for the route generator to correctly function
- `omitFirstPage` - Defaults to `false`, a boolean value indicating whether the first page should omit the pagination parameter (if true, `?page=1` will not be part of the paginated URL for page 1 of your list)
- `routeParams` - Defaults to an empty array, an array of additional parameters to pass to the router for generating the URL
- `referenceType` - Defaults to `Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_PATH`, allows specifying the `$referenceType` parameter when calling `Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate()`
+
+```twig
+{{ pagerfanta(pager, {'cursorParameter': '[after]'}) }}
+```
+
+## Page Number Based Route Generators
+
+The page number based route generators are deprecated since PagerfantaBundle 4.7 (following their deprecation in Pagerfanta 4.10), and will be removed in PagerfantaBundle 5.0. This includes the
BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator and
BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory classes, the
pagerfanta.route_generator_factory service, and the
Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface alias. Use the
position route generators instead, which accept the same options.
+
+Before PagerfantaBundle 4.7, the route generators were defined by the `Pagerfanta\RouteGenerator\RouteGeneratorInterface` and `Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface` interfaces, which generate routes using page numbers. The `pagerfanta.route_generator_factory` service (a `RequestAwareRouteGeneratorFactory` creating `RouterAwareRouteGenerator` instances) remains available until PagerfantaBundle 5.0, and supports all of the position route generator options except the `cursorParameter` option.
diff --git a/docs/index.md b/docs/index.md
index d4827728..f19fdebc 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -3,6 +3,7 @@
- [Default Configuration](/open-source/packages/pagerfantabundle/docs/4.x/default-configuration)
- [Rendering Pagerfantas](/open-source/packages/pagerfantabundle/docs/4.x/rendering-pagerfantas)
- [Generating Paginated Routes](/open-source/packages/pagerfantabundle/docs/4.x/generating-paginated-routes)
+- [Cursor Pagination](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination)
- [Available Views](/open-source/packages/pagerfantabundle/docs/4.x/views)
- [Adding Views](/open-source/packages/pagerfantabundle/docs/4.x/adding-views)
- [Retrieving Views](/open-source/packages/pagerfantabundle/docs/4.x/retrieving-views)
diff --git a/docs/rendering-pagerfantas.md b/docs/rendering-pagerfantas.md
index d37089cb..764296c4 100644
--- a/docs/rendering-pagerfantas.md
+++ b/docs/rendering-pagerfantas.md
@@ -62,3 +62,9 @@ If you are using a parameter other than `page` for pagination, you can set the p
Note that the page parameter *MUST* be wrapped in brackets (i.e. `[other_page]`) for the route generator to correctly function.
See the [Pagerfanta documentation](/open-source/packages/pagerfanta/docs) for the list of supported options.
+
+## Cursor Pagers
+
+Rendering cursor pagers was introduced in PagerfantaBundle 4.7.
+
+Cursor pagers are rendered with the `pagerfanta()` function in the same way, and are linked with signed cursors in the `cursor` parameter. See the [cursor pagination documentation](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) for details.
diff --git a/docs/serializer.md b/docs/serializer.md
index f4e29abc..36c27990 100644
--- a/docs/serializer.md
+++ b/docs/serializer.md
@@ -56,6 +56,40 @@ Below is an example of how a `Pagerfanta\Pagerfanta` instance is serialized into
}
```
+## Cursor Pagers
+
+Serializing cursor pagers was introduced in PagerfantaBundle 4.7.
+
+[Cursor pagers](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) are serialized with the [signed cursors](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination#signed-cursors) for the previous and next pages, which are null when there is no page in that direction. Clients pass these cursors back in the `cursor` parameter to request the other pages.
+
+The total number of items is only included for pagers which can count their results (implementing `Pagerfanta\CountablePagerInterface`), as counting the results is often expensive and cursor pagers do not need the total.
+
+```json
+{
+ "items": [
+ {
+ "id": 4
+ },
+ {
+ "id": 5
+ },
+ {
+ "id": 6
+ }
+ ],
+ "pagination": {
+ "per_page": 3,
+ "has_previous_page": true,
+ "has_next_page": true,
+ "previous_cursor": "eyJmIjp7ImlkIjo0fSwiZCI6InAifQ.3TwY0stlypgZh7JF3MaG4-U73b75wQ5vAjc7czLn25M",
+ "next_cursor": "eyJmIjp7ImlkIjo2fSwiZCI6Im4ifQ.FQMxPOBSKzLVFP20bHe62gJloQtIqkt4pQdnUc0Xk1s",
+ "total_items": 35
+ }
+}
+```
+
+The JMS Serializer omits null values unless the serialization context enables serializing them, so the previous_cursor and next_cursor keys are omitted when there is no page in that direction unless SerializationContext::setSerializeNull(true) is used.
+
## Serialization Context Configuration
### Preserving Array Keys
diff --git a/docs/views.md b/docs/views.md
index 5ce37033..83a2f685 100644
--- a/docs/views.md
+++ b/docs/views.md
@@ -16,6 +16,26 @@ The below table lists the view names and the corresponding class.
| `twitter_bootstrap4` | `Pagerfanta\View\TwitterBootstrap4View` |
| `twitter_bootstrap5` | `Pagerfanta\View\TwitterBootstrap5View` |
+## Sequential Views
+
+The sequential views were introduced in PagerfantaBundle 4.7.
+
+The views above render numbered page links, so they can only render offset pagers. The [sequential views](/open-source/packages/pagerfanta/docs/4.x/views#sequential-views) render only the links to the previous and next pages, so they can render any pager, including [cursor pagers](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination).
+
+A sequential view is available for each of the default views, all using the `Pagerfanta\View\SequentialView` class.
+
+| View Name | Template Class Name |
+|---------------------------------|------------------------------------------------------|
+| `default_sequential` | `Pagerfanta\View\Template\DefaultTemplate` |
+| `foundation6_sequential` | `Pagerfanta\View\Template\Foundation6Template` |
+| `semantic_ui_sequential` | `Pagerfanta\View\Template\SemanticUiTemplate` |
+| `twitter_bootstrap_sequential` | `Pagerfanta\View\Template\TwitterBootstrapTemplate` |
+| `twitter_bootstrap3_sequential` | `Pagerfanta\View\Template\TwitterBootstrap3Template` |
+| `twitter_bootstrap4_sequential` | `Pagerfanta\View\Template\TwitterBootstrap4Template` |
+| `twitter_bootstrap5_sequential` | `Pagerfanta\View\Template\TwitterBootstrap5Template` |
+
+When the `pagerfanta()` Twig function is given a pager which its view cannot render, the pager is rendered with the [default sequential view](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#default-sequential-view).
+
## Twig View
This bundle provides a Pagerfanta view which renders a Twig template. If you have not already, you will need to install the `pagerfanta/twig` package.
@@ -35,6 +55,8 @@ The below table lists the available templates and the CSS framework they corresp
The labels of the "Previous" and "Next" buttons are localizable in the Twig templates.
+The Twig view can render any pager. Offset pagers are rendered with numbered page links, and all other pagers (such as cursor pagers) are rendered with only the previous and next links using the same templates.
+
See the [Pagerfanta documentation](/open-source/packages/pagerfanta/docs/views) for more information about building a Twig template.
## Default View CSS
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 37391ce6..4aaa3026 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1,5 +1,11 @@
parameters:
ignoreErrors:
+ -
+ message: '#^Cannot access offset ''invalid_cursor'' on mixed\.$#'
+ identifier: offsetAccess.nonOffsetAccessible
+ count: 1
+ path: src/DependencyInjection/BabDevPagerfantaExtension.php
+
-
message: '#^Cannot access offset ''not_valid_current_page'' on mixed\.$#'
identifier: offsetAccess.nonOffsetAccessible
@@ -25,16 +31,16 @@ parameters:
path: src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php
-
- message: '#^Parameter \#2 \.\.\.\$arrays of function array_merge expects array, mixed given\.$#'
+ message: '#^Parameter \#3 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#'
identifier: argument.type
- count: 2
+ count: 1
path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php
-
- message: '#^Parameter \#3 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, non\-empty\-array\ given\.$#'
+ message: '#^Parameter \#4 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwarePositionRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, cursorParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#'
identifier: argument.type
count: 1
- path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php
+ path: src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php
-
message: '#^Default value of the parameter \#3 \$options \(array\{\}\) of method BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator\:\:__construct\(\) is incompatible with type array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}\.$#'
diff --git a/src/Cursor/SignedCursorEncoder.php b/src/Cursor/SignedCursorEncoder.php
new file mode 100644
index 00000000..93770ce6
--- /dev/null
+++ b/src/Cursor/SignedCursorEncoder.php
@@ -0,0 +1,68 @@
+encoder->encode($cursor);
+
+ return $payload.self::SEPARATOR.$this->sign($payload);
+ }
+
+ public function decode(string $encoded): Cursor
+ {
+ // The signature never contains the separator, so the payload may contain it
+ $position = strrpos($encoded, self::SEPARATOR);
+
+ if (false === $position) {
+ throw new InvalidCursorException('The cursor is not signed.');
+ }
+
+ $payload = substr($encoded, 0, $position);
+ $signature = substr($encoded, $position + 1);
+
+ if (!hash_equals($this->sign($payload), $signature)) {
+ throw new InvalidCursorException('The cursor signature is not valid.');
+ }
+
+ return $this->encoder->decode($payload);
+ }
+
+ private function sign(string $payload): string
+ {
+ return rtrim(strtr(base64_encode(hash_hmac('sha256', self::CONTEXT.'|'.$payload, $this->secret, true)), '+/', '-_'), '=');
+ }
+}
diff --git a/src/DependencyInjection/BabDevPagerfantaExtension.php b/src/DependencyInjection/BabDevPagerfantaExtension.php
index 504075ce..48949963 100644
--- a/src/DependencyInjection/BabDevPagerfantaExtension.php
+++ b/src/DependencyInjection/BabDevPagerfantaExtension.php
@@ -2,6 +2,7 @@
namespace BabDev\PagerfantaBundle\DependencyInjection;
+use BabDev\PagerfantaBundle\EventListener\ConvertInvalidCursorToBadRequestListener;
use BabDev\PagerfantaBundle\EventListener\ConvertNotValidCurrentPageToNotFoundListener;
use BabDev\PagerfantaBundle\EventListener\ConvertNotValidMaxPerPageToNotFoundListener;
use BabDev\PagerfantaBundle\Serializer\Normalizer\LegacyPagerfantaNormalizer;
@@ -37,7 +38,8 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
if (ContainerBuilder::willBeAvailable('pagerfanta/twig', PagerfantaExtension::class, ['babdev/pagerfanta-bundle'])) {
$container->getDefinition('pagerfanta.twig_runtime')
- ->replaceArgument(0, $mergedConfig['default_view']);
+ ->replaceArgument(0, $mergedConfig['default_view'])
+ ->replaceArgument(3, $mergedConfig['default_sequential_view']);
$container->getDefinition('pagerfanta.view.twig')
->replaceArgument(1, $mergedConfig['default_twig_template']);
@@ -89,6 +91,18 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
],
);
}
+
+ if (Configuration::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST === $mergedConfig['exceptions_strategy']['invalid_cursor']) {
+ $container->register('pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ConvertInvalidCursorToBadRequestListener::class)
+ ->addTag(
+ 'kernel.event_listener',
+ [
+ 'event' => KernelEvents::EXCEPTION,
+ 'method' => 'onKernelException',
+ 'priority' => 512,
+ ],
+ );
+ }
}
public function prepend(ContainerBuilder $container): void
diff --git a/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php b/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php
index cac514f9..32fc8c73 100644
--- a/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php
+++ b/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php
@@ -19,6 +19,8 @@ public function process(ContainerBuilder $container): void
return;
}
+ $this->configureDefaultSequentialView($container);
+
$definition = $container->getDefinition('pagerfanta.view_factory');
if (ContainerBackedImmutableViewFactory::class === $definition->getClass()) {
@@ -47,4 +49,38 @@ public function process(ContainerBuilder $container): void
$definition->addMethodCall('set', [$alias, new Reference($serviceId)]);
}
}
+
+ /**
+ * Defaults the sequential view of the Twig runtime to the sequential variant of the default view, when one exists.
+ */
+ private function configureDefaultSequentialView(ContainerBuilder $container): void
+ {
+ if (!$container->hasDefinition('pagerfanta.twig_runtime')) {
+ return;
+ }
+
+ $runtime = $container->getDefinition('pagerfanta.twig_runtime');
+
+ if (null !== $runtime->getArgument(3)) {
+ return;
+ }
+
+ $defaultView = $runtime->getArgument(0);
+
+ if (!\is_string($defaultView)) {
+ return;
+ }
+
+ $sequentialView = \sprintf('%s_sequential', $defaultView);
+
+ foreach ($container->findTaggedServiceIds('pagerfanta.view') as $serviceId => $arguments) {
+ $attributes = $arguments[0] ?? [];
+
+ if ($sequentialView === (\is_array($attributes) && isset($attributes['alias']) ? $attributes['alias'] : $serviceId)) {
+ $runtime->replaceArgument(3, $sequentialView);
+
+ return;
+ }
+ }
+ }
}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index ee62d351..c3e2ad71 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -8,6 +8,7 @@
final class Configuration implements ConfigurationInterface
{
public const EXCEPTION_STRATEGY_CUSTOM = 'custom';
+ public const EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST = 'to_http_bad_request';
public const EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND = 'to_http_not_found';
/**
@@ -21,6 +22,10 @@ public function getConfigTreeBuilder(): TreeBuilder
$treeBuilder->getRootNode()
->children()
->scalarNode('default_view')->defaultValue('default')->end()
+ ->scalarNode('default_sequential_view')
+ ->info('The view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view), defaults to the "_sequential" view when one exists')
+ ->defaultNull()
+ ->end()
->scalarNode('default_twig_template')->defaultValue('@BabDevPagerfanta/default.html.twig')->end()
->arrayNode('exceptions_strategy')
->addDefaultsIfNotSet()
@@ -33,6 +38,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue(self::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND)
->values([self::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND, self::EXCEPTION_STRATEGY_CUSTOM])
->end()
+ ->enumNode('invalid_cursor')
+ ->defaultValue(self::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST)
+ ->values([self::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST, self::EXCEPTION_STRATEGY_CUSTOM])
+ ->end()
->end()
->end()
->end();
diff --git a/src/EventListener/ConvertInvalidCursorToBadRequestListener.php b/src/EventListener/ConvertInvalidCursorToBadRequestListener.php
new file mode 100644
index 00000000..bddd16f6
--- /dev/null
+++ b/src/EventListener/ConvertInvalidCursorToBadRequestListener.php
@@ -0,0 +1,19 @@
+getThrowable();
+
+ if ($throwable instanceof InvalidCursorException) {
+ $event->setThrowable(new BadRequestHttpException('Invalid Cursor', $throwable));
+ }
+ }
+}
diff --git a/src/Position/PositionResolver.php b/src/Position/PositionResolver.php
new file mode 100644
index 00000000..faa71c0e
--- /dev/null
+++ b/src/Position/PositionResolver.php
@@ -0,0 +1,110 @@
+resolveCursorPosition($request, $options) ?? $this->resolvePagePosition($request, $options);
+ }
+
+ /**
+ * Resolves the position for an offset pager, the cursor parameter is ignored.
+ *
+ * @phpstan-param PositionResolverOptions $options
+ *
+ * @throws NotValidCurrentPageException if the page is not a positive integer
+ */
+ public function resolvePagePosition(Request $request, array $options = []): ?PagePosition
+ {
+ $parameter = $options['pageParameter'] ?? '[page]';
+ $page = $this->readParameter($request, $parameter);
+
+ if (null === $page || '' === $page) {
+ return null;
+ }
+
+ if (\is_string($page) && ctype_digit($page)) {
+ $page = (int) $page;
+ }
+
+ if (!\is_int($page)) {
+ throw new NotValidCurrentPageException(\sprintf('The "%s" parameter must be a positive integer.', $parameter));
+ }
+
+ if ($page < 1) {
+ throw new LessThan1CurrentPageException();
+ }
+
+ return new PagePosition($page);
+ }
+
+ /**
+ * Resolves the position for a cursor pager, the page parameter is ignored.
+ *
+ * @phpstan-param PositionResolverOptions $options
+ *
+ * @throws InvalidCursorException if the cursor is not valid
+ */
+ public function resolveCursorPosition(Request $request, array $options = []): ?CursorPosition
+ {
+ $parameter = $options['cursorParameter'] ?? '[cursor]';
+ $cursor = $this->readParameter($request, $parameter);
+
+ if (null === $cursor || '' === $cursor) {
+ return null;
+ }
+
+ if (!\is_string($cursor)) {
+ throw new InvalidCursorException(\sprintf('The "%s" parameter must be a string.', $parameter));
+ }
+
+ return new CursorPosition($this->cursorEncoder->decode($cursor));
+ }
+
+ private function readParameter(Request $request, string $parameter): mixed
+ {
+ $routeParams = $request->attributes->get('_route_params', []);
+
+ $parameters = array_merge($request->query->all(), \is_array($routeParams) ? $routeParams : []);
+ $propertyPath = new PropertyPath($parameter);
+
+ if (!$this->propertyAccessor->isReadable($parameters, $propertyPath)) {
+ return null;
+ }
+
+ return $this->propertyAccessor->getValue($parameters, $propertyPath);
+ }
+}
diff --git a/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php b/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php
new file mode 100644
index 00000000..c09815b4
--- /dev/null
+++ b/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php
@@ -0,0 +1,36 @@
+router,
+ $this->propertyAccessor,
+ $this->cursorEncoder,
+ $this->resolveOptions($options),
+ );
+ }
+}
diff --git a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php
index 7ed11ffd..4276376a 100644
--- a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php
+++ b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php
@@ -2,62 +2,48 @@
namespace BabDev\PagerfantaBundle\RouteGenerator;
-use Pagerfanta\Exception\RuntimeException;
+use Pagerfanta\Cursor\Base64JsonCursorEncoder;
+use Pagerfanta\Cursor\CursorEncoderInterface;
+use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
+use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface;
use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface;
use Pagerfanta\RouteGenerator\RouteGeneratorInterface;
-use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-final class RequestAwareRouteGeneratorFactory implements RouteGeneratorFactoryInterface
+/**
+ * Creates route generators for the current route, unless another route is set in the options.
+ *
+ * @deprecated since PagerfantaBundle 4.7, use the {@see RequestAwarePositionRouteGeneratorFactory} instead
+ */
+final class RequestAwareRouteGeneratorFactory implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface
{
+ use ResolvesRouteGeneratorOptions;
+
+ /**
+ * @param CursorEncoderInterface $cursorEncoder The encoder for the cursors in the generated URLs, the bundle configures an encoder which signs the cursors
+ */
public function __construct(
private readonly UrlGeneratorInterface $router,
private readonly RequestStack $requestStack,
- private readonly PropertyAccessorInterface $propertyAccessor
- ) {}
+ private readonly PropertyAccessorInterface $propertyAccessor,
+ private readonly CursorEncoderInterface $cursorEncoder = new Base64JsonCursorEncoder(),
+ ) {
+ trigger_deprecation('babdev/pagerfanta-bundle', '4.7', 'The "%s" class is deprecated, use the "%s" class instead.', self::class, RequestAwarePositionRouteGeneratorFactory::class);
+ }
public function create(array $options = []): RouteGeneratorInterface
{
- $options = array_replace(
- [
- 'routeName' => null,
- 'routeParams' => [],
- 'pageParameter' => '[page]',
- 'omitFirstPage' => false,
- ],
- $options
- );
-
- if (null === $options['routeName']) {
- $request = $this->getRequest();
-
- if (null === $request) {
- throw new RuntimeException('The request aware route generator can not be used when there is not an active request.');
- }
-
- if (null !== $this->requestStack->getParentRequest()) {
- throw new RuntimeException('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.');
- }
-
- $options['routeName'] = $request->attributes->get('_route');
-
- // Make sure we read the route parameters from the passed option array
- $defaultRouteParams = array_merge($request->query->all(), $request->attributes->get('_route_params', []));
-
- $options['routeParams'] = array_merge($defaultRouteParams, $options['routeParams']);
- }
-
return new RouterAwareRouteGenerator(
$this->router,
$this->propertyAccessor,
- $options,
+ $this->resolveOptions($options),
);
}
- private function getRequest(): ?Request
+ public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface
{
- return $this->requestStack->getCurrentRequest();
+ return (new RequestAwarePositionRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor, $this->cursorEncoder))->createPositionRouteGenerator($options);
}
}
diff --git a/src/RouteGenerator/ResolvesRouteGeneratorOptions.php b/src/RouteGenerator/ResolvesRouteGeneratorOptions.php
new file mode 100644
index 00000000..b5659e0d
--- /dev/null
+++ b/src/RouteGenerator/ResolvesRouteGeneratorOptions.php
@@ -0,0 +1,66 @@
+ $options
+ *
+ * @return array
+ *
+ * @throws InvalidArgumentException if the route parameters option is not an array
+ * @throws RuntimeException if the route cannot be resolved from the current request
+ */
+ private function resolveOptions(array $options): array
+ {
+ $options = array_replace(
+ [
+ 'routeName' => null,
+ 'routeParams' => [],
+ 'pageParameter' => '[page]',
+ 'cursorParameter' => '[cursor]',
+ 'omitFirstPage' => false,
+ ],
+ $options
+ );
+
+ if (null === $options['routeName']) {
+ $request = $this->requestStack->getCurrentRequest();
+
+ if (null === $request) {
+ throw new RuntimeException('The request aware route generator can not be used when there is not an active request.');
+ }
+
+ if (null !== $this->requestStack->getParentRequest()) {
+ throw new RuntimeException('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.');
+ }
+
+ $options['routeName'] = $request->attributes->get('_route');
+
+ if (!\is_array($options['routeParams'])) {
+ throw new InvalidArgumentException(\sprintf('The "routeParams" option must be an array, "%s" given.', get_debug_type($options['routeParams'])));
+ }
+
+ $requestRouteParams = $request->attributes->get('_route_params', []);
+
+ // Make sure we read the route parameters from the passed option array
+ $defaultRouteParams = array_merge($request->query->all(), \is_array($requestRouteParams) ? $requestRouteParams : []);
+
+ $options['routeParams'] = array_merge($defaultRouteParams, $options['routeParams']);
+ }
+
+ return $options;
+ }
+}
diff --git a/src/RouteGenerator/RouterAwarePositionRouteGenerator.php b/src/RouteGenerator/RouterAwarePositionRouteGenerator.php
new file mode 100644
index 00000000..a5a8ae05
--- /dev/null
+++ b/src/RouteGenerator/RouterAwarePositionRouteGenerator.php
@@ -0,0 +1,64 @@
+, referenceType?: UrlGeneratorInterface::*}
+ */
+final class RouterAwarePositionRouteGenerator implements PositionRouteGeneratorInterface
+{
+ /**
+ * @phpstan-param RouteGeneratorOptions $options
+ *
+ * @throws InvalidArgumentException if the route name is not set in the options
+ */
+ public function __construct(
+ private readonly UrlGeneratorInterface $router,
+ private readonly PropertyAccessorInterface $propertyAccessor,
+ private readonly CursorEncoderInterface $cursorEncoder,
+ private readonly array $options,
+ ) {
+ if (!isset($options['routeName'])) {
+ throw new InvalidArgumentException(\sprintf('The "%s" class options requires a "routeName" parameter to be set.', self::class));
+ }
+ }
+
+ /**
+ * @throws InvalidArgumentException if the position is not supported
+ */
+ public function __invoke(Position $position): string
+ {
+ $pagePropertyPath = new PropertyPath($this->options['pageParameter'] ?? '[page]');
+ $cursorPropertyPath = new PropertyPath($this->options['cursorParameter'] ?? '[cursor]');
+ $routeParams = $this->options['routeParams'] ?? [];
+
+ if ($position instanceof PagePosition) {
+ $omitFirstPage = $this->options['omitFirstPage'] ?? false;
+
+ $this->propertyAccessor->setValue($routeParams, $pagePropertyPath, $omitFirstPage && 1 === $position->page ? null : $position->page);
+ $this->propertyAccessor->setValue($routeParams, $cursorPropertyPath, null);
+ } elseif ($position instanceof CursorPosition) {
+ $this->propertyAccessor->setValue($routeParams, $cursorPropertyPath, $this->cursorEncoder->encode($position->cursor));
+ $this->propertyAccessor->setValue($routeParams, $pagePropertyPath, null);
+ } else {
+ throw new InvalidArgumentException(\sprintf('The "%s" route generator does not support "%s" positions.', self::class, get_debug_type($position)));
+ }
+
+ return $this->router->generate($this->options['routeName'], $routeParams, $this->options['referenceType'] ?? UrlGeneratorInterface::ABSOLUTE_PATH);
+ }
+}
diff --git a/src/RouteGenerator/RouterAwareRouteGenerator.php b/src/RouteGenerator/RouterAwareRouteGenerator.php
index a72ae2fe..a9560ff4 100644
--- a/src/RouteGenerator/RouterAwareRouteGenerator.php
+++ b/src/RouteGenerator/RouterAwareRouteGenerator.php
@@ -9,6 +9,8 @@
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
+ * @deprecated since PagerfantaBundle 4.7, use the {@see RouterAwarePositionRouteGenerator} instead
+ *
* @phpstan-type RouteGeneratorOptions array{routeName: non-empty-string, pageParameter?: non-empty-string, omitFirstPage?: bool, routeParams?: array, referenceType?: UrlGeneratorInterface::*}
*/
final class RouterAwareRouteGenerator implements RouteGeneratorInterface
@@ -21,6 +23,8 @@ public function __construct(
private readonly PropertyAccessorInterface $propertyAccessor,
private readonly array $options = [],
) {
+ trigger_deprecation('babdev/pagerfanta-bundle', '4.7', 'The "%s" class is deprecated, use the "%s" class instead.', self::class, RouterAwarePositionRouteGenerator::class);
+
// Check missing options
if (!isset($options['routeName'])) {
throw new InvalidArgumentException(\sprintf('The "%s" class options requires a "routeName" parameter to be set.', self::class));
diff --git a/src/Serializer/Handler/CursorPagerHandler.php b/src/Serializer/Handler/CursorPagerHandler.php
new file mode 100644
index 00000000..a7c62930
--- /dev/null
+++ b/src/Serializer/Handler/CursorPagerHandler.php
@@ -0,0 +1,94 @@
+ GraphNavigatorInterface::DIRECTION_SERIALIZATION,
+ 'format' => 'json',
+ 'type' => $type,
+ 'method' => 'serializeToJson',
+ ];
+ }
+
+ return $methods;
+ }
+
+ /**
+ * @param CursorPagerInterface $pager
+ * @param array{name: string, params: array} $type
+ *
+ * @return array|\ArrayObject|null
+ *
+ * @throws LogicException when the handler is not called in an expected context
+ */
+ public function serializeToJson(JsonSerializationVisitor $visitor, CursorPagerInterface $pager, array $type, SerializationContext $context)
+ {
+ $items = $pager->getCurrentPageResults();
+
+ if ($context->hasAttribute(self::PRESERVE_KEYS_KEY)) {
+ $preserveKeys = $context->getAttribute(self::PRESERVE_KEYS_KEY);
+
+ if (!\is_bool($preserveKeys) && null !== $preserveKeys) {
+ throw new LogicException(\sprintf('The "%s" context key must be a boolean value or null, "%s" given.', self::PRESERVE_KEYS_KEY, get_debug_type($preserveKeys)));
+ }
+
+ if (null !== $preserveKeys) {
+ // When requiring PHP 8.2, this `is_array()` check can be removed
+ if (\is_array($items)) {
+ $items = new \ArrayIterator($items);
+ }
+
+ $items = iterator_to_array($items, $preserveKeys);
+ }
+ }
+
+ $pagination = [
+ 'per_page' => $pager->getMaxPerPage(),
+ 'has_previous_page' => $pager->hasPreviousPage(),
+ 'has_next_page' => $pager->hasNextPage(),
+ 'previous_cursor' => $pager->hasPreviousPage() ? $this->cursorEncoder->encode($pager->getPreviousPosition()->cursor) : null,
+ 'next_cursor' => $pager->hasNextPage() ? $this->cursorEncoder->encode($pager->getNextPosition()->cursor) : null,
+ ];
+
+ if ($pager instanceof CountablePagerInterface) {
+ $pagination['total_items'] = $pager->getNbResults();
+ }
+
+ return $visitor->visitArray(
+ [
+ 'items' => $items,
+ 'pagination' => $pagination,
+ ],
+ $type,
+ );
+ }
+}
diff --git a/src/Serializer/Normalizer/CursorPagerNormalizer.php b/src/Serializer/Normalizer/CursorPagerNormalizer.php
new file mode 100644
index 00000000..0f625ac0
--- /dev/null
+++ b/src/Serializer/Normalizer/CursorPagerNormalizer.php
@@ -0,0 +1,94 @@
+getCurrentPageResults();
+
+ if (\array_key_exists(self::PRESERVE_KEYS_KEY, $context)) {
+ $preserveKeys = $context[self::PRESERVE_KEYS_KEY];
+
+ if (!\is_bool($preserveKeys) && null !== $preserveKeys) {
+ throw new LogicException(\sprintf('The "%s" context key must be a boolean value or null, "%s" given.', self::PRESERVE_KEYS_KEY, get_debug_type($preserveKeys)));
+ }
+
+ if (null !== $preserveKeys) {
+ // When requiring PHP 8.2, this `is_array()` check can be removed
+ if (\is_array($items)) {
+ $items = new \ArrayIterator($items);
+ }
+
+ $items = iterator_to_array($items, $preserveKeys);
+ }
+ }
+
+ $pagination = [
+ 'per_page' => $object->getMaxPerPage(),
+ 'has_previous_page' => $object->hasPreviousPage(),
+ 'has_next_page' => $object->hasNextPage(),
+ 'previous_cursor' => $object->hasPreviousPage() ? $this->cursorEncoder->encode($object->getPreviousPosition()->cursor) : null,
+ 'next_cursor' => $object->hasNextPage() ? $this->cursorEncoder->encode($object->getNextPosition()->cursor) : null,
+ ];
+
+ if ($object instanceof CountablePagerInterface) {
+ $pagination['total_items'] = $object->getNbResults();
+ }
+
+ return [
+ 'items' => $this->normalizer->normalize($items, $format, $context),
+ 'pagination' => $pagination,
+ ];
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof CursorPagerInterface;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ CursorPagerInterface::class => true,
+ CursorPagerfanta::class => true,
+ CountableCursorPagerfanta::class => true,
+ ];
+ }
+}
diff --git a/src/Twig/UndefinedCallableHandler.php b/src/Twig/UndefinedCallableHandler.php
index 9d409893..e0228ccd 100644
--- a/src/Twig/UndefinedCallableHandler.php
+++ b/src/Twig/UndefinedCallableHandler.php
@@ -12,6 +12,7 @@ final class UndefinedCallableHandler
private const SUPPORTED_FUNCTIONS = [
'pagerfanta',
'pagerfanta_page_url',
+ 'pagerfanta_position_url',
];
/**
diff --git a/tests/CapturesDeprecations.php b/tests/CapturesDeprecations.php
new file mode 100644
index 00000000..7e6d2087
--- /dev/null
+++ b/tests/CapturesDeprecations.php
@@ -0,0 +1,35 @@
+
+ */
+ private function captureDeprecations(callable $callback): array
+ {
+ $deprecations = [];
+
+ set_error_handler(
+ static function (int $level, string $message) use (&$deprecations): bool {
+ $deprecations[] = $message;
+
+ return true;
+ },
+ \E_USER_DEPRECATED
+ );
+
+ try {
+ $callback();
+ } finally {
+ restore_error_handler();
+ }
+
+ return $deprecations;
+ }
+}
diff --git a/tests/Cursor/SignedCursorEncoderTest.php b/tests/Cursor/SignedCursorEncoderTest.php
new file mode 100644
index 00000000..e38c8d56
--- /dev/null
+++ b/tests/Cursor/SignedCursorEncoderTest.php
@@ -0,0 +1,120 @@
+ '2026-09-28 12:00:00', 'p.id' => 42], Direction::Previous);
+
+ $encoder = $this->createEncoder();
+
+ self::assertEquals($cursor, $encoder->decode($encoder->encode($cursor)));
+ }
+
+ public function testTheEncodedCursorIsTheDecoratedEncodingWithASignature(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+
+ $encoded = $this->createEncoder()->encode($cursor);
+
+ self::assertStringStartsWith((new Base64JsonCursorEncoder())->encode($cursor).'.', $encoded);
+ self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/', $encoded, 'The encoded cursor is URL-safe');
+ }
+
+ public function testThePayloadMayContainTheSeparator(): void
+ {
+ $decorated = new class implements CursorEncoderInterface {
+ public function encode(Cursor $cursor): string
+ {
+ return 'with.separators.'.$cursor->fields['id'];
+ }
+
+ public function decode(string $encoded): Cursor
+ {
+ return new Cursor(['id' => (int) substr($encoded, \strlen('with.separators.'))]);
+ }
+ };
+
+ $encoder = new SignedCursorEncoder($decorated, 'secret');
+
+ self::assertEquals(new Cursor(['id' => 42]), $encoder->decode($encoder->encode(new Cursor(['id' => 42]))));
+ }
+
+ public static function dataTamperedCursors(): \Generator
+ {
+ $encoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret');
+ $encoded = $encoder->encode(new Cursor(['p.id' => 42]));
+ [$payload, $signature] = explode('.', $encoded);
+
+ $tamperedPayload = (new Base64JsonCursorEncoder())->encode(new Cursor(['p.id' => 43]));
+
+ yield 'unsigned cursor' => [$payload];
+ yield 'empty signature' => [$payload.'.'];
+ yield 'altered payload' => [$tamperedPayload.'.'.$signature];
+ yield 'altered signature' => [$payload.'.'.strrev($signature)];
+ yield 'truncated signature' => [substr($encoded, 0, -1)];
+ yield 'signed with another secret' => [(new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'another secret'))->encode(new Cursor(['p.id' => 42]))];
+ yield 'empty string' => [''];
+ }
+
+ /**
+ * @dataProvider dataTamperedCursors
+ */
+ #[DataProvider('dataTamperedCursors')]
+ public function testATamperedCursorIsRejectedBeforeItIsDecoded(string $encoded): void
+ {
+ $decorated = $this->createMock(CursorEncoderInterface::class);
+ $decorated->method('encode')->willReturnCallback(static fn (Cursor $cursor): string => (new Base64JsonCursorEncoder())->encode($cursor));
+ $decorated->expects(self::never())->method('decode');
+
+ $this->expectException(InvalidCursorException::class);
+
+ (new SignedCursorEncoder($decorated, 'secret'))->decode($encoded);
+ }
+
+ public function testAnInvalidCursorWithAValidSignatureIsRejectedByTheDecoratedEncoder(): void
+ {
+ // The decorated encoder produces a payload which it cannot decode, so the payload is correctly signed but not a valid cursor
+ $decorated = new class implements CursorEncoderInterface {
+ public function encode(Cursor $cursor): string
+ {
+ return 'not-a-cursor';
+ }
+
+ public function decode(string $encoded): Cursor
+ {
+ return (new Base64JsonCursorEncoder())->decode($encoded);
+ }
+ };
+
+ $encoder = new SignedCursorEncoder($decorated, 'secret');
+
+ $this->expectException(InvalidCursorException::class);
+
+ $encoder->decode($encoder->encode(new Cursor(['id' => 1])));
+ }
+
+ public function testTheSecretMustNotBeEmpty(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->createEncoder('');
+ }
+}
diff --git a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php
index 057eeb19..9b233694 100644
--- a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php
+++ b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php
@@ -5,12 +5,17 @@
use BabDev\PagerfantaBundle\BabDevPagerfantaBundle;
use BabDev\PagerfantaBundle\DependencyInjection\BabDevPagerfantaExtension;
use BabDev\PagerfantaBundle\DependencyInjection\Configuration;
+use BabDev\PagerfantaBundle\Position\PositionResolver;
use Composer\InstalledVersions;
use JMS\SerializerBundle\JMSSerializerBundle;
use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase;
use Matthias\SymfonyDependencyInjectionTest\PhpUnit\DefinitionDecoratesConstraint;
+use Pagerfanta\Cursor\CursorEncoderInterface;
+use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
+use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface;
use Pagerfanta\Twig\Extension\PagerfantaExtension;
use Pagerfanta\View\ViewFactoryInterface;
+use Symfony\Component\DependencyInjection\Reference;
use Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\HttpKernel\KernelEvents;
@@ -29,10 +34,12 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsNot
$this->load();
$this->assertContainerBuilderHasAlias(ViewFactoryInterface::class, 'pagerfanta.view_factory');
+ $this->assertCursorAndRouteGenerationServicesAreRegistered();
$listeners = [
'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found',
'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found',
+ 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request',
];
foreach ($listeners as $listener) {
@@ -114,6 +121,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsIns
$listeners = [
'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found',
'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found',
+ 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request',
];
foreach ($listeners as $listener) {
@@ -138,6 +146,10 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsIns
$this->assertContainerBuilderHasService($twigService);
}
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 0, 'default');
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 2, new Reference(PositionRouteGeneratorFactoryInterface::class));
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, null);
+
$twigConfig = $this->container->getExtensionConfig('twig');
self::assertArrayHasKey(0, $twigConfig);
@@ -178,6 +190,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenJMSSerializerBu
$listeners = [
'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found',
'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found',
+ 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request',
];
foreach ($listeners as $listener) {
@@ -194,6 +207,9 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenJMSSerializerBu
$this->assertContainerBuilderHasService('pagerfanta.serializer.handler');
$this->assertContainerBuilderHasService('pagerfanta.serializer.normalizer');
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.serializer.cursor_handler', 0, new Reference('pagerfanta.cursor_encoder'));
+ $this->assertContainerBuilderHasServiceDefinitionWithTag('pagerfanta.serializer.cursor_handler', 'jms_serializer.subscribing_handler');
}
public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionStrategies(): void
@@ -209,6 +225,7 @@ public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionSt
'exceptions_strategy' => [
'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
+ 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
],
];
@@ -219,6 +236,7 @@ public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionSt
$listeners = [
'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found',
'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found',
+ 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request',
];
foreach ($listeners as $listener) {
@@ -232,4 +250,50 @@ protected function getContainerExtensions(): array
new BabDevPagerfantaExtension(),
];
}
+
+ public function testTheDefaultSequentialViewIsGivenToTheTwigRuntime(): void
+ {
+ if (!class_exists(PagerfantaExtension::class)) {
+ self::markTestSkipped('Test requires Twig');
+ }
+
+ $this->container->setParameter(
+ 'kernel.bundles',
+ [
+ 'BabDevPagerfantaBundle' => BabDevPagerfantaBundle::class,
+ 'TwigBundle' => TwigBundle::class,
+ ],
+ );
+
+ $this->load(['default_sequential_view' => 'twitter_bootstrap5_sequential']);
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'twitter_bootstrap5_sequential');
+ }
+
+ private function assertCursorAndRouteGenerationServicesAreRegistered(): void
+ {
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.cursor_encoder.signed', 0, new Reference('pagerfanta.cursor_encoder.base64_json'));
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.cursor_encoder.signed', 1, '%kernel.secret%');
+ $this->assertContainerBuilderHasAlias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed');
+ $this->assertContainerBuilderHasAlias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder');
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.serializer.cursor_normalizer', 0, new Reference('pagerfanta.cursor_encoder'));
+ $this->assertContainerBuilderHasServiceDefinitionWithTag('pagerfanta.serializer.cursor_normalizer', 'serializer.normalizer');
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_resolver', 1, new Reference('pagerfanta.cursor_encoder'));
+ $this->assertContainerBuilderHasAlias(PositionResolver::class, 'pagerfanta.position_resolver');
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder'));
+ $this->assertContainerBuilderHasAlias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.position_route_generator_factory');
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder'));
+ self::assertTrue($this->container->getDefinition('pagerfanta.route_generator_factory')->isDeprecated());
+ $this->assertContainerBuilderHasAlias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory');
+ self::assertTrue($this->container->getAlias(RouteGeneratorFactoryInterface::class)->isDeprecated());
+
+ foreach (['default', 'foundation6', 'semantic_ui', 'twitter_bootstrap', 'twitter_bootstrap3', 'twitter_bootstrap4', 'twitter_bootstrap5'] as $name) {
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument(\sprintf('pagerfanta.view.%s_sequential', $name), 1, \sprintf('%s_sequential', $name));
+ $this->assertContainerBuilderHasServiceDefinitionWithTag(\sprintf('pagerfanta.view.%s_sequential', $name), 'pagerfanta.view', ['alias' => \sprintf('%s_sequential', $name)]);
+ }
+ }
}
diff --git a/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php b/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php
index adbd2a5b..ac557f92 100644
--- a/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php
+++ b/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php
@@ -5,7 +5,9 @@
use BabDev\PagerfantaBundle\DependencyInjection\CompilerPass\RegisterPagerfantaViewsPass;
use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory;
use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase;
+use Pagerfanta\Twig\Extension\PagerfantaRuntime;
use Pagerfanta\View\DefaultView;
+use Pagerfanta\View\SequentialView;
use Pagerfanta\View\ViewFactory;
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
@@ -54,6 +56,43 @@ public function testViewsAreAddedToTheContainerBackedViewFactory(): void
);
}
+ public function testTheDefaultSequentialViewIsTheSequentialVariantOfTheDefaultView(): void
+ {
+ $this->registerService('pagerfanta.view_factory', ViewFactory::class);
+ $this->registerService('pagerfanta.view.twitter_bootstrap5_sequential', SequentialView::class)
+ ->addTag('pagerfanta.view', ['alias' => 'twitter_bootstrap5_sequential']);
+ $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class)
+ ->setArguments(['twitter_bootstrap5', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), null]);
+
+ $this->compile();
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'twitter_bootstrap5_sequential');
+ }
+
+ public function testTheDefaultSequentialViewIsNotSetWhenTheDefaultViewHasNoSequentialVariant(): void
+ {
+ $this->registerService('pagerfanta.view_factory', ViewFactory::class);
+ $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class)
+ ->setArguments(['twig', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), null]);
+
+ $this->compile();
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, null);
+ }
+
+ public function testAConfiguredDefaultSequentialViewIsKept(): void
+ {
+ $this->registerService('pagerfanta.view_factory', ViewFactory::class);
+ $this->registerService('pagerfanta.view.default_sequential', SequentialView::class)
+ ->addTag('pagerfanta.view', ['alias' => 'default_sequential']);
+ $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class)
+ ->setArguments(['default', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), 'custom_sequential']);
+
+ $this->compile();
+
+ $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'custom_sequential');
+ }
+
protected function registerCompilerPass(ContainerBuilder $container): void
{
$container->addCompilerPass(new RegisterPagerfantaViewsPass());
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index e0396d06..e030b8c8 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -29,6 +29,20 @@ public function testConfigWithCustomDefaultView(): void
);
}
+ public function testConfigWithCustomDefaultSequentialView(): void
+ {
+ $extraConfig = [
+ 'default_sequential_view' => 'twitter_bootstrap5_sequential',
+ ];
+
+ $config = (new Processor())->processConfiguration(new Configuration(), [$extraConfig]);
+
+ self::assertEquals(
+ array_merge(self::getBundleDefaultConfig(), $extraConfig),
+ $config,
+ );
+ }
+
public function testConfigWithCustomDefaultTwigTemplate(): void
{
$extraConfig = [
@@ -49,6 +63,7 @@ public function testConfigWithCustomExceptionsStrategy(): void
'exceptions_strategy' => [
'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
+ 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_CUSTOM,
],
];
@@ -64,10 +79,12 @@ protected static function getBundleDefaultConfig(): array
{
return [
'default_view' => 'default',
+ 'default_sequential_view' => null,
'default_twig_template' => '@BabDevPagerfanta/default.html.twig',
'exceptions_strategy' => [
'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND,
'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND,
+ 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST,
],
];
}
diff --git a/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php b/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php
new file mode 100644
index 00000000..46c61dde
--- /dev/null
+++ b/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php
@@ -0,0 +1,47 @@
+createStub(HttpKernelInterface::class),
+ Request::create('/'),
+ HttpKernelInterface::MAIN_REQUEST,
+ $exception
+ );
+
+ (new ConvertInvalidCursorToBadRequestListener())->onKernelException($event);
+
+ self::assertInstanceOf(BadRequestHttpException::class, $event->getThrowable());
+ self::assertSame($exception, $event->getThrowable()->getPrevious());
+ }
+
+ public function testListenerDoesNotConvertUnknownExceptionForEvent(): void
+ {
+ $exception = new \RuntimeException();
+
+ $event = new ExceptionEvent(
+ $this->createStub(HttpKernelInterface::class),
+ Request::create('/'),
+ HttpKernelInterface::MAIN_REQUEST,
+ $exception
+ );
+
+ (new ConvertInvalidCursorToBadRequestListener())->onKernelException($event);
+
+ self::assertSame($exception, $event->getThrowable());
+ }
+}
diff --git a/tests/Position/PositionResolverTest.php b/tests/Position/PositionResolverTest.php
new file mode 100644
index 00000000..8ab44ed7
--- /dev/null
+++ b/tests/Position/PositionResolverTest.php
@@ -0,0 +1,142 @@
+cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret');
+ $this->resolver = new PositionResolver(PropertyAccess::createPropertyAccessor(), $this->cursorEncoder);
+ }
+
+ public function testNoPositionIsResolvedForTheFirstPage(): void
+ {
+ $request = Request::create('/');
+
+ self::assertNull($this->resolver->resolve($request));
+ self::assertNull($this->resolver->resolvePagePosition($request));
+ self::assertNull($this->resolver->resolveCursorPosition($request));
+ }
+
+ public function testNoPositionIsResolvedForEmptyParameters(): void
+ {
+ self::assertNull($this->resolver->resolve(Request::create('/', 'GET', ['page' => '', 'cursor' => ''])));
+ }
+
+ public function testAPagePositionIsResolvedFromTheQuery(): void
+ {
+ $request = Request::create('/', 'GET', ['page' => '3']);
+
+ self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition($request));
+ self::assertEquals(new PagePosition(3), $this->resolver->resolve($request));
+ }
+
+ public function testAPagePositionIsResolvedFromTheRouteParameters(): void
+ {
+ $request = Request::create('/posts/3');
+ $request->attributes->set('_route_params', ['page' => 3]);
+
+ self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition($request));
+ }
+
+ public function testAPagePositionIsResolvedFromACustomParameter(): void
+ {
+ $request = Request::create('/', 'GET', ['filters' => ['page' => '2']]);
+
+ self::assertEquals(new PagePosition(2), $this->resolver->resolvePagePosition($request, ['pageParameter' => '[filters][page]']));
+ }
+
+ public static function dataInvalidPages(): \Generator
+ {
+ yield 'not a number' => ['abc', NotValidCurrentPageException::class];
+ yield 'decimal' => ['1.5', NotValidCurrentPageException::class];
+ yield 'negative' => ['-1', NotValidCurrentPageException::class];
+ yield 'array' => [['1'], NotValidCurrentPageException::class];
+ yield 'zero' => ['0', LessThan1CurrentPageException::class];
+ }
+
+ /**
+ * @param class-string<\Throwable> $exception
+ *
+ * @dataProvider dataInvalidPages
+ */
+ #[DataProvider('dataInvalidPages')]
+ public function testAnInvalidPageIsRejected(mixed $page, string $exception): void
+ {
+ $this->expectException($exception);
+
+ $this->resolver->resolvePagePosition(Request::create('/', 'GET', ['page' => $page]));
+ }
+
+ public function testACursorPositionIsResolvedFromTheQuery(): void
+ {
+ $cursor = new Cursor(['p.id' => 42], Direction::Previous);
+ $request = Request::create('/', 'GET', ['cursor' => $this->cursorEncoder->encode($cursor)]);
+
+ self::assertEquals(new CursorPosition($cursor), $this->resolver->resolveCursorPosition($request));
+ self::assertEquals(new CursorPosition($cursor), $this->resolver->resolve($request));
+ }
+
+ public function testACursorPositionIsResolvedFromACustomParameter(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+ $request = Request::create('/', 'GET', ['after' => $this->cursorEncoder->encode($cursor)]);
+
+ self::assertEquals(new CursorPosition($cursor), $this->resolver->resolveCursorPosition($request, ['cursorParameter' => '[after]']));
+ }
+
+ public static function dataInvalidCursors(): \Generator
+ {
+ $encoded = (new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'))->encode(new Cursor(['p.id' => 42]));
+
+ yield 'unsigned' => [(new Base64JsonCursorEncoder())->encode(new Cursor(['p.id' => 42]))];
+ yield 'tampered' => [substr($encoded, 0, -2).'xx'];
+ yield 'garbage' => ['not a cursor'];
+ yield 'array' => [[$encoded]];
+ }
+
+ /**
+ * @dataProvider dataInvalidCursors
+ */
+ #[DataProvider('dataInvalidCursors')]
+ public function testAnInvalidCursorIsRejected(mixed $cursor): void
+ {
+ $this->expectException(InvalidCursorException::class);
+
+ $this->resolver->resolveCursorPosition(Request::create('/', 'GET', ['cursor' => $cursor]));
+ }
+
+ public function testACursorTakesPrecedenceOverAPageWhenResolvingEitherPosition(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+ $request = Request::create('/', 'GET', ['page' => '3', 'cursor' => $this->cursorEncoder->encode($cursor)]);
+
+ self::assertEquals(new CursorPosition($cursor), $this->resolver->resolve($request));
+ }
+
+ public function testTheOtherParameterIsIgnoredWhenResolvingASpecificPosition(): void
+ {
+ self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition(Request::create('/', 'GET', ['page' => '3', 'cursor' => 'tampered'])));
+ self::assertNull($this->resolver->resolveCursorPosition(Request::create('/', 'GET', ['page' => 'abc'])));
+ }
+}
diff --git a/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php b/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php
new file mode 100644
index 00000000..c995429b
--- /dev/null
+++ b/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php
@@ -0,0 +1,119 @@
+requestStack = new RequestStack();
+ }
+
+ protected function tearDown(): void
+ {
+ do {
+ $request = $this->requestStack->pop();
+ } while (null !== $request);
+ }
+
+ private function createFactory(): RequestAwarePositionRouteGeneratorFactory
+ {
+ $routeCollection = new RouteCollection();
+ $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view'));
+ $routeCollection->add('other_view', new Route('/other-view'));
+
+ return new RequestAwarePositionRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder());
+ }
+
+ private function pushRequest(): void
+ {
+ $request = Request::create('/pagerfanta-view', 'GET', ['page' => '3', 'cursor' => 'stale', 'hello' => 'world']);
+ $request->attributes->set('_route', 'pagerfanta_view');
+ $request->attributes->set('_route_params', []);
+
+ $this->requestStack->push($request);
+ }
+
+ public function testTheFactoryIsNotDeprecated(): void
+ {
+ self::assertSame([], $this->captureDeprecations(fn () => $this->createFactory()));
+ }
+
+ public function testAGeneratorIsCreatedForTheCurrentRequest(): void
+ {
+ $this->pushRequest();
+
+ $generator = $this->createFactory()->createPositionRouteGenerator();
+
+ $cursor = new Cursor(['p.id' => 42]);
+
+ // The parameters from the request keep their position
+ self::assertSame('/pagerfanta-view?page=4&hello=world', $generator(new PagePosition(4)));
+ self::assertSame('/pagerfanta-view?cursor='.(new Base64JsonCursorEncoder())->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor)));
+ }
+
+ public function testTheRouteParametersFromTheOptionsOverrideTheRequestParameters(): void
+ {
+ $this->pushRequest();
+
+ self::assertSame('/pagerfanta-view?page=2&hello=there', $this->createFactory()->createPositionRouteGenerator(['routeParams' => ['hello' => 'there']])(new PagePosition(2)));
+ }
+
+ public function testAGeneratorIsCreatedForAGivenRouteDuringASubrequest(): void
+ {
+ $this->pushRequest();
+ $this->requestStack->push(Request::create('/_internal'));
+
+ self::assertSame('/other-view?page=2', $this->createFactory()->createPositionRouteGenerator(['routeName' => 'other_view'])(new PagePosition(2)));
+ }
+
+ public function testAGeneratorIsNotCreatedWhenARouteNameIsNotGivenDuringASubrequest(): void
+ {
+ $this->pushRequest();
+ $this->requestStack->push(Request::create('/_internal'));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.');
+
+ $this->createFactory()->createPositionRouteGenerator();
+ }
+
+ public function testAGeneratorIsNotCreatedWhenARequestIsNotActive(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('The request aware route generator can not be used when there is not an active request.');
+
+ $this->createFactory()->createPositionRouteGenerator();
+ }
+
+ public function testTheRouteParametersOptionMustBeAnArray(): void
+ {
+ $this->pushRequest();
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The "routeParams" option must be an array, "string" given.');
+
+ $this->createFactory()->createPositionRouteGenerator(['routeParams' => 'hello=world']);
+ }
+}
diff --git a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php
index a4e49597..d60f129d 100644
--- a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php
+++ b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php
@@ -3,17 +3,33 @@
namespace BabDev\PagerfantaBundle\Tests\RouteGenerator;
use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory;
+use BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator;
+use BabDev\PagerfantaBundle\Tests\CapturesDeprecations;
+use Pagerfanta\Cursor\Base64JsonCursorEncoder;
use Pagerfanta\Exception\RuntimeException;
+use Pagerfanta\Position\PagePosition;
use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
+use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Component\Routing\Generator\UrlGenerator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * @group legacy
+ */
+#[Group('legacy')]
final class RequestAwareRouteGeneratorFactoryTest extends TestCase
{
+ use CapturesDeprecations;
+
private MockObject&UrlGeneratorInterface $router;
private RequestStack $requestStack;
@@ -100,4 +116,22 @@ private function createFactory(): RequestAwareRouteGeneratorFactory
$this->propertyAccessor
);
}
+
+ public function testTheFactoryIsDeprecated(): void
+ {
+ $deprecations = $this->captureDeprecations(fn () => $this->createFactory());
+
+ self::assertSame(['Since babdev/pagerfanta-bundle 4.7: The "BabDev\\PagerfantaBundle\\RouteGenerator\\RequestAwareRouteGeneratorFactory" class is deprecated, use the "BabDev\\PagerfantaBundle\\RouteGenerator\\RequestAwarePositionRouteGeneratorFactory" class instead.'], $deprecations);
+ }
+
+ public function testThePositionRouteGeneratorIsCreatedByTheReplacementFactory(): void
+ {
+ $routeCollection = new RouteCollection();
+ $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view'));
+
+ $generator = (new RequestAwareRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder()))->createPositionRouteGenerator(['routeName' => 'pagerfanta_view']);
+
+ self::assertInstanceOf(RouterAwarePositionRouteGenerator::class, $generator);
+ self::assertSame('/pagerfanta-view?page=2', $generator(new PagePosition(2)));
+ }
}
diff --git a/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php b/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php
new file mode 100644
index 00000000..d1bdae51
--- /dev/null
+++ b/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php
@@ -0,0 +1,103 @@
+, referenceType?: UrlGeneratorInterface::*} $options
+ */
+ private function createGenerator(array $options = []): RouterAwarePositionRouteGenerator
+ {
+ $routeCollection = new RouteCollection();
+ $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view'));
+
+ return new RouterAwarePositionRouteGenerator(
+ new UrlGenerator($routeCollection, new RequestContext()),
+ PropertyAccess::createPropertyAccessor(),
+ new Base64JsonCursorEncoder(),
+ ['routeName' => 'pagerfanta_view', ...$options],
+ );
+ }
+
+ private function encode(Cursor $cursor): string
+ {
+ return (new Base64JsonCursorEncoder())->encode($cursor);
+ }
+
+ public function testARouteIsGeneratedForAPage(): void
+ {
+ self::assertSame('/pagerfanta-view?page=1', $this->createGenerator()(new PagePosition(1)));
+ }
+
+ public function testARouteIsGeneratedForTheFirstPageWithTheFirstPageOmitted(): void
+ {
+ $generator = $this->createGenerator(['omitFirstPage' => true]);
+
+ self::assertSame('/pagerfanta-view', $generator(new PagePosition(1)));
+ self::assertSame('/pagerfanta-view?page=2', $generator(new PagePosition(2)));
+ }
+
+ public function testARouteIsGeneratedForACursor(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+
+ self::assertSame('/pagerfanta-view?cursor='.$this->encode($cursor), $this->createGenerator()(new CursorPosition($cursor)));
+ }
+
+ public function testARouteIsGeneratedForACursorWithACustomCursorParameter(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+
+ self::assertSame('/pagerfanta-view?after='.$this->encode($cursor), $this->createGenerator(['cursorParameter' => '[after]'])(new CursorPosition($cursor)));
+ }
+
+ public function testTheCursorParameterIsRemovedForAPageAndThePageParameterIsRemovedForACursor(): void
+ {
+ $cursor = new Cursor(['p.id' => 42]);
+
+ $generator = $this->createGenerator(['routeParams' => ['page' => 3, 'cursor' => 'stale', 'hello' => 'world']]);
+
+ self::assertSame('/pagerfanta-view?page=2&hello=world', $generator(new PagePosition(2)));
+ self::assertSame('/pagerfanta-view?cursor='.$this->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor)));
+ }
+
+ public function testARouteIsGeneratedWithAnAbsoluteUrl(): void
+ {
+ self::assertSame('http://localhost/pagerfanta-view?page=2', $this->createGenerator(['referenceType' => UrlGeneratorInterface::ABSOLUTE_URL])(new PagePosition(2)));
+ }
+
+ public function testAnUnsupportedPositionIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->createGenerator()(new class implements Position {});
+ }
+
+ public function testARouteIsNotGeneratedWhenTheRouteNameParameterIsMissing(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ new RouterAwarePositionRouteGenerator(
+ new UrlGenerator(new RouteCollection(), new RequestContext()),
+ PropertyAccess::createPropertyAccessor(),
+ new Base64JsonCursorEncoder(),
+ [], // @phpstan-ignore argument.type
+ );
+ }
+}
diff --git a/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php b/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php
index 02c79641..001c787a 100644
--- a/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php
+++ b/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php
@@ -3,7 +3,9 @@
namespace BabDev\PagerfantaBundle\Tests\RouteGenerator;
use BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator;
+use BabDev\PagerfantaBundle\Tests\CapturesDeprecations;
use Pagerfanta\Exception\InvalidArgumentException;
+use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -13,8 +15,14 @@
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
+/**
+ * @group legacy
+ */
+#[Group('legacy')]
final class RouterAwareRouteGeneratorTest extends TestCase
{
+ use CapturesDeprecations;
+
private function createRouter(): UrlGeneratorInterface
{
$routeCollection = new RouteCollection();
@@ -95,4 +103,11 @@ public function testARouteIsNotGeneratedWhenTheRouteNameParameterIsMissing(): vo
$generator(1);
}
+
+ public function testTheGeneratorIsDeprecated(): void
+ {
+ $deprecations = $this->captureDeprecations(fn () => new RouterAwareRouteGenerator($this->createRouter(), $this->createPropertyAccessor(), ['routeName' => 'pagerfanta_view']));
+
+ self::assertSame(['Since babdev/pagerfanta-bundle 4.7: The "BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator" class is deprecated, use the "BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwarePositionRouteGenerator" class instead.'], $deprecations);
+ }
}
diff --git a/tests/Serializer/Handler/CursorPagerHandlerTest.php b/tests/Serializer/Handler/CursorPagerHandlerTest.php
new file mode 100644
index 00000000..c0f1d19f
--- /dev/null
+++ b/tests/Serializer/Handler/CursorPagerHandlerTest.php
@@ -0,0 +1,183 @@
+cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret');
+ }
+
+ /**
+ * @return ArrayCursorAdapter
+ */
+ private function createAdapter(): ArrayCursorAdapter
+ {
+ return new ArrayCursorAdapter(range(1, 7), static fn (int $item): array => ['id' => $item]);
+ }
+
+ public function testACountablePagerIsSerializedWithTheTotal(): void
+ {
+ $pager = new CountableCursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 3])));
+
+ self::assertJsonStringEqualsJsonString(
+ json_encode([
+ 'items' => [4, 5, 6],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => true,
+ 'has_next_page' => true,
+ 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4], Direction::Previous)),
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 6])),
+ 'total_items' => 7,
+ ],
+ ], \JSON_THROW_ON_ERROR),
+ $this->createSerializer()->serialize($pager, 'json'),
+ );
+ }
+
+ public function testAPagerWhichCannotCountIsSerializedWithoutTheTotal(): void
+ {
+ $pager = new CursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 6])));
+
+ // The JMS Serializer omits null values unless the context enables serializing them
+ self::assertJsonStringEqualsJsonString(
+ json_encode([
+ 'items' => [7],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => true,
+ 'has_next_page' => false,
+ 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 7], Direction::Previous)),
+ ],
+ ], \JSON_THROW_ON_ERROR),
+ $this->createSerializer()->serialize($pager, 'json'),
+ );
+ }
+
+ public function testTheMissingCursorsAreSerializedAsNullWhenTheContextSerializesNulls(): void
+ {
+ $pager = new CursorPagerfanta($this->createAdapter(), 3);
+
+ self::assertJsonStringEqualsJsonString(
+ json_encode([
+ 'items' => [1, 2, 3],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => false,
+ 'has_next_page' => true,
+ 'previous_cursor' => null,
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 3])),
+ ],
+ ], \JSON_THROW_ON_ERROR),
+ $this->createSerializer()->serialize($pager, 'json', SerializationContext::create()->setSerializeNull(true)),
+ );
+ }
+
+ public function testAForwardOnlyPagerHasNoPreviousCursor(): void
+ {
+ $adapter = new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([4], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 4])));
+
+ self::assertJsonStringEqualsJsonString(
+ json_encode([
+ 'items' => [4],
+ 'pagination' => [
+ 'per_page' => 1,
+ 'has_previous_page' => false,
+ 'has_next_page' => true,
+ 'previous_cursor' => null,
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4])),
+ ],
+ ], \JSON_THROW_ON_ERROR),
+ $this->createSerializer()->serialize(new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 3]))), 'json', SerializationContext::create()->setSerializeNull(true)),
+ );
+ }
+
+ /**
+ * @return \Generator, array, string}>
+ */
+ public static function dataSerializeWithPreserveKeysContext(): \Generator
+ {
+ yield 'Context not set' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [], '{"items":{"0":"item1","2":"item2","4":"item3"},"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}'];
+
+ yield 'Context with preserve keys disabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], ['pagerfanta_preserve_keys' => false], '{"items":["item1","item2","item3"],"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}'];
+
+ yield 'Context with preserve keys enabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], ['pagerfanta_preserve_keys' => true], '{"items":{"0":"item1","2":"item2","4":"item3"},"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}'];
+ }
+
+ /**
+ * @param array $data
+ * @param array $context
+ *
+ * @dataProvider dataSerializeWithPreserveKeysContext
+ */
+ #[DataProvider('dataSerializeWithPreserveKeysContext')]
+ public function testSerializeToJsonWithPreserveKeysContext(array $data, array $context, string $expectedJson): void
+ {
+ // @phpstan-ignore argument.type
+ $pager = new CursorPagerfanta(new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice($data)));
+
+ $serializationContext = new SerializationContext();
+
+ foreach ($context as $key => $value) {
+ $serializationContext->setAttribute($key, $value);
+ }
+
+ self::assertJsonStringEqualsJsonString(
+ $expectedJson,
+ $this->createSerializer()->serialize($pager, 'json', $serializationContext),
+ );
+ }
+
+ public function testSerializeRejectsInvalidPreserveKeysContext(): void
+ {
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('The "pagerfanta_preserve_keys" context key must be a boolean value or null, "string" given.');
+
+ $serializationContext = new SerializationContext();
+ $serializationContext->setAttribute('pagerfanta_preserve_keys', 'invalid');
+
+ $this->createSerializer()->serialize(new CursorPagerfanta($this->createAdapter()), 'json', $serializationContext);
+ }
+
+ private function createSerializer(): SerializerInterface
+ {
+ $registry = new HandlerRegistry();
+ $registry->registerSubscribingHandler(new CursorPagerHandler($this->cursorEncoder));
+
+ return SerializerBuilder::create($registry, new EventDispatcher())->build();
+ }
+}
diff --git a/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php b/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php
new file mode 100644
index 00000000..5bc0cfed
--- /dev/null
+++ b/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php
@@ -0,0 +1,212 @@
+cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret');
+ }
+
+ private function createSerializer(): Serializer
+ {
+ return new Serializer([new CursorPagerNormalizer($this->cursorEncoder)]);
+ }
+
+ /**
+ * @return ArrayCursorAdapter
+ */
+ private function createAdapter(): ArrayCursorAdapter
+ {
+ return new ArrayCursorAdapter(array_map(static fn (int $id): array => ['id' => $id], range(1, 7)), static fn (array $item): array => ['id' => $item['id']]);
+ }
+
+ public function testACountablePagerIsNormalizedWithTheTotal(): void
+ {
+ $pager = new CountableCursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 3])));
+
+ self::assertSame(
+ [
+ 'items' => [['id' => 4], ['id' => 5], ['id' => 6]],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => true,
+ 'has_next_page' => true,
+ 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4], Direction::Previous)),
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 6])),
+ 'total_items' => 7,
+ ],
+ ],
+ $this->createSerializer()->normalize($pager),
+ );
+ }
+
+ public function testAPagerWhichCannotCountIsNormalizedWithoutTheTotal(): void
+ {
+ $pager = new CursorPagerfanta($this->createAdapter(), 3);
+
+ self::assertSame(
+ [
+ 'items' => [['id' => 1], ['id' => 2], ['id' => 3]],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => false,
+ 'has_next_page' => true,
+ 'previous_cursor' => null,
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 3])),
+ ],
+ ],
+ $this->createSerializer()->normalize($pager),
+ );
+ }
+
+ public function testTheLastPageHasNoNextCursor(): void
+ {
+ self::assertSame(
+ [
+ 'items' => [['id' => 7]],
+ 'pagination' => [
+ 'per_page' => 3,
+ 'has_previous_page' => true,
+ 'has_next_page' => false,
+ 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 7], Direction::Previous)),
+ 'next_cursor' => null,
+ ],
+ ],
+ $this->createSerializer()->normalize(new CursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 6])))),
+ );
+ }
+
+ public function testAForwardOnlyPagerHasNoPreviousCursor(): void
+ {
+ $adapter = new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([4], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 4])));
+
+ self::assertSame(
+ [
+ 'items' => [4],
+ 'pagination' => [
+ 'per_page' => 1,
+ 'has_previous_page' => false,
+ 'has_next_page' => true,
+ 'previous_cursor' => null,
+ 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4])),
+ ],
+ ],
+ $this->createSerializer()->normalize(new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 3])))),
+ );
+ }
+
+ public function testTheCursorsAreSignedWithTheBundleEncoder(): void
+ {
+ $normalized = $this->createSerializer()->normalize(new CursorPagerfanta($this->createAdapter(), 3));
+
+ self::assertIsArray($normalized);
+ self::assertIsArray($normalized['pagination']);
+
+ $nextCursor = $normalized['pagination']['next_cursor'];
+
+ self::assertIsString($nextCursor);
+ self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/', $nextCursor);
+ self::assertEquals(new Cursor(['id' => 3]), $this->cursorEncoder->decode($nextCursor));
+ }
+
+ /**
+ * Creates a pager whose adapter returns keyed items, which the cursor adapters are not required to reindex.
+ *
+ * @param array $items
+ *
+ * @return CursorPagerfanta
+ */
+ private function createPagerWithKeyedItems(array $items): CursorPagerfanta
+ {
+ // @phpstan-ignore argument.type
+ return new CursorPagerfanta(new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice($items)));
+ }
+
+ /**
+ * @return \Generator, array, array}>
+ */
+ public static function dataNormalizeWithPreserveKeysContext(): \Generator
+ {
+ yield 'Context not set' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [], [0 => 'item1', 2 => 'item2', 4 => 'item3']];
+
+ yield 'Context with preserve keys disabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => false], ['item1', 'item2', 'item3']];
+
+ yield 'Context with preserve keys enabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => true], [0 => 'item1', 2 => 'item2', 4 => 'item3']];
+
+ yield 'Context with preserve keys unset' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => null], [0 => 'item1', 2 => 'item2', 4 => 'item3']];
+ }
+
+ /**
+ * @param array $data
+ * @param array $context
+ * @param array $expectedItems
+ *
+ * @dataProvider dataNormalizeWithPreserveKeysContext
+ */
+ #[DataProvider('dataNormalizeWithPreserveKeysContext')]
+ public function testNormalizeWithPreserveKeysContext(array $data, array $context, array $expectedItems): void
+ {
+ self::assertSame(
+ [
+ 'items' => $expectedItems,
+ 'pagination' => [
+ 'per_page' => 10,
+ 'has_previous_page' => false,
+ 'has_next_page' => false,
+ 'previous_cursor' => null,
+ 'next_cursor' => null,
+ ],
+ ],
+ $this->createSerializer()->normalize($this->createPagerWithKeyedItems($data), null, $context),
+ );
+ }
+
+ public function testNormalizeRejectsInvalidPreserveKeysContext(): void
+ {
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('The "pagerfanta_preserve_keys" context key must be a boolean value or null, "string" given.');
+
+ (new CursorPagerNormalizer($this->cursorEncoder))->normalize(new CursorPagerfanta($this->createAdapter()), null, [CursorPagerNormalizer::PRESERVE_KEYS_KEY => 'invalid']);
+ }
+
+ public function testOnlyCursorPagersAreSupported(): void
+ {
+ $normalizer = new CursorPagerNormalizer($this->cursorEncoder);
+
+ self::assertTrue($normalizer->supportsNormalization(new CursorPagerfanta($this->createAdapter())));
+ self::assertFalse($normalizer->supportsNormalization(new Pagerfanta(new NullAdapter(5))));
+ self::assertArrayHasKey(CursorPagerInterface::class, $normalizer->getSupportedTypes(null));
+ }
+
+ public function testNormalizeOnlyAcceptsCursorPagers(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(\sprintf('The object must be an instance of "%s".', CursorPagerInterface::class));
+
+ (new CursorPagerNormalizer($this->cursorEncoder))->normalize(new \stdClass());
+ }
+}
diff --git a/tests/Twig/TwigUndefinedCallableHandlerTest.php b/tests/Twig/TwigUndefinedCallableHandlerTest.php
index 521eb35f..88b2fb3e 100644
--- a/tests/Twig/TwigUndefinedCallableHandlerTest.php
+++ b/tests/Twig/TwigUndefinedCallableHandlerTest.php
@@ -20,6 +20,7 @@ public static function dataSupportedFunctions(): \Generator
{
yield '"pagerfanta" function' => ['pagerfanta'];
yield '"pagerfanta_page_url" function' => ['pagerfanta_page_url'];
+ yield '"pagerfanta_position_url" function' => ['pagerfanta_position_url'];
}
/**
diff --git a/tests/View/TwigViewIntegrationTest.php b/tests/View/TwigViewIntegrationTest.php
index 2d45db92..211112ac 100644
--- a/tests/View/TwigViewIntegrationTest.php
+++ b/tests/View/TwigViewIntegrationTest.php
@@ -2,9 +2,17 @@
namespace BabDev\PagerfantaBundle\Tests\View;
-use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory;
+use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder;
+use BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory;
+use Pagerfanta\Adapter\CallbackCursorAdapter;
+use Pagerfanta\Adapter\CursorSlice;
use Pagerfanta\Adapter\FixedAdapter;
+use Pagerfanta\Cursor\Base64JsonCursorEncoder;
+use Pagerfanta\Cursor\Cursor;
+use Pagerfanta\Cursor\Direction;
+use Pagerfanta\CursorPagerfanta;
use Pagerfanta\Pagerfanta;
+use Pagerfanta\Position\CursorPosition;
use Pagerfanta\Twig\Extension\PagerfantaExtension;
use Pagerfanta\Twig\Extension\PagerfantaRuntime;
use Pagerfanta\Twig\View\TwigView;
@@ -43,6 +51,8 @@ final class TwigViewIntegrationTest extends TestCase
public PropertyAccessorInterface $propertyAccessor;
+ public SignedCursorEncoder $cursorEncoder;
+
public Environment $twig;
public static function setUpBeforeClass(): void
@@ -72,6 +82,7 @@ protected function setUp(): void
$this->router = $this->createRouter();
$this->requestStack = new RequestStack();
$this->propertyAccessor = PropertyAccess::createPropertyAccessor();
+ $this->cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret');
}
protected function tearDown(): void
@@ -410,11 +421,42 @@ public function testPagerfantaRenderingWithEmptyOptions(): void
self::assertNotEmpty(
(new TwigView($this->twig))->render(
$this->createPagerfanta(),
- (new RequestAwareRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor))->create(),
+ (new RequestAwarePositionRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor))->createPositionRouteGenerator(),
)
);
}
+ public function testACursorPagerIsRenderedWithSignedCursors(): void
+ {
+ $request = Request::create('/', 'GET', ['cursor' => 'current', 'hello' => 'world']);
+ $request->attributes->set('_route', 'pagerfanta_view');
+ $request->attributes->set('_route_params', []);
+
+ $this->requestStack->push($request);
+
+ $previous = new Cursor(['id' => 11], Direction::Previous);
+ $next = new Cursor(['id' => 20]);
+
+ $pager = new CursorPagerfanta(
+ new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice(range(11, 20), $previous, $next), true),
+ 10,
+ new CursorPosition(new Cursor(['id' => 10])),
+ );
+
+ $output = $this->twig->render('integration.html.twig', ['pager' => $pager, 'options' => ['template' => '@BabDevPagerfanta/twitter_bootstrap5.html.twig']]);
+
+ $this->assertViewOutputMatches(
+ $output,
+ \sprintf(
+ '',
+ $this->cursorEncoder->encode($previous),
+ $this->cursorEncoder->encode($next),
+ ),
+ );
+
+ self::assertMatchesRegularExpression('/cursor=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}&/', $output, 'The cursors are signed');
+ }
+
private function createRouter(): UrlGeneratorInterface
{
$routeCollection = new RouteCollection();
@@ -442,10 +484,11 @@ public function load($class)
$viewFactory = new ViewFactory();
$viewFactory->set('twig', new TwigView($this->testCase->twig));
- $routeGeneratorFactory = new RequestAwareRouteGeneratorFactory(
+ $routeGeneratorFactory = new RequestAwarePositionRouteGeneratorFactory(
$this->testCase->router,
$this->testCase->requestStack,
- $this->testCase->propertyAccessor
+ $this->testCase->propertyAccessor,
+ $this->testCase->cursorEncoder,
);
return new PagerfantaRuntime(