Skip to content

Commit ac2c10e

Browse files
anthonycastiglia-toastclaudemyronmarston
authored
Add support for Cursor type override for federation compatibility (#1231)
### 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](https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo). ### 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 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Myron Marston <myron.marston@gmail.com>
1 parent 7b616e8 commit ac2c10e

22 files changed

Lines changed: 287 additions & 116 deletions

File tree

config/site/examples/schema_customization_rake_tasks/Rakefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,11 @@ ElasticGraph::Local::RakeTasks.new(
3939
# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`:
4040
tasks.type_name_overrides = {JsonSafeLong: "BigInt"}
4141
# :snippet-end:
42+
43+
# :snippet-start: cursor_type_override
44+
# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`:
45+
# Override Cursor to String for federation compatibility with subgraphs
46+
# that use String for cursor fields per the Relay spec.
47+
tasks.type_name_overrides = {Cursor: "String"}
48+
# :snippet-end:
4249
end

config/site/src/guides/customizing-the-graphql-schema.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ scalar for one with a name your team prefers—use [`type_name_overrides`]({% ap
7070
The standard GraphQL scalars (`Boolean`, `Float`, `ID`, `Int`, `String`) and the root `Query` type cannot be renamed
7171
this way.
7272

73+
### Federation Compatibility: Overriding `Cursor` to `String`
74+
75+
When composing an ElasticGraph subgraph into a federated supergraph alongside other subgraphs that use `String` for
76+
cursor fields (as the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) permits),
77+
federation composition may fail with a type incompatibility error. ElasticGraph uses a dedicated `Cursor` scalar type
78+
for cursor fields by default, which provides better type safety and documentation but can cause conflicts.
79+
80+
To resolve this, override the `Cursor` type to `String`:
81+
82+
{% include copyable_code_snippet.html language="ruby" data="schema_customization_rake_tasks.snippets.Rakefile.cursor_type_override" %}
83+
84+
This configuration causes ElasticGraph to:
85+
- Skip registration of the `Cursor` scalar (avoiding duplicate type definitions)
86+
- Use `String` for all cursor-related fields (`PageInfo.startCursor`, `PageInfo.endCursor`, `Edge.cursor`)
87+
- Use `String` for pagination arguments (`before`, `after`)
88+
89+
{: .alert-note}
90+
**Note**{: .alert-title}
91+
The `Cursor` scalar and `String` are semantically identical on the wire—both are opaque base64-encoded strings. The
92+
only difference is that `Cursor` provides more expressive type information in the GraphQL schema. Using `String` for
93+
cursor fields is fully compatible with the Relay specification and is the common convention in most GraphQL implementations.
94+
7395
## Customization Hooks
7496

7597
The schema definition API exposes hooks that let you customize generated types and fields. These hooks are commonly used

elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
require "elastic_graph/errors"
1010
require "elastic_graph/support/memoizable_data"
11+
require "graphql"
1112

1213
module ElasticGraph
1314
class GraphQL
@@ -65,6 +66,18 @@ class Paginator < Support::MemoizableData.define(:default_page_size, :max_page_s
6566
# These methods are provided by `Data.define`:
6667
# @dynamic default_page_size, max_page_size, first, after, last, before, schema_element_names, initialize
6768

69+
# @return [DecodedCursor, nil] the decoded after cursor
70+
def decoded_after
71+
return @decoded_after if defined?(@decoded_after)
72+
@decoded_after = decode_cursor(after)
73+
end
74+
75+
# @return [DecodedCursor, nil] the decoded before cursor
76+
def decoded_before
77+
return @decoded_before if defined?(@decoded_before)
78+
@decoded_before = decode_cursor(before)
79+
end
80+
6881
def requested_page_size
6982
# `+ 1` so we can tell if there are more docs for `has_next_page`/`has_previous_page`
7083
# ...but only if we need to get anything at all.
@@ -86,8 +99,9 @@ def search_in_reverse?
8699
end
87100

88101
# The cursor values to search after (if we need to search after one at all).
102+
# Returns the decoded cursor when available, since callers need access to the decoded structure.
89103
def search_after
90-
search_in_reverse? ? before : after
104+
search_in_reverse? ? decoded_before : decoded_after
91105
end
92106

93107
# In some cases, we're forced to search in reverse; in those caes, this is used to restore
@@ -109,13 +123,13 @@ def truncate_items(items)
109123
# We can't always use `before` and `after` in the datastore query (such as when both are provided!),
110124
# so here we drop items from the start that come on or before `after`, and items from the
111125
# end that come on or after `before`.
112-
if (after_cursor = after)
126+
if (after_cursor = decoded_after)
113127
items = items.drop_while do |doc|
114128
item_sort_values_satisfy?(yield(doc, after_cursor), :<=)
115129
end
116130
end
117131

118-
if (before_cursor = before)
132+
if (before_cursor = decoded_before)
119133
items = items.take_while do |doc|
120134
item_sort_values_satisfy?(yield(doc, before_cursor), :<)
121135
end
@@ -128,7 +142,7 @@ def truncate_items(items)
128142
end
129143

130144
def paginated_from_singleton_cursor?
131-
before == DecodedCursor::SINGLETON || after == DecodedCursor::SINGLETON
145+
decoded_before == DecodedCursor::SINGLETON || decoded_after == DecodedCursor::SINGLETON
132146
end
133147

134148
def desired_page_size
@@ -139,6 +153,13 @@ def desired_page_size
139153

140154
private
141155

156+
def decode_cursor(cursor)
157+
return nil if cursor.nil?
158+
DecodedCursor.decode!(cursor)
159+
rescue Errors::InvalidCursorError => e
160+
raise ::GraphQL::ExecutionError, e.message
161+
end
162+
142163
def first_n
143164
@first_n ||= size_arg_value(:first, first)
144165
end

elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,20 @@
66
#
77
# frozen_string_literal: true
88

9-
require "elastic_graph/graphql/decoded_cursor"
10-
119
module ElasticGraph
1210
class GraphQL
1311
module ScalarCoercionAdapters
12+
# Coercion adapter for the Cursor scalar type.
13+
# Validates that cursor values are strings. When given a non-string value, returns nil
14+
# to trigger GraphQL-Ruby's validation error with full field context.
1415
class Cursor
1516
def self.coerce_input(value, ctx)
16-
case value
17-
when DecodedCursor
18-
value
19-
when ::String
20-
DecodedCursor.try_decode(value)
21-
end
17+
return value if value.nil? || value.is_a?(::String)
18+
nil # Returning nil causes GraphQL-Ruby to generate a validation error
2219
end
2320

2421
def self.coerce_result(value, ctx)
25-
case value
26-
when DecodedCursor
27-
value.encode
28-
when ::String
29-
value if DecodedCursor.try_decode(value)
30-
end
22+
value
3123
end
3224
end
3325
end

elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,26 @@ module ElasticGraph
1010
attr_reader default_page_size: ::Integer
1111
attr_reader max_page_size: ::Integer
1212
attr_reader first: ::Integer?
13-
attr_reader after: DecodedCursor?
13+
attr_reader after: ::String?
1414
attr_reader last: ::Integer?
15-
attr_reader before: DecodedCursor?
15+
attr_reader before: ::String?
1616
attr_reader schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames
1717

1818
def initialize: (
1919
default_page_size: ::Integer,
2020
max_page_size: ::Integer,
2121
first: ::Integer?,
22-
after: DecodedCursor?,
22+
after: ::String?,
2323
last: ::Integer?,
24-
before: DecodedCursor?,
24+
before: ::String?,
2525
schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames) -> void
2626

27+
@decoded_after: DecodedCursor?
28+
def decoded_after: () -> DecodedCursor?
29+
30+
@decoded_before: DecodedCursor?
31+
def decoded_before: () -> DecodedCursor?
32+
2733
def requested_page_size: () -> ::Integer
2834
def search_in_reverse?: () -> boolish
2935
def search_after: () -> DecodedCursor?
@@ -36,6 +42,8 @@ module ElasticGraph
3642

3743
private
3844

45+
def decode_cursor: (::String?) -> DecodedCursor?
46+
3947
@first_n: ::Integer?
4048
def first_n: () -> ::Integer?
4149

elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module ElasticGraph
22
class GraphQL
33
module ScalarCoercionAdapters
44
class Cursor
5-
extend SchemaArtifacts::_ScalarCoercionAdapter[DecodedCursor, ::String]
5+
extend SchemaArtifacts::_ScalarCoercionAdapter[::String, ::String]
66
end
77
end
88
end

elasticgraph-graphql/spec/acceptance/aggregations_spec.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,16 +1404,21 @@ def forward_paginate_through_workspace_id_groupings
14041404
{"count" => 2, grouped_by => {case_correctly("workspace_id") => "w2"}}
14051405
]
14061406

1407+
# Both contexts (Cursor scalar and String override) now produce consistent error messages
1408+
# because the coercion adapter returns nil for invalid values, causing GraphQL to generate
1409+
# validation errors with full field context.
1410+
array_error = "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'."
1411+
14071412
expect {
14081413
response = list_widget_workspace_id_groupings(first: 2, after: [1, 2, 3], expect_errors: true)
1409-
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type 'Cursor'."))
1410-
}.to log_warning a_string_including("Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value", "[1, 2, 3]")
1414+
expect(response["errors"]).to contain_exactly(a_hash_including("message" => array_error))
1415+
}.to log_warning a_string_including(array_error)
14111416

14121417
broken_cursor = page_info.fetch(case_correctly("end_cursor")) + "-broken"
14131418
expect {
14141419
response = list_widget_workspace_id_groupings(first: 2, after: broken_cursor, expect_errors: true)
1415-
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value (#{broken_cursor.inspect}). Expected type 'Cursor'."))
1416-
}.to log_warning a_string_including("Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value", broken_cursor)
1420+
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "`#{broken_cursor}` is an invalid cursor."))
1421+
}.to log_warning a_string_including("`#{broken_cursor}` is an invalid cursor.")
14171422

14181423
page_info, workspace_nodes = list_widget_workspace_id_groupings(first: 2, after: page_info.fetch(case_correctly("end_cursor")))
14191424

elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def self.with_both_casing_forms(&block)
6060
module_exec(&block)
6161
end
6262

63-
context "with a camelCase schema, alternate derived type naming, and enum value overrides" do
63+
context "with a camelCase schema, `String` cursors, alternate derived type naming, and enum value overrides" do
6464
include CamelCaseGraphQLAcceptanceAdapter
6565

6666
# Need to use a local variable instead of an instance variable for the context state,
@@ -90,6 +90,7 @@ def self.with_both_casing_forms(&block)
9090
datastore_backend: datastore_backend,
9191
schema_element_name_form: :camelCase,
9292
derived_type_name_formats: derived_type_name_formats,
93+
type_name_overrides: {Cursor: "String"},
9394
enum_value_overrides_by_type: enum_value_overrides_by_type,
9495
schema_definition: ->(schema) do
9596
# standard:disable Security/Eval -- it's ok here in a test.
@@ -164,6 +165,12 @@ def apply_derived_type_customizations(type_name)
164165
type_name
165166
end
166167

168+
# Returns the cursor type name used in this schema configuration.
169+
# In snake_case schemas, we use the default Cursor scalar.
170+
def cursor_type
171+
"Cursor"
172+
end
173+
167174
# For parity with our `camelCase` context, also roundtrip factory-built records through JSON.
168175
# Otherwise we can have subtle, surprising differences between the two casing contexts. For
169176
# example, if the factory puts a `Date` object in a record, the JSON roundtripping will convert
@@ -184,6 +191,12 @@ def enum_value(value)
184191
end
185192
end
186193

194+
# Returns the cursor type name used in this schema configuration.
195+
# In camelCase schemas, we override Cursor to String for testing.
196+
def cursor_type
197+
"String"
198+
end
199+
187200
def configure_for_camel_case(config)
188201
# Provide the same index definition settings, but for the `_camel` indices.
189202
original_index_defs = config.index_definitions

elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ module ElasticGraph
121121
FloatAggregatedValues IntAggregatedValues JsonSafeLongAggregatedValues LongStringAggregatedValues NonNumericAggregatedValues
122122
DateAggregatedValues DateTimeAggregatedValues LocalTimeAggregatedValues
123123
Company OnlineStore DirectWholesaler BrokerWholesaler
124-
Cursor PageInfo Query TextFilterInput GeoLocation
124+
PageInfo Query TextFilterInput GeoLocation
125125
DateTimeGroupingOffsetInput DateTimeUnitInput DateTimeTimeOfDayFilterInput
126126
DateGroupedBy DateGroupingOffsetInput DateGroupingTruncationUnitInput DateUnitInput
127127
DateTimeGroupedBy DateTimeGroupingTruncationUnitInput TimeZone
@@ -130,7 +130,10 @@ module ElasticGraph
130130
LocalTimeGroupingOffsetInput LocalTimeGroupingTruncationUnitInput LocalTimeUnitInput MatchesQueryFilterInput
131131
MatchesPhraseFilterInput MatchesQueryWithPrefixFilterInput MatchesQueryAllowedEditsPerTermInput StringContainsFilterInput StringStartsWithFilterInput
132132
SearchHighlight
133-
]
133+
] +
134+
# Cursor is conditionally included because when it's overridden to a built-in type like String
135+
# (as in the camelCase test context), the Cursor scalar is not registered in the schema.
136+
(all_fields_by_type_name.key?("Cursor") ? ["Cursor"] : [])
134137

135138
# The sub-aggregation types are quite complicated and we just add them all here.
136139
expected_types_present_on_both_schemas += %w[

elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,14 +307,19 @@ module ElasticGraph
307307
}.to log_warning a_string_including("`first` cannot be negative, but is -2.")
308308

309309
# Demonstrate how broken cursors behave.
310+
# Both contexts (Cursor scalar and String override) now produce consistent error messages
311+
# because the coercion adapter returns nil for invalid values, causing GraphQL to generate
312+
# validation errors with full field context.
313+
array_error = "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'."
314+
310315
expect {
311316
response = query_widgets_and_components_including_page_info(
312317
widget_args: {first: 1, order_by: [:amount_cents_ASC]},
313318
component_args: {first: 1, after: [1, 2, 3], order_by: [:name_ASC]},
314319
expect_errors: true
315320
)
316-
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type 'Cursor'."))
317-
}.to log_warning a_string_including("Argument 'after' on Field 'components' has an invalid value", "[1, 2, 3]")
321+
expect(response["errors"]).to contain_exactly(a_hash_including("message" => array_error))
322+
}.to log_warning a_string_including(array_error)
318323

319324
broken_cursor = results["edges"][0]["node"]["components"][case_correctly "page_info"][case_correctly "end_cursor"] + "-broken"
320325
expect {
@@ -323,8 +328,8 @@ module ElasticGraph
323328
component_args: {first: 1, after: broken_cursor, order_by: [:name_ASC]},
324329
expect_errors: true
325330
)
326-
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "Argument 'after' on Field 'components' has an invalid value (#{broken_cursor.inspect}). Expected type 'Cursor'."))
327-
}.to log_warning a_string_including("Argument 'after' on Field 'components' has an invalid value", broken_cursor)
331+
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "`#{broken_cursor}` is an invalid cursor."))
332+
}.to log_warning a_string_including("`#{broken_cursor}` is an invalid cursor.")
328333

329334
# get next page of components (but still on the first page of widgets)
330335
results = query_widgets_and_components_including_page_info(

0 commit comments

Comments
 (0)