diff --git a/README.md b/README.md
index 70deb2a..5ce4b03 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
+ [Cursor pagination](#cursor-pagination)
+ [Building the store query](#building-the-store-query)
+ [Rendering navigation links](#rendering-navigation-links)
+ + [Preserving other query parameters](#preserving-other-query-parameters)
* [FAQ](#faq)
* [License](#license)
* [Contributing](#contributing)
@@ -602,6 +603,79 @@ The pagination entries come first, and the supplied ones follow in the order the
repeats a pagination key is discarded, so `withMetadata(metadata: ['per_page' => 99])` leaves `per_page` on the real
page size. Calling it more than once accumulates.
+### Preserving other query parameters
+
+An endpoint often bounds its collection with query parameters outside `filter`, `sort`, and `page`, a time window for
+example. Declare their names with `preserving` on the schema. The criteria read each declared parameter from the
+request, and every link, in the body and in the `Link` header, carries it as the request sent it, between the sort and
+the pagination. Following a link then keeps the window. A declared parameter the request omits is left out, an
+undeclared one is never copied, and without the declaration the links stay as they were.
+
+```php
+preserving(parameters: ['from', 'to']);
+
+# GET /v1/availability-overrides?from=2026-10-01T00:00:00Z&to=2026-10-31T23:59:59Z&page[size]=20
+/** @var ServerRequestInterface $request */
+$criteria = Criteria::fromQuery(schema: $schema, request: $request);
+
+/** @var iterable $items */
+$response = $criteria->page(items: $items, total: 45)->toResponse(baseUri: '/v1/availability-overrides');
+```
+
+```json
+{
+ "links": {
+ "self": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=1&page[size]=20",
+ "first": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=1&page[size]=20",
+ "next": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=2&page[size]=20",
+ "last": "/v1/availability-overrides?from=2026-10-01T00%3A00%3A00Z&to=2026-10-31T23%3A59%3A59Z&page[number]=3&page[size]=20"
+ }
+}
+```
+
+A cursor page preserves them the same way, so the `next` link of a waitlist scoped by a released slot keeps the slot:
+
+```php
+sortable(fields: ['created_at', 'id'])
+ ->preserving(parameters: ['slot_start_time', 'slot_end_time'])
+ ->defaultSort(sort: Sort::fromExpression(expression: 'created_at,id'));
+
+# GET /v1/eligible-waitlist-entries?slot_start_time=2026-10-14T10:00:00Z&slot_end_time=2026-10-14T11:00:00Z&page[size]=1
+/** @var ServerRequestInterface $request */
+$keyset = Criteria::fromQuery(schema: $schema, request: $request)->keyset();
+
+# links.next: /v1/eligible-waitlist-entries?slot_start_time=2026-10-14T10%3A00%3A00Z
+# &slot_end_time=2026-10-14T11%3A00%3A00Z&page[cursor]=...&page[size]=1
+/** @var iterable $items */
+$response = $keyset->page(items: $items)->toResponse(baseUri: '/v1/eligible-waitlist-entries');
+```
+
+| Rule | Behavior |
+|---------------|---------------------------------------------------------------------------------------------------|
+| Encoding | Every name, key, and value is percent-encoded, so `:` renders as `%3A` and `+` as `%2B`. |
+| Brackets | Declaring `window` carries `window[from]` and `window[to]` under literal bracketed keys. |
+| Names | Empty, bracketed, `filter`, `sort`, and `page` raise `PreservedParameterIsInvalid` when declared. |
+| Values | A value other than a string or a nested array of strings raises `PreservedParameterIsInvalid`. |
+| Links by hand | `Links::from` and the page factories take an optional `PreservedParameters`. |
+
## FAQ
### 01. Why does the library never touch a data store?
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index b80039e..34e9393 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -47,6 +47,11 @@ parameters:
path: src/Internal/AllowedField.php
- identifier: missingType.iterableValue
path: src/Internal/AllowedFilters.php
+ # The preserved parameters arrive as the parsed query string and leave as a list of encoded pairs.
+ - identifier: missingType.iterableValue
+ path: src/Internal/PreservedPairs.php
+ - identifier: argument.type
+ path: src/Internal/PreservedPairs.php
# reduce() folds the links into the associative body; phpstan widens the carry to array.
- identifier: return.type
path: src/Links.php
diff --git a/src/Cursor/Criteria.php b/src/Cursor/Criteria.php
index 26f88fd..829e888 100644
--- a/src/Cursor/Criteria.php
+++ b/src/Cursor/Criteria.php
@@ -18,6 +18,7 @@
use TinyBlocks\HttpQuery\Exceptions\SortIsRequired;
use TinyBlocks\HttpQuery\Filter;
use TinyBlocks\HttpQuery\Internal\Query;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;
@@ -34,6 +35,7 @@
private function __construct(
private Sort $sort,
private Filter $filter,
+ private PreservedParameters $preserved,
private Pagination $pagination,
private array $comparisons,
private Sort $submittedSort
@@ -70,6 +72,7 @@ public static function fromQuery(Schema $schema, ServerRequestInterface $request
return new Criteria(
sort: $query->sort(),
filter: $query->filter(),
+ preserved: $query->preserved(),
pagination: Pagination::from(cursor: $cursor, perPage: $query->pageSize()),
comparisons: $query->comparisons(),
submittedSort: $query->submittedSort()
@@ -136,7 +139,8 @@ public function keyset(): Keyset
sort: $this->sort,
filter: $this->filter,
pagination: $this->pagination,
- submittedSort: $this->submittedSort
+ submittedSort: $this->submittedSort,
+ preserved: $this->preserved
);
}
diff --git a/src/Cursor/Keyset.php b/src/Cursor/Keyset.php
index dc952bf..123a71b 100644
--- a/src/Cursor/Keyset.php
+++ b/src/Cursor/Keyset.php
@@ -10,6 +10,7 @@
use TinyBlocks\HttpQuery\Internal\Cursor\SortKeys;
use TinyBlocks\HttpQuery\Limit;
use TinyBlocks\HttpQuery\Order;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
/**
@@ -25,6 +26,7 @@
private function __construct(
private Sort $sort,
private Filter $filter,
+ private ?PreservedParameters $preserved,
private Pagination $pagination,
private Sort $submittedSort
) {
@@ -37,11 +39,24 @@ private function __construct(
* @param Filter $filter The filter preserved in every rendered URI.
* @param Pagination $pagination The cursor pagination carrying the incoming cursor and the limit.
* @param Sort $submittedSort The submitted sort preserved in every rendered URI.
+ * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null
+ * for none.
* @return Keyset The cursor view.
*/
- public static function from(Sort $sort, Filter $filter, Pagination $pagination, Sort $submittedSort): Keyset
- {
- return new Keyset(sort: $sort, filter: $filter, pagination: $pagination, submittedSort: $submittedSort);
+ public static function from(
+ Sort $sort,
+ Filter $filter,
+ Pagination $pagination,
+ Sort $submittedSort,
+ ?PreservedParameters $preserved = null
+ ): Keyset {
+ return new Keyset(
+ sort: $sort,
+ filter: $filter,
+ preserved: $preserved,
+ pagination: $pagination,
+ submittedSort: $submittedSort
+ );
}
/**
@@ -62,7 +77,8 @@ public function page(iterable $items, ?Closure $keysOf = null): Page
items: $items,
filter: $this->filter,
keysOf: ($keysOf ?? SortKeys::from(sort: $this->sort)),
- pagination: $this->pagination
+ pagination: $this->pagination,
+ preserved: $this->preserved
);
}
diff --git a/src/Cursor/Page.php b/src/Cursor/Page.php
index 7d6d418..db8a232 100644
--- a/src/Cursor/Page.php
+++ b/src/Cursor/Page.php
@@ -12,6 +12,7 @@
use TinyBlocks\HttpQuery\Internal\Cursor\Seek;
use TinyBlocks\HttpQuery\Internal\Rendering;
use TinyBlocks\HttpQuery\Navigation;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
/**
@@ -30,6 +31,7 @@ private function __construct(
private Collection $items,
private Filter $filter,
private bool $hasNext,
+ private ?PreservedParameters $preserved,
private Token $nextCursor,
private Pagination $pagination,
private array $extraMetadata
@@ -49,6 +51,8 @@ private function __construct(
* @param Filter $filter The filter preserved in every rendered URI.
* @param Closure(TElement): list $keysOf Extracts the ordering key values from an element.
* @param Pagination $pagination The pagination describing the current page.
+ * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null
+ * for none.
* @return Page The cursor page carrying the trimmed items and the next cursor.
*/
public static function from(
@@ -56,7 +60,8 @@ public static function from(
iterable $items,
Filter $filter,
Closure $keysOf,
- Pagination $pagination
+ Pagination $pagination,
+ ?PreservedParameters $preserved = null
): Page {
/** @var Collection $collection */
$collection = Collection::createFrom(elements: $items);
@@ -67,6 +72,7 @@ public static function from(
items: $seek->items(),
filter: $filter,
hasNext: $seek->hasNext(),
+ preserved: $preserved,
nextCursor: $seek->next(),
pagination: $pagination,
extraMetadata: []
@@ -90,6 +96,7 @@ public function map(Closure $transformation): Page
items: $items,
filter: $this->filter,
hasNext: $this->hasNext,
+ preserved: $this->preserved,
nextCursor: $this->nextCursor,
pagination: $this->pagination,
extraMetadata: $this->extraMetadata
@@ -172,6 +179,7 @@ public function toResponse(string $baseUri): ResponseInterface
filter: $this->filter,
baseUri: $baseUri,
metadata: $this->metadata(),
+ preserved: $this->preserved,
navigation: $this->navigation()
);
}
@@ -193,6 +201,7 @@ public function withMetadata(array $metadata): Page
items: $this->items,
filter: $this->filter,
hasNext: $this->hasNext,
+ preserved: $this->preserved,
nextCursor: $this->nextCursor,
pagination: $this->pagination,
extraMetadata: [...$this->extraMetadata, ...$metadata]
diff --git a/src/Exceptions/PreservedParameterIsInvalid.php b/src/Exceptions/PreservedParameterIsInvalid.php
new file mode 100644
index 0000000..58d2c7b
--- /dev/null
+++ b/src/Exceptions/PreservedParameterIsInvalid.php
@@ -0,0 +1,51 @@
+ is empty, bracketed, or reserved.';
+ private const string VALUE_NOT_ALLOWED = 'Preserved parameter <%s> must be a string or a nested array of strings.';
+
+ private function __construct(string $reason)
+ {
+ parent::__construct(message: $reason);
+ }
+
+ /**
+ * Creates a PreservedParameterIsInvalid from the name that is empty, bracketed, or reserved.
+ *
+ * @param string $name The parameter name that cannot be preserved.
+ * @return PreservedParameterIsInvalid The composed exception describing the rejected name.
+ */
+ public static function fromName(string $name): PreservedParameterIsInvalid
+ {
+ $template = PreservedParameterIsInvalid::NAME_NOT_ALLOWED;
+
+ return new PreservedParameterIsInvalid(reason: sprintf($template, $name));
+ }
+
+ /**
+ * Creates a PreservedParameterIsInvalid from the name whose value is not a string or nested strings.
+ *
+ * @param string $name The parameter name whose value cannot be preserved, the value kept out of the message.
+ * @return PreservedParameterIsInvalid The composed exception describing the rejected value.
+ */
+ public static function fromValue(string $name): PreservedParameterIsInvalid
+ {
+ $template = PreservedParameterIsInvalid::VALUE_NOT_ALLOWED;
+
+ return new PreservedParameterIsInvalid(reason: sprintf($template, $name));
+ }
+}
diff --git a/src/Internal/ParameterName.php b/src/Internal/ParameterName.php
new file mode 100644
index 0000000..87cc905
--- /dev/null
+++ b/src/Internal/ParameterName.php
@@ -0,0 +1,25 @@
+value),
+ name: $parameterName,
+ value: $value
+ );
+ },
+ array_keys($parameters),
+ $parameters
+ );
+
+ return new PreservedPairs(pairs: array_merge(...$pairs));
+ }
+
+ private static function pairsOf(string $key, ParameterName $name, mixed $value): array
+ {
+ if (is_string($value)) {
+ $template = '%s=%s';
+
+ return [sprintf($template, $key, rawurlencode($value))];
+ }
+
+ if (!is_array($value)) {
+ throw PreservedParameterIsInvalid::fromValue(name: $name->value);
+ }
+
+ $template = '%s[%s]';
+
+ return array_merge(...array_map(
+ static fn(int|string $index, mixed $nested): array => PreservedPairs::pairsOf(
+ key: sprintf($template, $key, rawurlencode((string)$index)),
+ name: $name,
+ value: $nested
+ ),
+ array_keys($value),
+ $value
+ ));
+ }
+
+ public function toQueryString(): string
+ {
+ return implode('&', $this->pairs);
+ }
+}
diff --git a/src/Internal/Query.php b/src/Internal/Query.php
index 67d39b5..f21db1b 100644
--- a/src/Internal/Query.php
+++ b/src/Internal/Query.php
@@ -10,6 +10,7 @@
use TinyBlocks\HttpQuery\Filter;
use TinyBlocks\HttpQuery\Group;
use TinyBlocks\HttpQuery\Internal\Rsql\FilterParser;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;
@@ -19,6 +20,7 @@ private function __construct(
private Sort $sort,
private Filter $filter,
private int $pageSize,
+ private PreservedParameters $preserved,
private int $pageNumber,
private array $comparisons,
private string $cursorToken,
@@ -43,6 +45,7 @@ public static function from(Schema $schema, ServerRequestInterface $request): Qu
sort: $schema->sortFor(sort: $submittedSort),
filter: $filter,
pageSize: $schema->pageSizeFor(requested: $size->toString() === '' ? null : $size->toInteger()),
+ preserved: $schema->preservedFor(parameters: $parameters->toArray()),
pageNumber: $number->toString() === '' ? 1 : $number->toInteger(),
comparisons: $schema->comparisonsFor(filter: $filter, expression: $expression),
cursorToken: $cursor->toString(),
@@ -65,6 +68,11 @@ public function pageSize(): int
return $this->pageSize;
}
+ public function preserved(): PreservedParameters
+ {
+ return $this->preserved;
+ }
+
public function pageNumber(): int
{
return $this->pageNumber;
diff --git a/src/Internal/Rendering.php b/src/Internal/Rendering.php
index 273197d..17ccf53 100644
--- a/src/Internal/Rendering.php
+++ b/src/Internal/Rendering.php
@@ -11,6 +11,7 @@
use TinyBlocks\HttpQuery\Links;
use TinyBlocks\HttpQuery\Navigation;
use TinyBlocks\HttpQuery\Pagination;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
final class Rendering
@@ -26,9 +27,17 @@ public static function of(
Filter $filter,
string $baseUri,
array $metadata,
+ ?PreservedParameters $preserved,
Navigation $navigation
): ResponseInterface {
- $links = Links::from(self: $self, sort: $sort, filter: $filter, baseUri: $baseUri, navigation: $navigation);
+ $links = Links::from(
+ self: $self,
+ sort: $sort,
+ filter: $filter,
+ baseUri: $baseUri,
+ navigation: $navigation,
+ preserved: $preserved
+ );
return Response::ok([
'data' => $items->toArray(),
diff --git a/src/Internal/Uri.php b/src/Internal/Uri.php
index 230de55..f46c312 100644
--- a/src/Internal/Uri.php
+++ b/src/Internal/Uri.php
@@ -7,6 +7,7 @@
use TinyBlocks\HttpQuery\Filter;
use TinyBlocks\HttpQuery\Internal\Rsql\Renderer;
use TinyBlocks\HttpQuery\Pagination;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
final class Uri
@@ -15,8 +16,13 @@ private function __construct()
{
}
- public static function from(Sort $sort, Filter $filter, string $baseUri, Pagination $pagination): string
- {
+ public static function from(
+ Sort $sort,
+ Filter $filter,
+ string $baseUri,
+ PreservedParameters $preserved,
+ Pagination $pagination
+ ): string {
$parameters = [];
if (!$filter->isEmpty()) {
@@ -29,6 +35,12 @@ public static function from(Sort $sort, Filter $filter, string $baseUri, Paginat
$parameters[] = sprintf($template, Uri::encode(value: $sort->toExpression()));
}
+ $preservedQuery = $preserved->toQueryString();
+
+ if ($preservedQuery !== '') {
+ $parameters[] = $preservedQuery;
+ }
+
$parameters[] = $pagination->toQueryString();
$template = '%s?%s';
diff --git a/src/Links.php b/src/Links.php
index 880d0e6..5b25c96 100644
--- a/src/Links.php
+++ b/src/Links.php
@@ -13,10 +13,11 @@
/**
* Navigation of a result, rendered as a JSON:API body links object or an RFC 8288 Link header.
*
- * It renders uniformly over a {@see Navigation}, building each URI from the given filter and sort
- * plus the pagination of the self link and of every target, so the filter and the sort are
- * preserved in every URI. The self link is built from the page's own current pagination, so a cursor
- * page renders a cursor self and an offset page renders an offset self.
+ * It renders uniformly over a {@see Navigation}, building each URI from the given filter, sort, and
+ * preserved parameters plus the pagination of the self link and of every target, so the filter, the
+ * sort, and the preserved parameters are kept in every URI. The self link is built from the page's own
+ * current pagination, so a cursor page renders a cursor self and an offset page renders an offset
+ * self.
*/
final readonly class Links
{
@@ -35,6 +36,7 @@ private function __construct(private WebLink $self, private Collection $links)
* @param Filter $filter The filter preserved in every URI.
* @param string $baseUri The base URI the navigation URIs are built on.
* @param Navigation $navigation The navigation the result exposes.
+ * @param PreservedParameters|null $preserved The query parameters preserved in every URI, or null for none.
* @return Links The navigation for the result.
*/
public static function from(
@@ -42,12 +44,16 @@ public static function from(
Sort $sort,
Filter $filter,
string $baseUri,
- Navigation $navigation
+ Navigation $navigation,
+ ?PreservedParameters $preserved = null
): Links {
+ $preservedParameters = ($preserved ?? PreservedParameters::from(parameters: []));
+
$uriFor = static fn(Pagination $pagination): string => Uri::from(
sort: $sort,
filter: $filter,
baseUri: $baseUri,
+ preserved: $preservedParameters,
pagination: $pagination
);
diff --git a/src/Offset/Criteria.php b/src/Offset/Criteria.php
index 5bcc601..9b3dc2b 100644
--- a/src/Offset/Criteria.php
+++ b/src/Offset/Criteria.php
@@ -19,6 +19,7 @@
use TinyBlocks\HttpQuery\Filter;
use TinyBlocks\HttpQuery\Internal\Query;
use TinyBlocks\HttpQuery\Limit;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Schema;
use TinyBlocks\HttpQuery\Sort;
@@ -35,6 +36,7 @@
private function __construct(
private Sort $sort,
private Filter $filter,
+ private PreservedParameters $preserved,
private Pagination $pagination,
private array $comparisons,
private Sort $submittedSort
@@ -67,6 +69,7 @@ public static function fromQuery(Schema $schema, ServerRequestInterface $request
return new Criteria(
sort: $query->sort(),
filter: $query->filter(),
+ preserved: $query->preserved(),
pagination: Pagination::fromPage(page: $query->pageNumber(), perPage: $query->pageSize()),
comparisons: $query->comparisons(),
submittedSort: $query->submittedSort()
@@ -111,7 +114,8 @@ public function page(iterable $items, int $total): Page
items: $items,
total: $total,
filter: $this->filter,
- pagination: $this->pagination
+ pagination: $this->pagination,
+ preserved: $this->preserved
);
}
@@ -148,7 +152,8 @@ public function slice(iterable $items): Slice
sort: $this->submittedSort,
items: $items,
filter: $this->filter,
- pagination: $this->pagination
+ pagination: $this->pagination,
+ preserved: $this->preserved
);
}
diff --git a/src/Offset/Page.php b/src/Offset/Page.php
index 1955f5f..8f9105f 100644
--- a/src/Offset/Page.php
+++ b/src/Offset/Page.php
@@ -17,6 +17,7 @@
use TinyBlocks\HttpQuery\Internal\Offset\Total;
use TinyBlocks\HttpQuery\Internal\Rendering;
use TinyBlocks\HttpQuery\Navigation;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
/**
@@ -37,6 +38,7 @@ private function __construct(
private Filter $filter,
private OffsetNavigation $paging,
private PageCount $pageCount,
+ private ?PreservedParameters $preserved,
private Pagination $pagination,
private array $extraMetadata
) {
@@ -51,11 +53,19 @@ private function __construct(
* @param iterable $items The items on the current page.
* @param Filter $filter The filter preserved in every rendered URI.
* @param Pagination $pagination The pagination describing the current page.
+ * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null
+ * for none.
* @return Page The page carrying the items, the total, and the navigation.
* @throws TotalIsNegative If the total is less than 0.
*/
- public static function from(Sort $sort, iterable $items, int $total, Filter $filter, Pagination $pagination): Page
- {
+ public static function from(
+ Sort $sort,
+ iterable $items,
+ int $total,
+ Filter $filter,
+ Pagination $pagination,
+ ?PreservedParameters $preserved = null
+ ): Page {
$count = Total::from(value: $total);
$limit = Limit::from(value: $pagination->limit());
$pageCount = $count->pageCount(limit: $limit);
@@ -74,6 +84,7 @@ public static function from(Sort $sort, iterable $items, int $total, Filter $fil
pagination: $pagination
),
pageCount: $pageCount,
+ preserved: $preserved,
pagination: $pagination,
extraMetadata: []
);
@@ -211,6 +222,7 @@ public function toResponse(string $baseUri): ResponseInterface
filter: $this->filter,
baseUri: $baseUri,
metadata: $this->metadata(),
+ preserved: $this->preserved,
navigation: $this->navigation()
);
}
@@ -264,6 +276,7 @@ public function withMetadata(array $metadata): Page
filter: $this->filter,
paging: $this->paging,
pageCount: $this->pageCount,
+ preserved: $this->preserved,
pagination: $this->pagination,
extraMetadata: [...$this->extraMetadata, ...$metadata]
);
diff --git a/src/Offset/Slice.php b/src/Offset/Slice.php
index 9817395..034e720 100644
--- a/src/Offset/Slice.php
+++ b/src/Offset/Slice.php
@@ -12,6 +12,7 @@
use TinyBlocks\HttpQuery\Internal\Rendering;
use TinyBlocks\HttpQuery\Internal\Window;
use TinyBlocks\HttpQuery\Navigation;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
/**
@@ -29,6 +30,7 @@ private function __construct(
private Collection $items,
private Filter $filter,
private OffsetNavigation $paging,
+ private ?PreservedParameters $preserved,
private Pagination $pagination
) {
}
@@ -44,10 +46,17 @@ private function __construct(
* @param iterable $items The items fetched for the page size plus one.
* @param Filter $filter The filter preserved in every rendered URI.
* @param Pagination $pagination The pagination describing the current page.
+ * @param PreservedParameters|null $preserved The query parameters preserved in every rendered URI, or null
+ * for none.
* @return Slice The slice carrying the trimmed items and the next-page hint.
*/
- public static function from(Sort $sort, iterable $items, Filter $filter, Pagination $pagination): Slice
- {
+ public static function from(
+ Sort $sort,
+ iterable $items,
+ Filter $filter,
+ Pagination $pagination,
+ ?PreservedParameters $preserved = null
+ ): Slice {
/** @var Collection $collection */
$collection = Collection::createFrom(elements: $items);
$window = Window::from(items: $collection, limit: $pagination->limit());
@@ -60,6 +69,7 @@ public static function from(Sort $sort, iterable $items, Filter $filter, Paginat
items: $trimmed,
filter: $filter,
paging: OffsetNavigation::from(hasNext: $window->hasNext(), pagination: $pagination),
+ preserved: $preserved,
pagination: $pagination
);
}
@@ -168,6 +178,7 @@ public function toResponse(string $baseUri): ResponseInterface
filter: $this->filter,
baseUri: $baseUri,
metadata: $this->metadata(),
+ preserved: $this->preserved,
navigation: $this->navigation()
);
}
diff --git a/src/PreservedParameters.php b/src/PreservedParameters.php
new file mode 100644
index 0000000..885ef7d
--- /dev/null
+++ b/src/PreservedParameters.php
@@ -0,0 +1,46 @@
+Each parameter is a string or a nested array of strings, the shapes a query string parses
+ * into. A nested array renders under bracketed keys, and every name, key, and value is
+ * percent-encoded, so a reserved character in a value never splits or ends the query string.
+ */
+final readonly class PreservedParameters
+{
+ private function __construct(private string $query)
+ {
+ }
+
+ /**
+ * Creates a PreservedParameters from the parameters keyed by name, kept in the given order.
+ *
+ * @param array $parameters The parameters keyed by name, each a string or a nested array
+ * of strings.
+ * @return PreservedParameters The parameters every navigation URI preserves.
+ * @throws PreservedParameterIsInvalid If a name is empty, bracketed, or reserved, or a value is not a string
+ * or a nested array of strings.
+ */
+ public static function from(array $parameters): PreservedParameters
+ {
+ return new PreservedParameters(query: PreservedPairs::from(parameters: $parameters)->toQueryString());
+ }
+
+ /**
+ * Returns the PreservedParameters as a query string fragment.
+ *
+ * @return string The fragment carrying every parameter, empty when there is none.
+ */
+ public function toQueryString(): string
+ {
+ return $this->query;
+ }
+}
diff --git a/src/Schema.php b/src/Schema.php
index a109730..73a67cd 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -11,17 +11,20 @@
use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported;
use TinyBlocks\HttpQuery\Exceptions\FilterValueNotAllowed;
use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange;
+use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid;
use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed;
use TinyBlocks\HttpQuery\Internal\AllowedFilters;
use TinyBlocks\HttpQuery\Internal\Conjunction;
use TinyBlocks\HttpQuery\Internal\Cursor\CursorKeys;
+use TinyBlocks\HttpQuery\Internal\ParameterName;
/**
* Declarative contract of the query an endpoint accepts, used to validate an incoming request.
*
* It declares the filterable fields with their permitted operators, values, and kinds, the
* client-sortable fields, the kind each cursor key value must match, the sort applied when the
- * client sends none, and the page-size bounds.
+ * client sends none, the page-size bounds, and the other query parameters every navigation URI
+ * preserves.
* The query parameter names follow JSON:API and are fixed: filter, sort,
* and the page family. The default page size is 20 and the maximum is 100.
*/
@@ -30,6 +33,7 @@
private function __construct(
private AllowedFilters $allowed,
private Sort $byDefault,
+ private array $preserved,
private CursorKeys $cursorKeys,
private int $maxPerPage,
private int $defaultPerPage,
@@ -75,6 +79,7 @@ public static function default(): Schema
return new Schema(
allowed: AllowedFilters::createFromEmpty(),
byDefault: Sort::fromExpression(expression: ''),
+ preserved: [],
cursorKeys: CursorKeys::createFromEmpty(),
maxPerPage: 100,
defaultPerPage: 20,
@@ -116,6 +121,7 @@ public function sortable(array $fields): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
@@ -158,6 +164,7 @@ public function cursorKey(string $field, ValueKind $valueKind): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys->with(kind: $valueKind, field: $field),
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
@@ -189,6 +196,7 @@ public function filterable(
operators: $operators
),
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
@@ -209,6 +217,7 @@ public function maxPerPage(int $maxPerPage): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $maxPerPage,
defaultPerPage: $this->defaultPerPage,
@@ -217,6 +226,35 @@ public function maxPerPage(int $maxPerPage): Schema
);
}
+ /**
+ * Returns a copy of the Schema declaring the query parameters every navigation URI preserves.
+ *
+ * The parameters sit outside the filter, sort, and page families, a time window bounding the
+ * collection for example. Each one the request carries is copied into the self link and into every
+ * navigation link, so following a link keeps the window. A declared parameter the request omits is
+ * left out. The declaration replaces any previous one.
+ *
+ * @param list $parameters The names of the query parameters every navigation URI preserves.
+ * @return Schema A copy carrying the preserved parameter names.
+ * @throws PreservedParameterIsInvalid If a name is empty, bracketed, or one of filter, sort, or page.
+ */
+ public function preserving(array $parameters): Schema
+ {
+ return new Schema(
+ allowed: $this->allowed,
+ byDefault: $this->byDefault,
+ preserved: array_flip(array_map(
+ static fn(string $name): string => ParameterName::from(value: $name)->value,
+ $parameters
+ )),
+ cursorKeys: $this->cursorKeys,
+ maxPerPage: $this->maxPerPage,
+ defaultPerPage: $this->defaultPerPage,
+ sortableFields: $this->sortableFields,
+ allowsDisjunction: $this->allowsDisjunction
+ );
+ }
+
/**
* Returns a copy of the Schema with the sort applied when the client sends none.
*
@@ -228,6 +266,7 @@ public function defaultSort(Sort $sort): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $sort,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
@@ -254,6 +293,19 @@ public function pageSizeFor(?int $requested): int
return $size;
}
+ /**
+ * Returns the declared parameters the request carries, preserved in every navigation URI.
+ *
+ * @param array $parameters The query parameters parsed from the request.
+ * @return PreservedParameters The declared parameters present in the request, in the request order.
+ * @throws PreservedParameterIsInvalid If a declared parameter carries a value that is not a string or a
+ * nested array of strings.
+ */
+ public function preservedFor(array $parameters): PreservedParameters
+ {
+ return PreservedParameters::from(parameters: array_intersect_key($parameters, $this->preserved));
+ }
+
/**
* Returns the validated comparisons read from the filter.
*
@@ -295,6 +347,7 @@ public function defaultPerPage(int $defaultPerPage): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $defaultPerPage,
@@ -313,6 +366,7 @@ public function allowDisjunction(): Schema
return new Schema(
allowed: $this->allowed,
byDefault: $this->byDefault,
+ preserved: $this->preserved,
cursorKeys: $this->cursorKeys,
maxPerPage: $this->maxPerPage,
defaultPerPage: $this->defaultPerPage,
diff --git a/tests/Unit/EndToEndTest.php b/tests/Unit/EndToEndTest.php
index 13fb227..f519b35 100644
--- a/tests/Unit/EndToEndTest.php
+++ b/tests/Unit/EndToEndTest.php
@@ -134,4 +134,272 @@ public function testPipelineWhenOffsetRequestGivenThenLinkHeaderFoldsEveryRelati
sprintf($template, $base, 24, 'last')
]), $header);
}
+
+ public function testPipelineWhenRequestCarriesUndeclaredParametersThenLinksLeaveThemOut(): void
+ {
+ /** @Given a request carrying a window the default schema never declared */
+ $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]);
+
+ /** @When rendering the first page of a two-element result as a JSON:API response */
+ $response = OffsetCriteria::fromQueryWithDefaultSchema(request: $query)
+ ->page(items: ['a'], total: 2)
+ ->toResponse(baseUri: '/v1/overrides');
+
+ /** @Then every link renders only the pagination, exactly as before */
+ self::assertSame([
+ 'data' => ['a'],
+ 'meta' => [
+ 'total' => 2,
+ 'per_page' => 1,
+ 'total_pages' => 2,
+ 'current_page' => 1,
+ 'has_next' => true,
+ 'has_previous' => false
+ ],
+ 'links' => [
+ 'self' => '/v1/overrides?page[number]=1&page[size]=1',
+ 'first' => '/v1/overrides?page[number]=1&page[size]=1',
+ 'next' => '/v1/overrides?page[number]=2&page[size]=1',
+ 'last' => '/v1/overrides?page[number]=2&page[size]=1'
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenDeclaredParametersAreAbsentFromTheRequestThenLinksLeaveThemOut(): void
+ {
+ /** @Given the query contract of an endpoint windowed by from and to */
+ $schema = Schema::create()->preserving(parameters: ['from', 'to']);
+
+ /** @And a request carrying only the window start */
+ $query = Query::from(parameters: ['from' => '2026-10-01', 'page' => ['size' => '1']]);
+
+ /** @When rendering the first page of a two-element result as a JSON:API response */
+ $response = OffsetCriteria::fromQuery(schema: $schema, request: $query)
+ ->page(items: ['a'], total: 2)
+ ->toResponse(baseUri: '/v1/overrides');
+
+ /** @Then every link carries the window start and no empty window end */
+ self::assertSame([
+ 'data' => ['a'],
+ 'meta' => [
+ 'total' => 2,
+ 'per_page' => 1,
+ 'total_pages' => 2,
+ 'current_page' => 1,
+ 'has_next' => true,
+ 'has_previous' => false
+ ],
+ 'links' => [
+ 'self' => '/v1/overrides?from=2026-10-01&page[number]=1&page[size]=1',
+ 'first' => '/v1/overrides?from=2026-10-01&page[number]=1&page[size]=1',
+ 'next' => '/v1/overrides?from=2026-10-01&page[number]=2&page[size]=1',
+ 'last' => '/v1/overrides?from=2026-10-01&page[number]=2&page[size]=1'
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenDeclaredParameterCarriesBracketedKeysThenEveryLinkPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint windowed by a bracketed window parameter */
+ $schema = Schema::create()->preserving(parameters: ['window']);
+
+ /** @And a request carrying window[from] and window[to] */
+ $query = Query::from(parameters: [
+ 'page' => ['size' => '1'],
+ 'window' => ['from' => '2026-10-01', 'to' => '2026-10-31']
+ ]);
+
+ /** @When rendering the first page of a two-element result as a JSON:API response */
+ $response = OffsetCriteria::fromQuery(schema: $schema, request: $query)
+ ->page(items: ['a'], total: 2)
+ ->toResponse(baseUri: '/v1/overrides');
+
+ /** @Then every link preserves both bracketed keys */
+ self::assertSame([
+ 'data' => ['a'],
+ 'meta' => [
+ 'total' => 2,
+ 'per_page' => 1,
+ 'total_pages' => 2,
+ 'current_page' => 1,
+ 'has_next' => true,
+ 'has_previous' => false
+ ],
+ 'links' => [
+ 'self' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=1&page[size]=1',
+ 'first' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=1&page[size]=1',
+ 'next' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=2&page[size]=1',
+ 'last' => '/v1/overrides?window[from]=2026-10-01&window[to]=2026-10-31&page[number]=2&page[size]=1'
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenSliceRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint windowed by from and to */
+ $schema = Schema::create()->preserving(parameters: ['from', 'to']);
+
+ /** @And a request carrying the window and the first page */
+ $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]);
+
+ /** @When rendering a slice fetched with one element beyond the page size as a JSON:API response */
+ $response = OffsetCriteria::fromQuery(schema: $schema, request: $query)
+ ->slice(items: ['a', 'b'])
+ ->toResponse(baseUri: '/v1/overrides');
+
+ /** @Then every link preserves the window */
+ self::assertSame([
+ 'data' => ['a'],
+ 'meta' => [
+ 'per_page' => 1,
+ 'current_page' => 1,
+ 'has_next' => true,
+ 'has_previous' => false
+ ],
+ 'links' => [
+ 'self' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=1',
+ 'first' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=1',
+ 'next' => '/v1/overrides?from=2026-10-01&to=2026-10-31&page[number]=2&page[size]=1'
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenCursorRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint scoped by a released slot */
+ $schema = Schema::create()
+ ->sortable(fields: ['id'])
+ ->preserving(parameters: ['slot_start_time', 'slot_end_time']);
+
+ /** @And a request carrying the released slot, a sort, and a page size of one */
+ $query = Query::from(parameters: [
+ 'sort' => 'id',
+ 'page' => ['size' => '1'],
+ 'slot_end_time' => '2026-10-14T11:00:00Z',
+ 'slot_start_time' => '2026-10-14T10:00:00Z'
+ ]);
+
+ /** @And the base URI carrying the sort and the released slot */
+ $base = '/v1/eligible?sort=id&slot_end_time=2026-10-14T11%3A00%3A00Z&slot_start_time=2026-10-14T10%3A00%3A00Z';
+
+ /** @When rendering the cursor page built through the keyset view as a JSON:API response */
+ $response = CursorCriteria::fromQuery(schema: $schema, request: $query)
+ ->keyset()
+ ->page(items: [['id' => 10], ['id' => 20]])
+ ->toResponse(baseUri: '/v1/eligible');
+
+ /** @Then the self and next links preserve the released slot */
+ self::assertSame([
+ 'data' => [['id' => 10]],
+ 'meta' => [
+ 'per_page' => 1,
+ 'has_next' => true
+ ],
+ 'links' => [
+ 'self' => sprintf('%s&page[size]=1', $base),
+ 'next' => sprintf('%s&page[cursor]=%s&page[size]=1', $base, Token::fromKeys(keys: [10])->toString())
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenOffsetRequestCarriesDeclaredParametersThenEveryLinkPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint windowed by from and to */
+ $schema = Schema::create()->sortable(fields: ['id'])->preserving(parameters: ['from', 'to']);
+
+ /** @And a request carrying the window, an undeclared parameter, a sort, and the second page */
+ $query = Query::from(parameters: [
+ 'to' => '2026-10-31T23:59:59+00:00',
+ 'from' => '2026-10-01T00:00:00+00:00',
+ 'sort' => 'id',
+ 'page' => ['number' => '2', 'size' => '1'],
+ 'debug' => 'true'
+ ]);
+
+ /** @And the base URI carrying the sort and the window as the request ordered it */
+ $base = '/overrides?sort=id&to=2026-10-31T23%3A59%3A59%2B00%3A00&from=2026-10-01T00%3A00%3A00%2B00%3A00';
+
+ /** @When rendering the second page of a three-element result as a JSON:API response */
+ $response = OffsetCriteria::fromQuery(schema: $schema, request: $query)
+ ->page(items: ['b'], total: 3)
+ ->toResponse(baseUri: '/overrides');
+
+ /** @Then every link preserves the window and drops the undeclared parameter */
+ self::assertSame([
+ 'data' => ['b'],
+ 'meta' => [
+ 'total' => 3,
+ 'per_page' => 1,
+ 'total_pages' => 3,
+ 'current_page' => 2,
+ 'has_next' => true,
+ 'has_previous' => true
+ ],
+ 'links' => [
+ 'self' => sprintf('%s&page[number]=2&page[size]=1', $base),
+ 'first' => sprintf('%s&page[number]=1&page[size]=1', $base),
+ 'prev' => sprintf('%s&page[number]=1&page[size]=1', $base),
+ 'next' => sprintf('%s&page[number]=3&page[size]=1', $base),
+ 'last' => sprintf('%s&page[number]=3&page[size]=1', $base)
+ ]
+ ], json_decode($response->getBody()->getContents(), true));
+ }
+
+ public function testPipelineWhenCursorRequestCarriesDeclaredParametersThenLinkHeaderPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint scoped by a released slot */
+ $schema = Schema::create()
+ ->sortable(fields: ['id'])
+ ->preserving(parameters: ['slot_start_time', 'slot_end_time']);
+
+ /** @And a request carrying the released slot, a sort, and a page size of one */
+ $query = Query::from(parameters: [
+ 'sort' => 'id',
+ 'page' => ['size' => '1'],
+ 'slot_start_time' => '2026-10-14T10:00:00Z',
+ 'slot_end_time' => '2026-10-14T11:00:00Z'
+ ]);
+
+ /** @And the base URI carrying the sort and the released slot */
+ $base = '/v1/eligible?sort=id&slot_start_time=2026-10-14T10%3A00%3A00Z&slot_end_time=2026-10-14T11%3A00%3A00Z';
+
+ /** @When rendering the cursor page and reading its RFC 8288 Link header line */
+ $header = CursorCriteria::fromQuery(schema: $schema, request: $query)
+ ->keyset()
+ ->page(items: [['id' => 10], ['id' => 20]])
+ ->toResponse(baseUri: '/v1/eligible')
+ ->getHeaderLine('Link');
+
+ /** @Then the self and next relations preserve the released slot */
+ self::assertSame(implode(', ', [
+ sprintf('<%s&page[size]=1>; rel="self"', $base),
+ sprintf('<%s&page[cursor]=%s&page[size]=1>; rel="next"', $base, Token::fromKeys(keys: [10])->toString())
+ ]), $header);
+ }
+
+ public function testPipelineWhenOffsetRequestCarriesDeclaredParametersThenLinkHeaderPreservesThem(): void
+ {
+ /** @Given the query contract of an endpoint windowed by from and to */
+ $schema = Schema::create()->preserving(parameters: ['from', 'to']);
+
+ /** @And a request carrying the window and the first page */
+ $query = Query::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31', 'page' => ['size' => '1']]);
+
+ /** @And the Link header template rendered per relation */
+ $template = '; rel="%s"';
+
+ /** @When rendering the first page of a two-element result and reading its RFC 8288 Link header line */
+ $header = OffsetCriteria::fromQuery(schema: $schema, request: $query)
+ ->page(items: ['a'], total: 2)
+ ->toResponse(baseUri: '/v1/overrides')
+ ->getHeaderLine('Link');
+
+ /** @Then every relation in the line preserves the window */
+ self::assertSame(implode(', ', [
+ sprintf($template, 1, 'self'),
+ sprintf($template, 1, 'first'),
+ sprintf($template, 2, 'next'),
+ sprintf($template, 2, 'last')
+ ]), $header);
+ }
}
diff --git a/tests/Unit/LinksTest.php b/tests/Unit/LinksTest.php
index c832fb7..e5e725c 100644
--- a/tests/Unit/LinksTest.php
+++ b/tests/Unit/LinksTest.php
@@ -8,6 +8,7 @@
use ReflectionClass;
use ReflectionMethod;
use Test\TinyBlocks\HttpQuery\Models\Query;
+use TinyBlocks\Http\LinkRelation;
use TinyBlocks\HttpQuery\Comparison;
use TinyBlocks\HttpQuery\Group;
use TinyBlocks\HttpQuery\Internal\Rsql\Renderer;
@@ -18,6 +19,7 @@
use TinyBlocks\HttpQuery\Offset\Criteria;
use TinyBlocks\HttpQuery\Offset\Pagination;
use TinyBlocks\HttpQuery\Operator;
+use TinyBlocks\HttpQuery\PreservedParameters;
use TinyBlocks\HttpQuery\Sort;
final class LinksTest extends TestCase
@@ -385,6 +387,34 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt
self::assertInstanceOf(Renderer::class, $renderer);
}
+ public function testToArrayWhenPreservedParametersGivenThenEveryUriCarriesThemBeforeThePagination(): void
+ {
+ /** @Given the parameters every navigation URI preserves */
+ $preserved = PreservedParameters::from(parameters: ['from' => '2026-10-01', 'to' => '2026-10-31']);
+
+ /** @And a navigation exposing the next page */
+ $navigation = Navigation::empty()->with(
+ target: Pagination::fromPage(page: 2, perPage: 20),
+ relation: LinkRelation::NEXT
+ );
+
+ /** @When rendering the navigation for a sorted page carrying those parameters */
+ $links = Links::from(
+ self: Pagination::fromPage(page: 1, perPage: 20),
+ sort: Sort::fromExpression(expression: 'id'),
+ filter: Group::none(),
+ baseUri: '/v1/overrides',
+ navigation: $navigation,
+ preserved: $preserved
+ );
+
+ /** @Then the self and next links carry the parameters between the sort and the pagination */
+ self::assertSame([
+ 'self' => '/v1/overrides?sort=id&from=2026-10-01&to=2026-10-31&page[number]=1&page[size]=20',
+ 'next' => '/v1/overrides?sort=id&from=2026-10-01&to=2026-10-31&page[number]=2&page[size]=20'
+ ], $links->toArray());
+ }
+
public function testToArrayWhenValueCarriesUnsafeAndStructuralCharactersThenEncodesOnlyTheUnsafeOnes(): void
{
/** @Given a comparison whose value carries query-unsafe and RSQL-structural characters */
diff --git a/tests/Unit/PreservedParametersTest.php b/tests/Unit/PreservedParametersTest.php
new file mode 100644
index 0000000..b12cc78
--- /dev/null
+++ b/tests/Unit/PreservedParametersTest.php
@@ -0,0 +1,172 @@
+ [], 'from' => '2026-10-01'];
+
+ /** @When creating the preserved parameters from them */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then only the scalar parameter renders */
+ self::assertSame('from=2026-10-01', $actual);
+ }
+
+ public function testFromWhenNameIsNumericThenRendersItAsAName(): void
+ {
+ /** @Given a parameter whose name the query string parsed into an integer key */
+ $parameters = [2026 => 'year'];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then the numeric name renders as written */
+ self::assertSame('2026=year', $actual);
+ }
+
+ public function testFromWhenNestedMapThenRendersBracketedKeys(): void
+ {
+ /** @Given a window parameter carrying a nested map of bounds */
+ $parameters = ['window' => ['from' => '2026-10-01', 'to' => '2026-10-31']];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then each bound renders under a bracketed key */
+ self::assertSame('window[from]=2026-10-01&window[to]=2026-10-31', $actual);
+ }
+
+ public function testFromWhenListThenRendersIndexedBracketedKeys(): void
+ {
+ /** @Given a parameter carrying a list of identifiers */
+ $parameters = ['ids' => ['10', '20']];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then each identifier renders under its index */
+ self::assertSame('ids[0]=10&ids[1]=20', $actual);
+ }
+
+ public function testFromWhenNoParameterThenRendersAnEmptyFragment(): void
+ {
+ /** @Given no parameter to preserve */
+ $parameters = [];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then the fragment is empty */
+ self::assertSame('', $actual);
+ }
+
+ public function testFromWhenValueCarriesBracketsThenPercentEncodesThem(): void
+ {
+ /** @Given a value carrying brackets */
+ $parameters = ['label' => '[draft]'];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then the brackets in the value are percent-encoded */
+ self::assertSame('label=%5Bdraft%5D', $actual);
+ }
+
+ public function testFromWhenDeeplyNestedMapThenRendersEveryLevelBracketed(): void
+ {
+ /** @Given a parameter nested two levels deep */
+ $parameters = ['range' => ['start' => ['date' => '2026-10-01']]];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then every level renders under its own bracketed key */
+ self::assertSame('range[start][date]=2026-10-01', $actual);
+ }
+
+ #[DataProvider('invalidNameCases')]
+ public function testFromWhenNameIsInvalidThenThrowsPreservedParameterIsInvalid(string $name): void
+ {
+ /** @Given a parameter name that is empty, bracketed, or reserved */
+
+ /** @Then an exception indicating the preserved parameter is invalid is raised */
+ $this->expectException(PreservedParameterIsInvalid::class);
+ $this->expectExceptionMessage(
+ sprintf('Preserved parameter name <%s> is empty, bracketed, or reserved.', $name)
+ );
+
+ /** @When creating the preserved parameters from it */
+ PreservedParameters::from(parameters: [$name => 'value']);
+ }
+
+ public function testFromWhenValueCarriesReservedCharactersThenPercentEncodesThem(): void
+ {
+ /** @Given instants carrying a colon and a plus offset, next to a note carrying separators */
+ $parameters = ['from' => '2026-10-01T09:00:00+00:00', 'note' => 'a&b=c #d'];
+
+ /** @When creating the preserved parameters from them */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then every reserved character in the values is percent-encoded */
+ self::assertSame('from=2026-10-01T09%3A00%3A00%2B00%3A00¬e=a%26b%3Dc%20%23d', $actual);
+ }
+
+ #[DataProvider('invalidValueCases')]
+ public function testFromWhenValueIsNotAStringThenThrowsPreservedParameterIsInvalid(mixed $value): void
+ {
+ /** @Given a parameter value that is neither a string nor a nested array of strings */
+
+ /** @Then an exception indicating the preserved parameter is invalid is raised */
+ $this->expectException(PreservedParameterIsInvalid::class);
+ $this->expectExceptionMessage('Preserved parameter must be a string or a nested array of strings.');
+
+ /** @When creating the preserved parameters from it */
+ PreservedParameters::from(parameters: ['from' => $value]);
+ }
+
+ public function testFromWhenNameAndKeyCarryReservedCharactersThenPercentEncodesThem(): void
+ {
+ /** @Given a name and a nested key carrying a space and an ampersand */
+ $parameters = ['time zone' => ['a&b' => 'UTC']];
+
+ /** @When creating the preserved parameters from it */
+ $actual = PreservedParameters::from(parameters: $parameters)->toQueryString();
+
+ /** @Then the name and the key are percent-encoded around literal brackets */
+ self::assertSame('time%20zone[a%26b]=UTC', $actual);
+ }
+
+ public static function invalidNameCases(): array
+ {
+ return [
+ 'Empty' => ['name' => ''],
+ 'Page family' => ['name' => 'page'],
+ 'Sort parameter' => ['name' => 'sort'],
+ 'Filter parameter' => ['name' => 'filter'],
+ 'Opening bracket' => ['name' => 'window['],
+ 'Closing bracket' => ['name' => 'window]'],
+ 'Bracketed path name' => ['name' => 'window[from]']
+ ];
+ }
+
+ public static function invalidValueCases(): array
+ {
+ return [
+ 'Null' => ['value' => null],
+ 'Integer' => ['value' => 10],
+ 'Boolean' => ['value' => true],
+ 'Nested integer' => ['value' => ['start' => 10]]
+ ];
+ }
+}
diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php
index 2511ab8..e86745b 100644
--- a/tests/Unit/SchemaTest.php
+++ b/tests/Unit/SchemaTest.php
@@ -4,10 +4,12 @@
namespace Test\TinyBlocks\HttpQuery\Unit;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionMethod;
use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange;
+use TinyBlocks\HttpQuery\Exceptions\PreservedParameterIsInvalid;
use TinyBlocks\HttpQuery\Internal\Conjunction;
use TinyBlocks\HttpQuery\Schema;
@@ -149,6 +151,21 @@ public function testDefaultPerPageWhenBelowMinimumThenThrowsPageSizeOutOfRange()
$schema->defaultPerPage(defaultPerPage: 0);
}
+ #[DataProvider('invalidNameCases')]
+ public function testPreservingWhenNameIsInvalidThenThrowsPreservedParameterIsInvalid(string $name): void
+ {
+ /** @Given a parameter name that is empty, bracketed, or reserved */
+
+ /** @Then an exception indicating the preserved parameter is invalid is raised */
+ $this->expectException(PreservedParameterIsInvalid::class);
+ $this->expectExceptionMessage(
+ sprintf('Preserved parameter name <%s> is empty, bracketed, or reserved.', $name)
+ );
+
+ /** @When declaring it as a parameter every navigation URI preserves */
+ Schema::create()->preserving(parameters: ['from', $name]);
+ }
+
public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyConjunction(): void
{
/** @Given an uninitialized instance of the static-only conjunction flattener */
@@ -160,4 +177,34 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt
/** @Then the static-only conjunction flattener is instantiated */
self::assertInstanceOf(Conjunction::class, $flattener);
}
+
+ public function testPreservedForWhenUndeclaredParameterPresentThenKeepsOnlyTheDeclaredInRequestOrder(): void
+ {
+ /** @Given a schema preserving the window bounds and a parameter the request omits */
+ $schema = Schema::create()->preserving(parameters: ['from', 'to', 'timezone']);
+
+ /** @When reading the preserved parameters out of query parameters carrying an undeclared one */
+ $actual = $schema->preservedFor(parameters: [
+ 'to' => '2026-10-31',
+ 'page' => ['size' => '1'],
+ 'from' => '2026-10-01',
+ 'debug' => 'true'
+ ]);
+
+ /** @Then only the declared parameters the request carries are kept, in the request order */
+ self::assertSame('to=2026-10-31&from=2026-10-01', $actual->toQueryString());
+ }
+
+ public static function invalidNameCases(): array
+ {
+ return [
+ 'Empty' => ['name' => ''],
+ 'Page family' => ['name' => 'page'],
+ 'Sort parameter' => ['name' => 'sort'],
+ 'Filter parameter' => ['name' => 'filter'],
+ 'Opening bracket' => ['name' => 'window['],
+ 'Closing bracket' => ['name' => 'window]'],
+ 'Bracketed path name' => ['name' => 'window[from]']
+ ];
+ }
}