Validate pagination cursors on array-backed relay connections - #1354
Open
jwils wants to merge 1 commit into
Open
Validate pagination cursors on array-backed relay connections#1354jwils wants to merge 1 commit into
jwils wants to merge 1 commit into
Conversation
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.
jwils
requested review from
BrianSigafoos-SQ,
bsorbo,
ellisandrews-toast,
jwondrusch,
marcdaniels-toast,
myronmarston and
rossroberts-toast
as code owners
August 16, 2026 00:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
widgetNames(first: 2, after: "not-a-cursor")silently returns the first page withhasPreviousPage: false. The same invalid cursor on a datastore-backed field returns a validation error, so the two paths disagree.Before ac2c10e,
Cursor.coerce_inputdecoded each cursor and returnednilfor an invalid one, and GraphQL-Ruby rejected it at argument coercion time. That commit moved decoding intoPaginatorso decoding still runs when a project overrides theCursortype toString(GraphQL only coerces at the scalar level, so aString-typed cursor argument never reaches ourCursorscalar).Array-backed relay connections never reach
Paginator.GetRecordFieldValueandApolloEntityRefResolverpass their args straight toArrayAdapter.build.GraphQL::Pagination::ArrayConnectionthen decodes withBase64.decode64, which is lenient —Base64.decode64("not-a-cursor").to_iis0, i.e. the start of the collection.before: "garbage"yields an empty page withhasNextPage: true.Fix
ArrayAdapter.buildnow decodesafterandbeforebefore handing them toArrayConnection, raising the sameGraphQL::ExecutionErrorthatPaginatorraises. Both paths share a newDecodedCursor.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:
&.encodecalls inArrayAdapter.build. They became no-ops when cursor args became strings, and the comment above them was no longer true.array_adapter_spec.rbfrom decoding cursor args itself. That emulated the pre-ac2c10ea runtime and hid the bug.widget_names, covering both theCursorscalar and theStringcursor override.Verification
I confirmed the acceptance test is a real regression test: with
array_adapter.rbreverted it fails in both the snake_case and camelCase contexts, and passes with the fix.Valid cursors are unaffected.
ArrayConnectioncursors round-trip throughDecodedCursorunchanged (index1→"MQ"→"MQ"→1).script/quick_buildpasses: 5259 examples, 0 failures, 100% line and branch coverage, no standardrb offenses, no steep type errors.Note
DecodedCursor.try_decodeis now unused withinlib— it went dead in ac2c10e. It is public, documented API, so I left it in place rather than widening this change.