Skip to content

Add support for Cursor type override for federation compatibility#1231

Open
anthonycastiglia-toast wants to merge 8 commits into
block:mainfrom
anthonycastiglia-toast:pagination-cursor-type-override
Open

Add support for Cursor type override for federation compatibility#1231
anthonycastiglia-toast wants to merge 8 commits into
block:mainfrom
anthonycastiglia-toast:pagination-cursor-type-override

Conversation

@anthonycastiglia-toast

Copy link
Copy Markdown
Contributor

Summary

Allows the Cursor type to be overridden via type_name_overrides: { Cursor: "String" } to built-in String-like scalar types (ID, String) in order to enable federation composition with graphs that use String for cursor fields per the Relay spec.

Implementation Details

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)

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, a single decoding path is established 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.

Testing:

  • Cursor-related tests verify the feature (paginator, coercion, schema generation)
  • 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 #1028

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>
@anthonycastiglia-toast anthonycastiglia-toast force-pushed the pagination-cursor-type-override branch from 32fc6aa to a2a027c Compare June 2, 2026 22:05
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)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment on lines +92 to +95
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment on lines +69 to +76
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(these changes also aren't needed).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed.

Comment on lines +107 to +117

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
end

This 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These last 3 tests could similarly be written in a loop that loops over %w[Boolean Float Int].

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.

Allow String to be used as the cursor type

2 participants