[Hydra] Empty cursor-paginated collection omits hydra:next and hydra:previous · Issue #7953 · api-platform/core · GitHub
Skip to content

[Hydra] Empty cursor-paginated collection omits hydra:next and hydra:previous #7953

Closed

Description

@soyuka

Summary

When a cursor-paginated Hydra collection returns zero results (e.g. a filter excludes every row), the response's hydra:view contains only @id and @type. Both hydra:next and hydra:previous are missing, leaving clients with no way to navigate back or retry without out-of-band knowledge of the resource's cursor field and items-per-page setting.

Steps to Reproduce

  1. Declare a resource with paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']] and 10 rows.

  2. Request a filtered range that yields no rows:

    GET /so_manies?order[id]=desc&id[gt]=10
    

Expected Behavior

Pre-4.x behavior (asserted by the legacy Behat scenario "Cursor-based pagination with range filtered items" in features/hydra/collection.feature):

"hydra:view": {
  "@id": "/so_manies?id%5Bgt%5D=10&order%5Bid%5D=desc",
  "@type": "hydra:PartialCollectionView",
  "hydra:previous": "/so_manies?id%5Bgt%5D=13&order%5Bid%5D=desc",
  "hydra:next": "/so_manies?id%5Blt%5D=10&order%5Bid%5D=desc"
}

hydra:previous is the existing cursor filter shifted by items_per_page in the reverse direction; hydra:next is the cursor filter with the operator inverted (gt → lt).

Actual Behavior

{
  "@type": "hydra:Collection",
  "hydra:member": [],
  "hydra:view": {
    "@id": "/so_manies?id%5Bgt%5D=10&order%5Bid%5D=desc",
    "@type": "hydra:PartialCollectionView"
  }
}

Root Cause

src/Hydra/Serializer/PartialCollectionViewNormalizer.php, populateDataWithCursorBasedPagination(), lines 168–185:

$objects = iterator_to_array($object);
$firstObject = current($objects);
$lastObject = end($objects);
// ...
if (false !== $lastObject && \is_array($cursorPaginationAttribute)) {
    $data[$hydraPrefix.'view'][$hydraPrefix.'next'] = ...;
}
if (false !== $firstObject && \is_array($cursorPaginationAttribute)) {
    $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = ...;
}

When $objects is empty, current() and end() both return false, so both branches are skipped and neither key is emitted.

Suggested Fix

When $objects is empty and the request URL contains a cursor filter parameter for a field declared in paginationViaCursor, synthesize both URLs from $parsed['parameters'] directly:

  • hydra:next — same URL with the cursor operator inverted (gt ↔ lt), value preserved.
  • hydra:previous — same URL with the cursor operator preserved, value shifted by items_per_page in the cursor direction.

If no cursor filter is present in the request and the result is empty, synthesis is not possible — keep the current behavior (omit the keys).

Additional Context

Discovered while migrating features/hydra/collection.feature to functional tests on the JSON-LD refactor branch. The empty-filtered cursor scenario had to be relaxed because of this gap; non-empty scenarios still pass and assert the exact URLs from the legacy expectation.

Activity

  1. added a commit that references this issue on May 8, 2026
    6244b35
  2. added a commit that references this issue on May 11, 2026
    ad606ca
  3. soyuka commented on May 11, 2026

    @soyuka
    MemberAuthor

    Findings while preparing the fix

    The PR for this issue ships the minimal regression fix only (synthesize hydra:next/hydra:previous from URL parameters when the cursor-paginated collection is empty). During review we identified follow-up work that's out of scope here but worth tracking.

    Scope shipped in the PR

    • populateDataWithCursorBasedPagination() gains an empty-collection branch that reads $parsed['parameters'], inverts the operator for next, shifts the value by items_per_page for previous. No change to the non-empty path.
    • New fixture under tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php uses the canonical QueryParameter form — ComparisonFilter(new ExactFilter()) + SortFilter, with nativeType: new BuiltinType(TypeIdentifier::INT) — so we don't ship new usages of the legacy #[ApiFilter(RangeFilter::class, …)] / OrderFilter that are slated for deprecation in 4.4 and removal in 6.0 (see TODO-filters.md).

    Known limitations to address as follow-ups

    1. Numeric-only cursor shift. cursorPaginationFieldsFromUrl() casts the URL value via (float) and computes $currentValue ± $itemsPerPage. This is correct for sequential auto-increment integer PKs (the common case) but silently breaks for date, UUID, or string cursors. A type-aware shift is needed before we can claim general cursor-pagination support on non-numeric fields.

    2. paginationViaCursor is decoupled from the filter system. The declaration today is [['field' => 'id', 'direction' => 'DESC']] — operation-level metadata that names a property and a direction, but knows nothing about the filter that actually parses ?id[gt]=10. As a result, the normalizer has to reverse-engineer the URL parameter shape (operator-keyed arrays, array_key_first to extract the operator, hard-coded gt/lt) instead of asking the filter system.

      The cleaner shape is to express the cursor configuration as a reference to a QueryParameter:

      #[ApiResource(
          parameters: [
              'id' => new QueryParameter(
                  filter: new ComparisonFilter(new ExactFilter()),
                  property: 'id',
                  nativeType: new BuiltinType(TypeIdentifier::INT),
              ),
          ],
          // declarative cursor: points at the parameter that owns the cursor URL surface
          paginationViaCursor: [['parameter' => 'id', 'direction' => 'DESC']],
      )]

      With this shape, nativeType becomes implicit, the URL key and operator schema are owned by the filter, and the normalizer stops parsing filter syntax. It also opens the door to non-Doctrine cursor pagination (Laravel, ES) since the contract is metadata-only.

    3. Cursor synthesis logic belongs in a resolver service. PartialCollectionViewNormalizer currently mixes three concerns: collection serialization, cursor URL inspection, and next/previous shift math. Extracting a CursorPaginationFieldsResolver (or similar) under src/State/Pagination/ would:

      • Let both the populated path (today: PropertyAccessor::getValue($firstObject, $field)) and the empty path (today: URL parsing) go through one API.
      • Make the JSON:API / GraphQL / hypothetical-format implementations consume the same logic.
      • Centralize the URL-key contract introduced in (2).

    Suggested target

    Points 1, 2, 3 form a coherent 4.4 task: refactor paginationViaCursor to reference QueryParameter, build the resolver service, drop the URL surgery from the normalizer. Backwards compatibility for the existing ['field' => …, 'direction' => …] declaration is straightforward (resolver falls back to looking up a parameter by property name when no parameter key is given).

    The regression fix in the PR stays as the 4.3 / 4.4 patch landing now; the architectural work happens on top.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions