From 60a03f9e13123ebd3d84b9c9ec275aff84ab8a56 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 3 Aug 2026 18:15:37 -0300 Subject: [PATCH 1/2] fix: Order the page meta contents with the pagination entries first. The 2.2.0 shape put the supplied entries ahead of the pagination ones, because the last key of a spread wins and that was how a supplied key was kept from shadowing the real page size. Discarding the colliding keys with array_diff_key protects them just as well and leaves the counts and sizes where the meta contract documents them, at the front. --- src/Cursor/Page.php | 13 +++++++------ src/Offset/Page.php | 13 +++++++------ tests/Unit/Cursor/PageTest.php | 18 +++++++++--------- tests/Unit/Offset/PageTest.php | 14 +++++++------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/Cursor/Page.php b/src/Cursor/Page.php index 16bc169..7d6d418 100644 --- a/src/Cursor/Page.php +++ b/src/Cursor/Page.php @@ -131,19 +131,20 @@ public function hasNext(): bool /** * Returns the cursor page as the JSON:API meta contents. * - *

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.

+ *

The pagination contents come first. Any metadata supplied through withMetadata follows, + * in the order it was given, and a supplied key that repeats a pagination one is discarded.

* - * @return array The meta contents, the supplied metadata first, then the - * pagination counts and sizes, then the boolean flags, each by ascending key-name length. + * @return array The meta contents, the pagination counts and sizes first, then + * the boolean flags, each by ascending key-name length, then the supplied metadata. */ public function metadata(): array { - return [ - ...$this->extraMetadata, + $pagination = [ 'per_page' => $this->pagination->limit(), 'has_next' => $this->hasNext ]; + + return [...$pagination, ...array_diff_key($this->extraMetadata, $pagination)]; } /** diff --git a/src/Offset/Page.php b/src/Offset/Page.php index 3b905a1..1955f5f 100644 --- a/src/Offset/Page.php +++ b/src/Offset/Page.php @@ -152,16 +152,15 @@ public function hasNext(): bool /** * Returns the page as the JSON:API meta contents. * - *

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.

+ *

The pagination contents come first. Any metadata supplied through withMetadata follows, + * in the order it was given, and a supplied key that repeats a pagination one is discarded.

* - * @return array The meta contents, the supplied metadata first, then the - * pagination counts and sizes, then the boolean flags, each by ascending key-name length. + * @return array The meta contents, the pagination counts and sizes first, then + * the boolean flags, each by ascending key-name length, then the supplied metadata. */ public function metadata(): array { - return [ - ...$this->extraMetadata, + $pagination = [ 'total' => $this->total->value(), 'per_page' => $this->paging->limit(), 'total_pages' => $this->pageCount->value(), @@ -169,6 +168,8 @@ public function metadata(): array 'has_next' => $this->paging->hasNext(), 'has_previous' => $this->paging->hasPrevious() ]; + + return [...$pagination, ...array_diff_key($this->extraMetadata, $pagination)]; } /** diff --git a/tests/Unit/Cursor/PageTest.php b/tests/Unit/Cursor/PageTest.php index 677b361..d5e47b6 100644 --- a/tests/Unit/Cursor/PageTest.php +++ b/tests/Unit/Cursor/PageTest.php @@ -40,8 +40,8 @@ public function testMapWhenPageCarriesMetadataThenTheCopyKeepsIt(): void /** @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()); + /** @Then the copy keeps the supplied metadata after the pagination contents */ + self::assertSame(['per_page' => 2, 'has_next' => false, 'unread_count' => 7], $mapped->metadata()); } public function testNavigationWhenNoExtraElementThenHasNoNextPage(): void @@ -85,12 +85,12 @@ public function testWithMetadataWhenAppliedTwiceThenBothEntriesAreKept(): void /** @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 */ + /** @Then both entries follow the pagination contents, in the order they were supplied */ self::assertSame([ - 'unread_count' => 7, - 'muted_count' => 3, 'per_page' => 2, - 'has_next' => false + 'has_next' => false, + 'unread_count' => 7, + 'muted_count' => 3 ], $counted->metadata()); } @@ -218,13 +218,13 @@ public function testWithMetadataWhenRenderedThenMetaCarriesItAndTheLinkHeaderHol /** @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 */ + /** @Then the supplied counter renders inside meta, after the pagination contents */ self::assertSame([ 'data' => [10, 20], 'meta' => [ - 'unread_count' => 7, 'per_page' => 2, - 'has_next' => true + 'has_next' => true, + 'unread_count' => 7 ], 'links' => [ 'self' => '/v1/notifications?page[size]=2', diff --git a/tests/Unit/Offset/PageTest.php b/tests/Unit/Offset/PageTest.php index 87e5ff7..55a50db 100644 --- a/tests/Unit/Offset/PageTest.php +++ b/tests/Unit/Offset/PageTest.php @@ -156,16 +156,16 @@ public function testWithMetadataWhenAppliedTwiceThenBothEntriesAreKept(): void /** @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 */ + /** @Then both entries follow the pagination contents, in the order they were supplied */ self::assertSame([ - 'unread_count' => 7, - 'muted_count' => 3, 'total' => 2, 'per_page' => 20, 'total_pages' => 1, 'current_page' => 1, 'has_next' => false, - 'has_previous' => false + 'has_previous' => false, + 'unread_count' => 7, + 'muted_count' => 3 ], $counted->metadata()); } @@ -335,17 +335,17 @@ public function testWithMetadataWhenRenderedThenMetaCarriesItAndTheLinkHeaderHol /** @When rendering the 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 */ + /** @Then the supplied counter renders inside meta, after the pagination contents */ self::assertSame([ 'data' => ['a', 'b'], 'meta' => [ - 'unread_count' => 7, 'total' => 2, 'per_page' => 20, 'total_pages' => 1, 'current_page' => 1, 'has_next' => false, - 'has_previous' => false + 'has_previous' => false, + 'unread_count' => 7 ], 'links' => [ 'self' => '/v1/notifications?page[number]=1&page[size]=20', From 17a59e96267c814864c721fd8fa74d18f1b3371d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 3 Aug 2026 18:15:37 -0300 Subject: [PATCH 2/2] docs: Correct the meta ordering in the extra contents example. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 678b480..44f0759 100644 --- a/README.md +++ b/README.md @@ -533,15 +533,15 @@ $response = $keyset->page(items: $items) ```json { "meta": { - "unread_count": 7, "per_page": 20, - "has_next": true + "has_next": true, + "unread_count": 7 } } ``` -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 +The pagination entries come first, and the supplied ones follow in the order they were given. A supplied key that +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. ## FAQ