Add support for Cursor type override for federation compatibility#1231
Conversation
Allow `type_name_overrides: { Cursor: "String" }` to enable federation
composition with subgraphs that use String for cursor fields per the
Relay spec.
When Cursor is overridden to a built-in string-like type (String or ID):
- The Cursor scalar is not registered (avoids duplicate type definition)
- PageInfo, Edge, and pagination arguments use the overridden type
- Cursor strings are validated at input but passed through unchanged
- Paginator decodes cursors lazily with memoization
- Invalid overrides (Int, Boolean, Float) are rejected with a clear error
- No spurious warning is shown (Cursor override is properly tracked as used)
Architecture:
Cursor decoding is handled in the Paginator rather than the scalar coercion
adapter because GraphQL only supports scalar-level coercion, not field-level
coercion. When Cursor is overridden to String, the schema uses the String
scalar type for cursor fields (PageInfo.startCursor, Edge.cursor, etc.).
Since these fields share the String scalar with many other non-cursor fields,
we cannot apply cursor-specific decoding logic at the scalar coercion level
without incorrectly affecting all String fields.
By moving decoding to the Paginator, we establish a single decoding path that
works regardless of whether the Cursor scalar exists:
- With Cursor scalar: coercion validates strings, Paginator decodes them
- Without Cursor scalar (override): GraphQL passes raw strings, Paginator decodes them
Encoding remains in the resolvers (Edge#cursor calls DecodedCursor#encode),
ensuring cursor values are always encoded to strings before reaching the
coercion layer.
Tests:
- 35 cursor-related tests verify the feature (paginator, coercion, schema generation)
- All existing tests pass (no regressions)
Documentation:
- Add federation compatibility section to schema customization guide
- Add code example demonstrating the cursor type override
- Add comprehensive TESTING.md with 8 testing approaches
Resolves block#1028
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
32fc6aa to
a2a027c
Compare
Tests were passing DecodedCursor objects directly to pagination methods, but the Paginator now expects cursor strings. Updated tests to call .encode on cursor objects before passing them.
The cursor_of helper was returning DecodedCursor objects, but the Paginator now expects encoded cursor strings. Updated to call .encode on the cursor before returning it.
Instead of requiring all test call sites to encode cursors to strings, add backward compatibility to the Paginator to accept both String and DecodedCursor objects. When a DecodedCursor is passed, it's used directly without decoding. This is cleaner than updating every test helper and call site, and makes the API more forgiving for test code. Production GraphQL queries will still pass encoded strings as expected. Changes: - Updated decode_cursor to accept DecodedCursor | String | nil - Return cursor directly if it's already a DecodedCursor - Updated RBS signatures to reflect the union type - Reverted all test changes that added .encode calls
Co-authored-by: Myron Marston <myron.marston@gmail.com>
Co-authored-by: Myron Marston <myron.marston@gmail.com>
…/paginator.rb Co-authored-by: Myron Marston <myron.marston@gmail.com>
Co-authored-by: Myron Marston <myron.marston@gmail.com>
|
I believe I've addressed all of the feedback. There's one small detail regarding the scalar coercion adapter for custom scalars. If we remove it entirely, we lose the graphql-level validation of In the case where the cursor type is a custom scalar (the default When the cursor type is overridden to a built-in type, the failure happens during query parsing and we get a slightly more useful error message referencing the field: The |
|
I've pushed two more commits to fix a couple of issues I came across while testing. Testing summary:
Verified that all three of these work as expected - pagination functionality works the same for each case, and the error messages are all the same (see the previous comment) if a non-string type is passed as a cursor value. |
Conceptually, I'd like the behavior to be identical. Error message wording will need to differ (e.g. when using the When cursors use the
We should try to mirror that 2-part validation approach for I originally thought the scalar coercion adapter for |
…e warehouse extension is loaded
This is how it works with the revised coercion adapter, the only difference is where the "is this value a string" check happens. In the case where the cursor type is set to
When the cursor is a custom scalar, e.g. def self.coerce_input(value, ctx)
return value if value.nil? || value.is_a?(::String)
nil # Returning nil causes GraphQL-Ruby to generate a validation error
endIf that returns
In both cases the type validation on the cursor string is done at the up-front, and the string's value is validated and decoded in the paginator. |
Co-authored-by: Myron Marston <myron.marston@gmail.com>
Co-authored-by: Myron Marston <myron.marston@gmail.com>
Summary
Allows the
Cursortype to be overridden viatype_name_overrides: { Cursor: "String" }to built-in String-like scalar types (ID,String) in order to enable federation composition with graphs that useStringfor cursor fields per the Relay spec.Implementation Details
When
Cursoris overridden to a built-in string-like type (StringorID):Cursor decoding is handled in the
Paginatorrather than the scalar coercion adapter because GraphQL only supports scalar-level coercion, not field-level coercion. When Cursor is overridden to String, the schema uses the String scalar type for cursor fields (PageInfo.startCursor, Edge.cursor, etc.). Since these fields share the String scalar with many other non-cursor fields, we cannot apply cursor-specific decoding logic at the scalar coercion level without incorrectly affecting all String fields.By moving decoding to the Paginator, a single decoding path is established that works regardless of whether the Cursor scalar exists:
Encoding remains in the resolvers (Edge#cursor calls DecodedCursor#encode), ensuring cursor values are always encoded to strings before reaching the coercion layer.
Testing:
Documentation:
Resolves #1028