Add support for Cursor type override for federation compatibility#1231
Add support for Cursor type override for federation compatibility#1231anthonycastiglia-toast wants to merge 8 commits into
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
| ### Federation Compatibility: Overriding `Cursor` to `String` | ||
|
|
||
| When composing an ElasticGraph subgraph into a federated supergraph alongside other subgraphs that use `String` for | ||
| cursor fields (following the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm)), |
There was a problem hiding this comment.
| cursor fields (following the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm)), | |
| cursor fields (as the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) permits), |
ElasticGraph's Cursor type already follows the Relay spec. The Relay spec specifically allows custom scalars to be used for cursors instead of String:
An “Edge Type” must contain a field called cursor. This field must return a type that serializes as a String; this may be a String, a Non-Null wrapper around a String, a custom scalar that serializes as a String, or a Non-Null wrapper around a custom scalar that serializes as a String.
Whatever type this field returns will be referred to as the cursor type in the rest of this spec.
I read the Relay spec closely when implementing pagination in ElasticGraph and chose to use a custom scalar type based on that.
| **Note**{: .alert-title} | ||
| The `Cursor` scalar and `String` are semantically identical on the wire—both are opaque base64-encoded strings. The | ||
| only difference is that `Cursor` provides more expressive type information in the GraphQL schema. Using `String` for | ||
| cursor fields is fully compatible with the Relay specification and is the common convention in most GraphQL implementations. |
There was a problem hiding this comment.
is the common convention in most GraphQL implementations.
Funny, until today I thought the common convention was to use a Cursor type like ElasticGraph uses. I checked the schemas of a few popular GraphQL APIs and it seems like you're right that String is the common convention. I thought I was following the standard convention when I chose to define a Cursor scalar. TIL!
| Internally, ElasticGraph will lazily decode cursor strings when pagination logic requires access to the decoded cursor | ||
| structure. This decoding is transparent—pagination continues to work exactly as before, including forward and backward | ||
| pagination, cursor validation, and aggregation pagination. | ||
|
|
There was a problem hiding this comment.
| Internally, ElasticGraph will lazily decode cursor strings when pagination logic requires access to the decoded cursor | |
| structure. This decoding is transparent—pagination continues to work exactly as before, including forward and backward | |
| pagination, cursor validation, and aggregation pagination. |
| def decoded_after | ||
| @decoded_after ||= decode_cursor(after) | ||
| end | ||
|
|
||
| # @return [DecodedCursor, nil] the decoded before cursor | ||
| def decoded_before | ||
| @decoded_before ||= decode_cursor(before) | ||
| end |
There was a problem hiding this comment.
| def decoded_after | |
| @decoded_after ||= decode_cursor(after) | |
| end | |
| # @return [DecodedCursor, nil] the decoded before cursor | |
| def decoded_before | |
| @decoded_before ||= decode_cursor(before) | |
| end | |
| def decoded_after | |
| return @decoded_after if defined?(@decoded_after) | |
| @decoded_after = decode_cursor(after) | |
| end | |
| # @return [DecodedCursor, nil] the decoded before cursor | |
| def decoded_before | |
| return @decoded_before if defined?(@decoded_before) | |
| @decoded_before = decode_cursor(before) | |
| end |
See https://til.hashrocket.com/posts/aoulxej8zd-ruby-memoization-with-nil-values for why this matters.
|
|
||
| def decode_cursor(cursor) | ||
| return nil if cursor.nil? | ||
| return cursor if cursor.is_a?(DecodedCursor) |
There was a problem hiding this comment.
| return cursor if cursor.is_a?(DecodedCursor) |
Allowing cursor to be a String or a DecodedCursor adds a lot of complexity. Let's require it to always be a String. (There are a bunch of other changes I'll suggest that will make that possible.)
There was a problem hiding this comment.
(these changes also aren't needed).
|
|
||
| it "keeps the `Cursor` scalar when not overridden" do | ||
| result = define_schema(type_name_overrides: {}) | ||
|
|
||
| # The Cursor scalar should be registered | ||
| expect(type_def_from(result, "Cursor")).to include("scalar Cursor") | ||
|
|
||
| # PageInfo fields should use Cursor | ||
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.start_cursor}: Cursor") | ||
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.end_cursor}: Cursor") | ||
| end |
There was a problem hiding this comment.
| it "keeps the `Cursor` scalar when not overridden" do | |
| result = define_schema(type_name_overrides: {}) | |
| # The Cursor scalar should be registered | |
| expect(type_def_from(result, "Cursor")).to include("scalar Cursor") | |
| # PageInfo fields should use Cursor | |
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.start_cursor}: Cursor") | |
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.end_cursor}: Cursor") | |
| end |
This is covered by existing tests.
| # PageInfo fields should use ID | ||
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.start_cursor}: ID") | ||
| expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.end_cursor}: ID") | ||
| end |
There was a problem hiding this comment.
The ID test is less complete than the String one. I'd recommend just writing a loop to cover both:
%w[ID String].each do |cursor_override|
it "allows overriding `Cursor` to `#{cursor_override}` for federation compatibility" do
result = define_schema(type_name_overrides: {Cursor: cursor_override}) do |api|
api.object_type "Widget" do |t|
t.field "id", "ID!"
t.index "widgets"
end
end
# The Cursor scalar should not be registered when overridden to a built-in type
expect(type_def_from(result, "Cursor")).to be_nil
# PageInfo fields should use the override instead of Cursor
expect(type_def_from(result, "PageInfo")).to eq(<<~EOS.strip)
type PageInfo {
#{schema_elements.has_next_page}: Boolean!
#{schema_elements.has_previous_page}: Boolean!
#{schema_elements.start_cursor}: #{cursor_override}
#{schema_elements.end_cursor}: #{cursor_override}
}
EOS
# Edge.cursor field should use the override
expect(type_def_from(result, "WidgetEdge")).to include("#{schema_elements.cursor}: #{cursor_override}")
# Pagination arguments should use the override
query_type = type_def_from(result, "Query")
expect(query_type).to include("after: #{cursor_override}")
expect(query_type).to include("before: #{cursor_override}")
end
endThis loop would replace this test and the test on line 78.
| expect { | ||
| define_schema(type_name_overrides: {Cursor: "Float"}) | ||
| }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) | ||
| end |
There was a problem hiding this comment.
These last 3 tests could similarly be written in a loop that loops over %w[Boolean Float Int].
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