Skip to content

Validate pagination cursors on array-backed relay connections - #1354

Open
jwils wants to merge 1 commit into
mainfrom
joshuaw/array-cursor-validation
Open

Validate pagination cursors on array-backed relay connections#1354
jwils wants to merge 1 commit into
mainfrom
joshuaw/array-cursor-validation

Conversation

@jwils

@jwils jwils commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

widgetNames(first: 2, after: "not-a-cursor") silently returns the first page with hasPreviousPage: false. The same invalid cursor on a datastore-backed field returns a validation error, so the two paths disagree.

Before ac2c10e, Cursor.coerce_input decoded each cursor and returned nil for an invalid one, and GraphQL-Ruby rejected it at argument coercion time. That commit moved decoding into Paginator so decoding still runs when a project overrides the Cursor type to String (GraphQL only coerces at the scalar level, so a String-typed cursor argument never reaches our Cursor scalar).

Array-backed relay connections never reach Paginator. GetRecordFieldValue and ApolloEntityRefResolver pass their args straight to ArrayAdapter.build. GraphQL::Pagination::ArrayConnection then decodes with Base64.decode64, which is lenient — Base64.decode64("not-a-cursor").to_i is 0, i.e. the start of the collection. before: "garbage" yields an empty page with hasNextPage: true.

Fix

ArrayAdapter.build now decodes after and before before handing them to ArrayConnection, raising the same GraphQL::ExecutionError that Paginator raises. Both paths share a new DecodedCursor.decode_or_raise_execution_error, so they produce an identical message.

This keeps ac2c10e's design — validation lives in the consumer rather than the scalar — so it still works under the Cursor: "String" override.

Also included:

  • Removed the &.encode calls in ArrayAdapter.build. They became no-ops when cursor args became strings, and the comment above them was no longer true.
  • Stopped array_adapter_spec.rb from decoding cursor args itself. That emulated the pre-ac2c10ea runtime and hid the bug.
  • Added an acceptance expectation for a broken cursor on widget_names, covering both the Cursor scalar and the String cursor override.

Verification

I confirmed the acceptance test is a real regression test: with array_adapter.rb reverted it fails in both the snake_case and camelCase contexts, and passes with the fix.

Valid cursors are unaffected. ArrayConnection cursors round-trip through DecodedCursor unchanged (index 1"MQ""MQ"1).

script/quick_build passes: 5259 examples, 0 failures, 100% line and branch coverage, no standardrb offenses, no steep type errors.

Note

DecodedCursor.try_decode is now unused within lib — it went dead in ac2c10e. It is public, documented API, so I left it in place rather than widening this change.

Before ac2c10e, `Cursor.coerce_input` decoded each cursor and returned
`nil` for an invalid one. GraphQL-Ruby then rejected the bad cursor at
argument coercion time. That commit moved cursor decoding into
`Paginator`, so that decoding still works when a project overrides the
`Cursor` type to `String`.

Array-backed relay connections never reach `Paginator`, though.
`GetRecordFieldValue` and `ApolloEntityRefResolver` pass their args
straight to `ArrayAdapter.build`. `GraphQL::Pagination::ArrayConnection`
then decodes the cursor with `Base64.decode64`, which is lenient:
`Base64.decode64("not-a-cursor").to_i` is `0`. As a result,
`widgetNames(first: 2, after: "not-a-cursor")` returned the first page
with `hasPreviousPage: false`, while the same cursor on a
datastore-backed field returned a validation error.

`ArrayAdapter.build` now decodes `after` and `before` before it passes
them to `ArrayConnection`, and raises the same `GraphQL::ExecutionError`
that `Paginator` raises. Both paths use the new
`DecodedCursor.decode_or_raise_execution_error` method.

This commit also:

- Removes the `&.encode` calls in `ArrayAdapter.build`. They became
  no-ops when cursor args became strings, and the comment above them was
  no longer true.
- Stops `array_adapter_spec.rb` from decoding cursor args itself. That
  emulated the old runtime and hid the bug.
- Adds an acceptance expectation for a broken cursor on `widget_names`.
  It covers both the `Cursor` scalar and the `String` cursor override.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant