Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a2a027c
Add support for Cursor type override for federation compatibility
anthonycastiglia-toast Jun 2, 2026
de3d6b2
Fix integration tests to pass encoded cursor strings
anthonycastiglia-toast Jun 2, 2026
d0bcf91
Fix pagination_spec cursor_of helper to return encoded strings
anthonycastiglia-toast Jun 3, 2026
5adb6e8
Add backward compatibility for DecodedCursor objects in Paginator
anthonycastiglia-toast Jun 3, 2026
a45b85b
Fix test failures
anthonycastiglia-toast Jun 3, 2026
98d1e79
Add missing import
anthonycastiglia-toast Jun 3, 2026
2cd0bee
Fix awkward punctuation
anthonycastiglia-toast Jun 3, 2026
392e6bc
Increse branch coverage to 100%
anthonycastiglia-toast Jun 3, 2026
1b191cd
Update config/site/src/guides/customizing-the-graphql-schema.md
anthonycastiglia-toast Jun 10, 2026
3535167
Update config/site/src/guides/customizing-the-graphql-schema.md
anthonycastiglia-toast Jun 10, 2026
e52058a
Update elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query…
anthonycastiglia-toast Jun 10, 2026
d569758
Require cursor to always be a string, remove coercion
anthonycastiglia-toast Jun 10, 2026
f1d830d
Revert unnecessary cursor_type_name call
anthonycastiglia-toast Jun 10, 2026
8291c7e
Apply suggestion from @myronmarston
anthonycastiglia-toast Jun 10, 2026
ef4ad1a
Use simple coercion adapter to validate cursor scalars
anthonycastiglia-toast Jun 11, 2026
ab59a88
Allow overriding cursor scalar with custom type name
anthonycastiglia-toast Jun 11, 2026
ebf5524
Conditionally configure warehouse type to avoid when the module isn'…
anthonycastiglia-toast Jun 11, 2026
e745c6d
Remove unused support method parameter
anthonycastiglia-toast Jun 11, 2026
b8b6cd7
Add nocov annotation to branch that is conditionally executed when th…
anthonycastiglia-toast Jun 11, 2026
d53fb38
Apply suggestions from code review
anthonycastiglia-toast Jun 12, 2026
3f457b5
Add support method
anthonycastiglia-toast Jun 12, 2026
e23423d
Link documentation to validated snippet
anthonycastiglia-toast Jun 12, 2026
0afb627
Apply suggestions from code review
anthonycastiglia-toast Jun 12, 2026
e586d2a
Use scalar coercion adapter shared context in cursor spec
anthonycastiglia-toast Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,11 @@ ElasticGraph::Local::RakeTasks.new(
# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`:
tasks.type_name_overrides = {JsonSafeLong: "BigInt"}
# :snippet-end:

# :snippet-start: cursor_type_override
# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`:
# Override Cursor to String for federation compatibility with subgraphs
# that use String for cursor fields per the Relay spec.
tasks.type_name_overrides = {Cursor: "String"}
# :snippet-end:
end
22 changes: 22 additions & 0 deletions config/site/src/guides/customizing-the-graphql-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ scalar for one with a name your team prefers—use [`type_name_overrides`]({% ap
The standard GraphQL scalars (`Boolean`, `Float`, `ID`, `Int`, `String`) and the root `Query` type cannot be renamed
this way.

### Federation Compatibility: Overriding `Cursor` to `String`

When composing an ElasticGraph subgraph into a federated supergraph alongside other subgraphs that use `String` for
cursor fields (as the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) permits),
federation composition may fail with a type incompatibility error. ElasticGraph uses a dedicated `Cursor` scalar type
for cursor fields by default, which provides better type safety and documentation but can cause conflicts.

To resolve this, override the `Cursor` type to `String`:

{% include copyable_code_snippet.html language="ruby" data="schema_customization_rake_tasks.snippets.Rakefile.cursor_type_override" %}

This configuration causes ElasticGraph to:
- Skip registration of the `Cursor` scalar (avoiding duplicate type definitions)
- Use `String` for all cursor-related fields (`PageInfo.startCursor`, `PageInfo.endCursor`, `Edge.cursor`)
- Use `String` for pagination arguments (`before`, `after`)

{: .alert-note}
**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.
Comment thread
myronmarston marked this conversation as resolved.

## Customization Hooks

The schema definition API exposes hooks that let you customize generated types and fields. These hooks are commonly used
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

require "elastic_graph/errors"
require "elastic_graph/support/memoizable_data"
require "graphql"

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

# @return [DecodedCursor, nil] the decoded after cursor
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
Comment thread
anthonycastiglia-toast marked this conversation as resolved.

def requested_page_size
# `+ 1` so we can tell if there are more docs for `has_next_page`/`has_previous_page`
# ...but only if we need to get anything at all.
Expand All @@ -86,8 +99,9 @@ def search_in_reverse?
end

# The cursor values to search after (if we need to search after one at all).
# Returns the decoded cursor when available, since callers need access to the decoded structure.
def search_after
search_in_reverse? ? before : after
search_in_reverse? ? decoded_before : decoded_after
end

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

if (before_cursor = before)
if (before_cursor = decoded_before)
items = items.take_while do |doc|
item_sort_values_satisfy?(yield(doc, before_cursor), :<)
end
Expand All @@ -128,7 +142,7 @@ def truncate_items(items)
end

def paginated_from_singleton_cursor?
before == DecodedCursor::SINGLETON || after == DecodedCursor::SINGLETON
decoded_before == DecodedCursor::SINGLETON || decoded_after == DecodedCursor::SINGLETON
end

def desired_page_size
Expand All @@ -139,6 +153,13 @@ def desired_page_size

private

def decode_cursor(cursor)
return nil if cursor.nil?
DecodedCursor.decode!(cursor)
rescue Errors::InvalidCursorError => e
raise ::GraphQL::ExecutionError, e.message
end

def first_n
@first_n ||= size_arg_value(:first, first)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,20 @@
#
# frozen_string_literal: true

require "elastic_graph/graphql/decoded_cursor"

module ElasticGraph
class GraphQL
module ScalarCoercionAdapters
# Coercion adapter for the Cursor scalar type.
# Validates that cursor values are strings. When given a non-string value, returns nil
# to trigger GraphQL-Ruby's validation error with full field context.
class Cursor
def self.coerce_input(value, ctx)
case value
when DecodedCursor
value
when ::String
DecodedCursor.try_decode(value)
end
return value if value.nil? || value.is_a?(::String)
nil # Returning nil causes GraphQL-Ruby to generate a validation error
end

def self.coerce_result(value, ctx)
case value
when DecodedCursor
value.encode
when ::String
value if DecodedCursor.try_decode(value)
end
value
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,26 @@ module ElasticGraph
attr_reader default_page_size: ::Integer
attr_reader max_page_size: ::Integer
attr_reader first: ::Integer?
attr_reader after: DecodedCursor?
attr_reader after: ::String?
attr_reader last: ::Integer?
attr_reader before: DecodedCursor?
attr_reader before: ::String?
attr_reader schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames

def initialize: (
default_page_size: ::Integer,
max_page_size: ::Integer,
first: ::Integer?,
after: DecodedCursor?,
after: ::String?,
last: ::Integer?,
before: DecodedCursor?,
before: ::String?,
schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames) -> void

@decoded_after: DecodedCursor?
def decoded_after: () -> DecodedCursor?

@decoded_before: DecodedCursor?
def decoded_before: () -> DecodedCursor?

def requested_page_size: () -> ::Integer
def search_in_reverse?: () -> boolish
def search_after: () -> DecodedCursor?
Expand All @@ -36,6 +42,8 @@ module ElasticGraph

private

def decode_cursor: (::String?) -> DecodedCursor?

@first_n: ::Integer?
def first_n: () -> ::Integer?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module ElasticGraph
class GraphQL
module ScalarCoercionAdapters
class Cursor
extend SchemaArtifacts::_ScalarCoercionAdapter[DecodedCursor, ::String]
extend SchemaArtifacts::_ScalarCoercionAdapter[::String, ::String]
end
end
end
Expand Down
13 changes: 9 additions & 4 deletions elasticgraph-graphql/spec/acceptance/aggregations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1404,16 +1404,21 @@ def forward_paginate_through_workspace_id_groupings
{"count" => 2, grouped_by => {case_correctly("workspace_id") => "w2"}}
]

# Both contexts (Cursor scalar and String override) now produce consistent error messages
# because the coercion adapter returns nil for invalid values, causing GraphQL to generate
# validation errors with full field context.
array_error = "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'."

expect {
response = list_widget_workspace_id_groupings(first: 2, after: [1, 2, 3], expect_errors: true)
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'."))
}.to log_warning a_string_including("Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value", "[1, 2, 3]")
expect(response["errors"]).to contain_exactly(a_hash_including("message" => array_error))
}.to log_warning a_string_including(array_error)

broken_cursor = page_info.fetch(case_correctly("end_cursor")) + "-broken"
expect {
response = list_widget_workspace_id_groupings(first: 2, after: broken_cursor, expect_errors: true)
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'."))
}.to log_warning a_string_including("Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value", broken_cursor)
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "`#{broken_cursor}` is an invalid cursor."))
}.to log_warning a_string_including("`#{broken_cursor}` is an invalid cursor.")

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def self.with_both_casing_forms(&block)
module_exec(&block)
end

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

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

# Returns the cursor type name used in this schema configuration.
# In snake_case schemas, we use the default Cursor scalar.
def cursor_type
"Cursor"
end

# For parity with our `camelCase` context, also roundtrip factory-built records through JSON.
# Otherwise we can have subtle, surprising differences between the two casing contexts. For
# example, if the factory puts a `Date` object in a record, the JSON roundtripping will convert
Expand All @@ -184,6 +191,12 @@ def enum_value(value)
end
end

# Returns the cursor type name used in this schema configuration.
# In camelCase schemas, we override Cursor to String for testing.
def cursor_type
"String"
end

def configure_for_camel_case(config)
# Provide the same index definition settings, but for the `_camel` indices.
original_index_defs = config.index_definitions
Expand Down
7 changes: 5 additions & 2 deletions elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ module ElasticGraph
FloatAggregatedValues IntAggregatedValues JsonSafeLongAggregatedValues LongStringAggregatedValues NonNumericAggregatedValues
DateAggregatedValues DateTimeAggregatedValues LocalTimeAggregatedValues
Company OnlineStore DirectWholesaler BrokerWholesaler
Cursor PageInfo Query TextFilterInput GeoLocation
PageInfo Query TextFilterInput GeoLocation
DateTimeGroupingOffsetInput DateTimeUnitInput DateTimeTimeOfDayFilterInput
DateGroupedBy DateGroupingOffsetInput DateGroupingTruncationUnitInput DateUnitInput
DateTimeGroupedBy DateTimeGroupingTruncationUnitInput TimeZone
Expand All @@ -130,7 +130,10 @@ module ElasticGraph
LocalTimeGroupingOffsetInput LocalTimeGroupingTruncationUnitInput LocalTimeUnitInput MatchesQueryFilterInput
MatchesPhraseFilterInput MatchesQueryWithPrefixFilterInput MatchesQueryAllowedEditsPerTermInput StringContainsFilterInput StringStartsWithFilterInput
SearchHighlight
]
] +
# Cursor is conditionally included because when it's overridden to a built-in type like String
# (as in the camelCase test context), the Cursor scalar is not registered in the schema.
(all_fields_by_type_name.key?("Cursor") ? ["Cursor"] : [])

# The sub-aggregation types are quite complicated and we just add them all here.
expected_types_present_on_both_schemas += %w[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,14 +307,19 @@ module ElasticGraph
}.to log_warning a_string_including("`first` cannot be negative, but is -2.")

# Demonstrate how broken cursors behave.
# Both contexts (Cursor scalar and String override) now produce consistent error messages
# because the coercion adapter returns nil for invalid values, causing GraphQL to generate
# validation errors with full field context.
array_error = "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'."

expect {
response = query_widgets_and_components_including_page_info(
widget_args: {first: 1, order_by: [:amount_cents_ASC]},
component_args: {first: 1, after: [1, 2, 3], order_by: [:name_ASC]},
expect_errors: true
)
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'."))
}.to log_warning a_string_including("Argument 'after' on Field 'components' has an invalid value", "[1, 2, 3]")
expect(response["errors"]).to contain_exactly(a_hash_including("message" => array_error))
}.to log_warning a_string_including(array_error)

broken_cursor = results["edges"][0]["node"]["components"][case_correctly "page_info"][case_correctly "end_cursor"] + "-broken"
expect {
Expand All @@ -323,8 +328,8 @@ module ElasticGraph
component_args: {first: 1, after: broken_cursor, order_by: [:name_ASC]},
expect_errors: true
)
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'."))
}.to log_warning a_string_including("Argument 'after' on Field 'components' has an invalid value", broken_cursor)
expect(response["errors"]).to contain_exactly(a_hash_including("message" => "`#{broken_cursor}` is an invalid cursor."))
}.to log_warning a_string_including("`#{broken_cursor}` is an invalid cursor.")

# get next page of components (but still on the first page of widgets)
results = query_widgets_and_components_including_page_info(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ def index_doc_with_null_value
end

def paginated_search(first: nil, after: nil, last: nil, before: nil, groupings: [field_term_grouping_of("name")], computations: [], filter_to: nil)
# Encode cursors (all cursors in aggregation tests are DecodedCursor objects)
after = after&.encode
before = before&.encode

aggregation_query = aggregation_query_of(
groupings: groupings, computations: computations,
first: first, after: after, last: last, before: before,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ def index_doc_with_null_value
end

def paginated_search(first: nil, after: nil, last: nil, before: nil, document_pagination: nil, sort: sort_list, filter_to: nil)
# Encode cursors (all cursors in document pagination tests are DecodedCursor objects)
after = after&.encode
before = before&.encode

document_pagination ||= {first: first, after: after, last: last, before: before}.compact
query = nil
response = search_datastore(
Expand Down
12 changes: 5 additions & 7 deletions elasticgraph-graphql/spec/support/scalar_coercion_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,18 @@ def execute_query_returning(value)
@graphql.graphql_query_executor.execute(@query).to_h
end

def expect_input_value_to_be_accepted(value, as: value, only_test_variable: false)
def expect_input_value_to_be_accepted(value, as: value)
response = execute_query_with_variable_value(value)

expect(response).not_to include("errors")
expect(response).to eq({"data" => {"echo" => nil}})
expect(@test_resolver.last_arg_value).to eq(as)

unless only_test_variable
response = execute_query_with_inline_query_value(value)
response = execute_query_with_inline_query_value(value)

expect(response).not_to include("errors")
expect(response).to eq({"data" => {"echo" => nil}})
expect(@test_resolver.last_arg_value).to eq(as)
end
expect(response).not_to include("errors")
expect(response).to eq({"data" => {"echo" => nil}})
expect(@test_resolver.last_arg_value).to eq(as)
end

# Use `define_method` instead of `def` to have access to `scalar_type_name`
Expand Down
Loading