Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,40 @@ total).
| `next` | The next page. |
| `last` | The last page. |

#### Adding your own meta contents

A page derives its own `meta` from the pagination. When the endpoint owns a counter the page cannot derive, an unread
total for example, `withMetadata` returns a copy carrying it. The value renders inside `meta`, so the response keeps the
single JSON:API envelope and the RFC 8288 `Link` header. Both pagination approaches expose it.

```php
<?php

declare(strict_types=1);

use TinyBlocks\HttpQuery\Cursor\Keyset;

/** @var Keyset $keyset */
/** @var iterable<array{id: int, created_at: string}> $items */
$response = $keyset->page(items: $items)
->withMetadata(metadata: ['unread_count' => 7])
->toResponse(baseUri: '/v1/notifications');
```

```json
{
"meta": {
"unread_count": 7,
"per_page": 20,
"has_next": true
}
}
```

The supplied entries come first, in the order they were given, and the pagination entries come last. A supplied key that
repeats a pagination key never shadows it, so `withMetadata(metadata: ['per_page' => 99])` leaves `per_page` on the real
page size. Calling it more than once accumulates.

## FAQ

### 01. Why does the library never touch a data store?
Expand Down
41 changes: 36 additions & 5 deletions src/Cursor/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@
{
/**
* @param Collection<TValue> $items
* @param array<string, mixed> $extraMetadata
*/
private function __construct(
private Sort $sort,
private Collection $items,
private Filter $filter,
private bool $hasNext,
private Token $nextCursor,
private Pagination $pagination
private Pagination $pagination,
private array $extraMetadata
) {
}

Expand Down Expand Up @@ -66,7 +68,8 @@ public static function from(
filter: $filter,
hasNext: $seek->hasNext(),
nextCursor: $seek->next(),
pagination: $pagination
pagination: $pagination,
extraMetadata: []
);
}

Expand All @@ -88,7 +91,8 @@ public function map(Closure $transformation): Page
filter: $this->filter,
hasNext: $this->hasNext,
nextCursor: $this->nextCursor,
pagination: $this->pagination
pagination: $this->pagination,
extraMetadata: $this->extraMetadata
);
}

Expand Down Expand Up @@ -127,12 +131,16 @@ public function hasNext(): bool
/**
* Returns the cursor page as the JSON:API meta contents.
*
* @return array<string, int|bool> The meta contents, counts and sizes first, then the boolean
* flags, each by ascending key-name length.
* <p>Any metadata supplied through withMetadata comes first, in the order it was given. The
* pagination contents come last, so a supplied key never shadows them.</p>
*
* @return array<string, mixed> The meta contents, the supplied metadata first, then the
* pagination counts and sizes, then the boolean flags, each by ascending key-name length.
*/
public function metadata(): array
{
return [
...$this->extraMetadata,
'per_page' => $this->pagination->limit(),
'has_next' => $this->hasNext
];
Expand Down Expand Up @@ -166,4 +174,27 @@ public function toResponse(string $baseUri): ResponseInterface
navigation: $this->navigation()
);
}

/**
* Returns a copy of the cursor page carrying the supplied metadata in its meta contents.
*
* <p>The supplied metadata is the place for a counter the consumer owns and the page cannot
* derive, an unread total for example. It renders inside meta, so the response keeps the
* single JSON:API envelope and the RFC 8288 Link header.</p>
*
* @param array<string, mixed> $metadata The metadata added to the meta contents.
* @return Page<TValue> A copy carrying the supplied metadata, preserving the items and the cursor.
*/
public function withMetadata(array $metadata): Page
{
return new Page(
sort: $this->sort,
items: $this->items,
filter: $this->filter,
hasNext: $this->hasNext,
nextCursor: $this->nextCursor,
pagination: $this->pagination,
extraMetadata: [...$this->extraMetadata, ...$metadata]
);
}
}
39 changes: 35 additions & 4 deletions src/Offset/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
{
/**
* @param Collection<TValue> $items
* @param array<string, mixed> $extraMetadata
*/
private function __construct(
private Sort $sort,
Expand All @@ -36,7 +37,8 @@ private function __construct(
private Filter $filter,
private OffsetNavigation $paging,
private PageCount $pageCount,
private Pagination $pagination
private Pagination $pagination,
private array $extraMetadata
) {
}

Expand Down Expand Up @@ -72,7 +74,8 @@ public static function from(Sort $sort, iterable $items, int $total, Filter $fil
pagination: $pagination
),
pageCount: $pageCount,
pagination: $pagination
pagination: $pagination,
extraMetadata: []
);
}

Expand Down Expand Up @@ -149,12 +152,16 @@ public function hasNext(): bool
/**
* Returns the page as the JSON:API meta contents.
*
* @return array<string, int|bool> The meta contents, counts and sizes first, then the boolean
* flags, each by ascending key-name length.
* <p>Any metadata supplied through withMetadata comes first, in the order it was given. The
* pagination contents come last, so a supplied key never shadows them.</p>
*
* @return array<string, mixed> The meta contents, the supplied metadata first, then the
* pagination counts and sizes, then the boolean flags, each by ascending key-name length.
*/
public function metadata(): array
{
return [
...$this->extraMetadata,
'total' => $this->total->value(),
'per_page' => $this->paging->limit(),
'total_pages' => $this->pageCount->value(),
Expand Down Expand Up @@ -236,4 +243,28 @@ public function hasPrevious(): bool
{
return $this->paging->hasPrevious();
}

/**
* Returns a copy of the page carrying the supplied metadata in its meta contents.
*
* <p>The supplied metadata is the place for a counter the consumer owns and the page cannot
* derive, an unread total for example. It renders inside meta, so the response keeps the
* single JSON:API envelope and the RFC 8288 Link header.</p>
*
* @param array<string, mixed> $metadata The metadata added to the meta contents.
* @return Page<TValue> A copy carrying the supplied metadata, preserving the items and the navigation.
*/
public function withMetadata(array $metadata): Page
{
return new Page(
sort: $this->sort,
items: $this->items,
total: $this->total,
filter: $this->filter,
paging: $this->paging,
pageCount: $this->pageCount,
pagination: $this->pagination,
extraMetadata: [...$this->extraMetadata, ...$metadata]
);
}
}
100 changes: 100 additions & 0 deletions tests/Unit/Cursor/PageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ protected function setUp(): void
$this->filter = Group::none();
}

public function testMapWhenPageCarriesMetadataThenTheCopyKeepsIt(): void
{
/** @Given a cursor page carrying a counter the page cannot derive */
$page = Page::from(
sort: $this->sort,
items: [10, 20],
filter: $this->filter,
keysOf: static fn(int $element): array => [$element],
pagination: Pagination::from(cursor: Token::none(), perPage: 2)
)->withMetadata(metadata: ['unread_count' => 7]);

/** @When the items are projected through a transformation */
$mapped = $page->map(transformation: static fn(int $element): int => ($element * 2));

/** @Then the copy keeps the supplied metadata ahead of the pagination contents */
self::assertSame(['unread_count' => 7, 'per_page' => 2, 'has_next' => false], $mapped->metadata());
}

public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void
{
/** @Given a keyset pagination with an absent incoming cursor and a page size of two */
Expand Down Expand Up @@ -53,6 +71,29 @@ public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void
self::assertSame(['per_page' => 2, 'has_next' => false], $page->metadata());
}

public function testWithMetadataWhenAppliedTwiceThenBothEntriesAreKept(): void
{
/** @Given a cursor page with no supplied metadata */
$page = Page::from(
sort: $this->sort,
items: [10, 20],
filter: $this->filter,
keysOf: static fn(int $element): array => [$element],
pagination: Pagination::from(cursor: Token::none(), perPage: 2)
);

/** @When metadata is supplied twice */
$counted = $page->withMetadata(metadata: ['unread_count' => 7])->withMetadata(metadata: ['muted_count' => 3]);

/** @Then both entries reach the meta contents, in the order they were supplied */
self::assertSame([
'unread_count' => 7,
'muted_count' => 3,
'per_page' => 2,
'has_next' => false
], $counted->metadata());
}

public function testToResponseWhenFirstCursorPageThenSelfLinkIsCursorStyle(): void
{
/** @Given a cursor page on the first page with no incoming cursor */
Expand Down Expand Up @@ -81,6 +122,24 @@ public function testToResponseWhenFirstCursorPageThenSelfLinkIsCursorStyle(): vo
], json_decode($response->getBody()->getContents(), true));
}

public function testWithMetadataWhenAKeyCollidesThenThePaginationEntryWins(): void
{
/** @Given a cursor page with a page size of two */
$page = Page::from(
sort: $this->sort,
items: [10, 20],
filter: $this->filter,
keysOf: static fn(int $element): array => [$element],
pagination: Pagination::from(cursor: Token::none(), perPage: 2)
);

/** @When metadata reusing a pagination key is supplied */
$counted = $page->withMetadata(metadata: ['per_page' => 99]);

/** @Then the pagination entry stands and the supplied value never shadows it */
self::assertSame(['per_page' => 2, 'has_next' => false], $counted->metadata());
}

public function testToResponseWhenCursorPageGivenThenRendersBodyAndLinkHeader(): void
{
/** @Given an opaque token produced from ordering key values */
Expand Down Expand Up @@ -145,6 +204,47 @@ public function testNavigationWhenExtraElementFetchedThenListsOnlyTheNextTarget(
);
}

public function testWithMetadataWhenRenderedThenMetaCarriesItAndTheLinkHeaderHolds(): void
{
/** @Given a cursor page carrying a counter the page cannot derive */
$page = Page::from(
sort: $this->sort,
items: [10, 20, 30],
filter: $this->filter,
keysOf: static fn(int $element): array => [$element],
pagination: Pagination::from(cursor: Token::none(), perPage: 2)
)->withMetadata(metadata: ['unread_count' => 7]);

/** @When rendering the cursor page as a JSON:API response over the notifications base URI */
$response = $page->toResponse(baseUri: '/v1/notifications');

/** @Then the supplied counter renders inside meta, ahead of the pagination contents */
self::assertSame([
'data' => [10, 20],
'meta' => [
'unread_count' => 7,
'per_page' => 2,
'has_next' => true
],
'links' => [
'self' => '/v1/notifications?page[size]=2',
'next' => sprintf(
'/v1/notifications?page[cursor]=%s&page[size]=2',
Token::fromKeys(keys: [20])->toString()
)
]
], json_decode($response->getBody()->getContents(), true));

/** @And the RFC 8288 Link header still folds the self and next relations */
self::assertSame(implode(', ', [
'</v1/notifications?page[size]=2>; rel="self"',
sprintf(
'</v1/notifications?page[cursor]=%s&page[size]=2>; rel="next"',
Token::fromKeys(keys: [20])->toString()
)
]), $response->getHeaderLine('Link'));
}

public function testMapWhenTransformationGivenThenProjectsItemsAndPreservesTheCursor(): void
{
/** @Given a cursor page built over items fetched for the page size plus one */
Expand Down
Loading