Skip to content

Add support for Cursor type override for federation compatibility#1231

Merged
myronmarston merged 24 commits into
block:mainfrom
anthonycastiglia-toast:pagination-cursor-type-override
Jun 12, 2026
Merged

Add support for Cursor type override for federation compatibility#1231
myronmarston merged 24 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
Comment thread config/site/src/guides/customizing-the-graphql-schema.md Outdated
Comment thread config/site/src/guides/customizing-the-graphql-schema.md
Comment thread config/site/src/guides/customizing-the-graphql-schema.md Outdated
Comment thread elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb Outdated
@anthonycastiglia-toast

Copy link
Copy Markdown
Contributor Author

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 Cursor-typed cursors and error messages end up being different depending on whether you've overridden the Cursor type to a stock GraphQL scalar or a custom value.

In the case where the cursor type is a custom scalar (the default Cursor or some other other name), there's no validation at the graphql level, and there's no error generated until the paginator tries to decode the value. Example error response:

{
  "errors": [
    {
      "message": "Cursor must be a String, got Array",
      ...
    }
  ]
}

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:

{
  "errors": [
    {
      "message": "Argument 'after' on Field 'widgets' has an invalid value ([1, 2, 3]). Expected type 'String'.",
      ...
    }
  ]
}

The nil returned from the simplified coercion adapter is interpreted by the GraphQL as a coercion failure, which, while not exactly the same as a query parsing failure, still at least generates a consistent error message:

{
  "errors": [
    {
      "message": "Argument 'after' on Field 'widgets' has an invalid value ([1, 2, 3]). Expected type 'Cursor'.",
       ...
    }
  ]
}

@anthonycastiglia-toast

Copy link
Copy Markdown
Contributor Author

I've pushed two more commits to fix a couple of issues I came across while testing.

Testing summary:

  • ./scripts/quick_build now succeeds with 0 test failures
  • Tested each cursor type scenario by adding tasks.type_name_overrides = {Cursor: "<type name>"} to the Rakefile, regenerating schema artifacts and ad-hoc testing via the boot_locally` task:
    • Keeping the default Cursor
    • Overriding to String
    • Overriding to a custom scalar MyPaginationCursor

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.

@myronmarston

Copy link
Copy Markdown
Collaborator

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 Cursor-typed cursors and error messages end up being different depending on whether you've overridden the Cursor type to a stock GraphQL scalar or a custom value.

Conceptually, I'd like the behavior to be identical. Error message wording will need to differ (e.g. when using the Cursor type it shouldn't say "argument was not a String"), but it would be nice if the messages are consistent/similar while allowing for type differences.

When cursors use the String type I believe we have two layers of validation:

  • The GraphQL gem validates that the given argument value is a String, so that, for example, if a client submits an array as a String argument it'll return an error saying it wasn't a string.
  • In the paginator you then parse the string as a cursor and confirm its a valid encoded cursor, returning an error if it's not.

We should try to mirror that 2-part validation approach for Cursor. I think that means that we need a Cursor scalar coercion adapter so that it can confirm that the provided cursor value is a string...and then we aan rely on the paginator for parsing the string.

I originally thought the scalar coercion adapter for Cursor would lead to different behavior between String vs Cursor (since there's no String adapter to provide parity) but now I'm realizing that the GraphQL gem provides type-level validation for String already and we need the scalar coercion adapter to do the same for Cursor.

@anthonycastiglia-toast

anthonycastiglia-toast commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

We should try to mirror that 2-part validation approach for Cursor. I think that means that we need a Cursor scalar coercion adapter so that it can confirm that the provided cursor value is a string...and then we aan rely on the paginator for parsing the string.

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 String, the GraphQL gem validates that the input value is actually a string and will throw a validation error if it isn't:

"Argument 'after' on Field 'widgets' has an invalid value ([1, 2, 3]). Expected type 'String'.",

When the cursor is a custom scalar, e.g.Cursor, the GraphQL gem doesn't actually know what a Cursor is or how to validate its type, so we do it in the coercion adapter:

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
end

If that returns nil the GraphQL gem also treats it as a validation error:

"Argument 'after' on Field 'widgets' has an invalid value ([1, 2, 3]). Expected type 'Cursor'."

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.

Comment thread config/site/src/guides/customizing-the-graphql-schema.md Outdated
Comment thread elasticgraph-graphql/spec/acceptance/aggregations_spec.rb Outdated
@myronmarston myronmarston merged commit ac2c10e into block:main Jun 12, 2026
19 checks passed
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