From a2a027cb522a1be38454697c726b4918d2e2eb03 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Tue, 2 Jun 2026 13:48:40 -0700 Subject: [PATCH 01/24] Add support for Cursor type override for federation compatibility 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 #1028 Co-Authored-By: Claude Opus 4.6 --- .../schema_customization_rake_tasks/Rakefile | 7 + .../guides/customizing-the-graphql-schema.md | 29 ++++ .../graphql/datastore_query/paginator.rb | 27 +++- .../scalar_coercion_adapters/cursor.rb | 12 +- .../graphql/datastore_query/paginator.rbs | 16 ++- .../scalar_coercion_adapters/cursor.rbs | 2 +- .../graphql/datastore_query/paginator_spec.rb | 128 ++++++++++++++++++ .../scalar_coercion_adapters/cursor_spec.rb | 72 +++------- .../schema_definition/factory.rb | 2 +- .../schema_elements/built_in_types.rb | 61 ++++++--- .../schema_elements/field.rb | 4 +- .../schema_elements/type_namer.rb | 6 + .../schema_elements/type_namer.rbs | 1 + .../graphql_schema/built_in_types_spec.rb | 83 ++++++++++++ 14 files changed, 362 insertions(+), 88 deletions(-) create mode 100644 elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb diff --git a/config/site/examples/schema_customization_rake_tasks/Rakefile b/config/site/examples/schema_customization_rake_tasks/Rakefile index 5a8baaf22..1fd03cea2 100644 --- a/config/site/examples/schema_customization_rake_tasks/Rakefile +++ b/config/site/examples/schema_customization_rake_tasks/Rakefile @@ -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 diff --git a/config/site/src/guides/customizing-the-graphql-schema.md b/config/site/src/guides/customizing-the-graphql-schema.md index 456e7adc6..46086ebc0 100644 --- a/config/site/src/guides/customizing-the-graphql-schema.md +++ b/config/site/src/guides/customizing-the-graphql-schema.md @@ -70,6 +70,35 @@ 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 (following the [Relay GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm)), +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`: + +```ruby +# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`: +tasks.type_name_overrides = {Cursor: "String"} +``` + +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`) + +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. + +{: .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. + ## Customization Hooks The schema definition API exposes hooks that let you customize generated types and fields. These hooks are commonly used diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index 2281fc13d..0061ac21e 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -65,6 +65,16 @@ 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 + @decoded_after ||= decode_cursor(after) + end + + # @return [DecodedCursor, nil] the decoded before cursor + def decoded_before + @decoded_before ||= decode_cursor(before) + end + 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. @@ -86,8 +96,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 @@ -109,13 +120,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 @@ -128,7 +139,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 @@ -139,6 +150,14 @@ def desired_page_size private + # Decodes a cursor string to a DecodedCursor object. + # @param cursor [String, nil] the cursor string to decode + # @return [DecodedCursor, nil] the decoded cursor + def decode_cursor(cursor) + return nil if cursor.nil? + DecodedCursor.decode!(cursor) + end + def first_n @first_n ||= size_arg_value(:first, first) end diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb index 62a5ccaf0..deb118b14 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb @@ -15,19 +15,15 @@ class Cursor def self.coerce_input(value, ctx) case value when DecodedCursor - value + value.encode when ::String - DecodedCursor.try_decode(value) + value end end def self.coerce_result(value, ctx) - case value - when DecodedCursor - value.encode - when ::String - value if DecodedCursor.try_decode(value) - end + # Pass-through: resolvers already encode cursors to strings + value end end end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs index c42566aa9..8e35a9946 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs @@ -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 | nil) attr_reader last: ::Integer? - attr_reader before: DecodedCursor? + attr_reader before: (::String | nil) attr_reader schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames def initialize: ( default_page_size: ::Integer, max_page_size: ::Integer, first: ::Integer?, - after: DecodedCursor?, + after: (::String | nil), last: ::Integer?, - before: DecodedCursor?, + before: (::String | nil), 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? @@ -36,6 +42,8 @@ module ElasticGraph private + def decode_cursor: (::String?) -> DecodedCursor? + @first_n: ::Integer? def first_n: () -> ::Integer? diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs index 3b613567f..e212bd14b 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs @@ -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 diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb new file mode 100644 index 000000000..da4012f34 --- /dev/null +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb @@ -0,0 +1,128 @@ +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +require "elastic_graph/graphql/datastore_query/paginator" +require "elastic_graph/graphql/decoded_cursor" +require "elastic_graph/schema_artifacts/runtime_metadata/schema_element_names" + +module ElasticGraph + class GraphQL + class DatastoreQuery + RSpec.describe Paginator do + let(:schema_element_names) { SchemaArtifacts::RuntimeMetadata::SchemaElementNames.new(form: "snake_case") } + let(:sort_values) { {"id" => "abc123", "created_at" => "2024-01-01T00:00:00Z"} } + let(:decoded_cursor) { DecodedCursor.new(sort_values) } + let(:encoded_cursor_string) { decoded_cursor.encode } + + def build_paginator(after: nil, before: nil, first: nil, last: nil) + Paginator.new( + default_page_size: 25, + max_page_size: 100, + first: first, + after: after, + last: last, + before: before, + schema_element_names: schema_element_names + ) + end + + describe "lazy cursor decoding" do + it "accepts a String for `after` and lazily decodes it in `decoded_after`" do + paginator = build_paginator(after: encoded_cursor_string) + + expect(paginator.decoded_after).to be_a(DecodedCursor) + expect(paginator.decoded_after.sort_values).to eq(sort_values) + end + + it "accepts a String for `before` and lazily decodes it in `decoded_before`" do + paginator = build_paginator(before: encoded_cursor_string) + + expect(paginator.decoded_before).to be_a(DecodedCursor) + expect(paginator.decoded_before.sort_values).to eq(sort_values) + end + + it "returns nil from `decoded_after` when `after` is nil" do + paginator = build_paginator + + expect(paginator.decoded_after).to be_nil + end + + it "returns nil from `decoded_before` when `before` is nil" do + paginator = build_paginator + + expect(paginator.decoded_before).to be_nil + end + + it "memoizes the decoded cursor to avoid repeated decoding" do + paginator = build_paginator(after: encoded_cursor_string) + + first_call = paginator.decoded_after + second_call = paginator.decoded_after + + expect(first_call).to be(second_call) + end + + it "raises InvalidCursorError when decoding an invalid cursor string" do + invalid_cursor = "invalid_base64_!@#%" + paginator = build_paginator(after: invalid_cursor) + + expect { + paginator.decoded_after + }.to raise_error(Errors::InvalidCursorError, a_string_including(invalid_cursor)) + end + end + + describe "#search_after" do + it "returns the decoded after cursor when not searching in reverse" do + paginator = build_paginator(first: 10, after: encoded_cursor_string) + + expect(paginator.search_in_reverse?).to be false + expect(paginator.search_after).to be_a(DecodedCursor) + expect(paginator.search_after.sort_values).to eq(sort_values) + end + + it "returns the decoded before cursor when searching in reverse" do + paginator = build_paginator(last: 10, before: encoded_cursor_string) + + expect(paginator.search_in_reverse?).to be_truthy + expect(paginator.search_after).to be_a(DecodedCursor) + expect(paginator.search_after.sort_values).to eq(sort_values) + end + end + + describe "#paginated_from_singleton_cursor?" do + it "returns true when decoded_after is the singleton cursor" do + singleton_cursor_string = DecodedCursor::SINGLETON.encode + paginator = build_paginator(after: singleton_cursor_string) + + expect(paginator.paginated_from_singleton_cursor?).to be true + end + + it "returns true when decoded_before is the singleton cursor" do + singleton_cursor_string = DecodedCursor::SINGLETON.encode + paginator = build_paginator(before: singleton_cursor_string) + + expect(paginator.paginated_from_singleton_cursor?).to be true + end + + it "returns false when neither cursor is the singleton" do + paginator = build_paginator(after: encoded_cursor_string) + + expect(paginator.paginated_from_singleton_cursor?).to be false + end + + it "returns false when both cursors are nil" do + paginator = build_paginator + + expect(paginator.paginated_from_singleton_cursor?).to be false + end + end + end + end + end +end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb index b6f9382db..48dd0535f 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb @@ -15,80 +15,48 @@ module ScalarCoercionAdapters include_context "scalar coercion adapter support", "Cursor" context "input coercion" do - it "accepts a properly encoded string cursor" do + it "accepts a properly encoded string cursor and returns it as a string" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - expect_input_value_to_be_accepted(cursor.encode, as: cursor) + encoded = cursor.encode + expect_input_value_to_be_accepted(encoded, as: encoded) end - it "accepts an already decoded cursor" do - cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - expect_input_value_to_be_accepted(cursor, only_test_variable: true) - end - - it "accepts the special singleton cursor string value" do - expect_input_value_to_be_accepted(DecodedCursor::SINGLETON.encode, as: DecodedCursor::SINGLETON) - end - - it "accepts the special singleton cursor value" do - expect_input_value_to_be_accepted(DecodedCursor::SINGLETON, only_test_variable: true) + it "accepts the special singleton cursor string value and returns it as a string" do + encoded = DecodedCursor::SINGLETON.encode + expect_input_value_to_be_accepted(encoded, as: encoded) end it "accepts a `nil` value as-is" do expect_input_value_to_be_accepted(nil) end - it "rejects values that are not strings" do - expect_input_value_to_be_rejected(3) - expect_input_value_to_be_rejected(3.7) - expect_input_value_to_be_rejected(false) - expect_input_value_to_be_rejected([1, 2, 3]) - expect_input_value_to_be_rejected(["a", "b"]) - expect_input_value_to_be_rejected({"a" => 1, "b" => "foo"}) - end - - it "rejects broken string cursors" do + it "accepts broken string cursors (validation deferred to Paginator)" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}).encode - expect_input_value_to_be_rejected(cursor + "-broken") + broken_cursor = cursor + "-broken" + # Coercion passes it through; Paginator will raise InvalidCursorError when decoding + expect_input_value_to_be_accepted(broken_cursor, as: broken_cursor) end end context "result coercion" do - it "returns the encoded form of a decoded string cursor" do - cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - expect_result_to_be_returned(cursor, as: cursor.encode) - end + # Note: Resolvers always return already-encoded cursor strings (via Edge#cursor + # which calls DecodedCursor#encode), so coerce_result just passes values through. + # These tests verify the pass-through behavior. - it "returns a properly encoded cursor as-is" do + it "returns a properly encoded cursor string as-is" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - expect_result_to_be_returned(cursor.encode, as: cursor.encode) - end - - it "returns the encoded form of the special singleton cursor" do - cursor = DecodedCursor::SINGLETON - expect_result_to_be_returned(cursor, as: cursor.encode) + encoded = cursor.encode + expect_result_to_be_returned(encoded, as: encoded) end - it "returns the encoded form of the special singleton cursor as-is when given in its string form" do - cursor = DecodedCursor::SINGLETON - expect_result_to_be_returned(cursor.encode, as: cursor.encode) + it "returns the singleton cursor string as-is" do + encoded = DecodedCursor::SINGLETON.encode + expect_result_to_be_returned(encoded, as: encoded) end - it "returns `nil` as is" do + it "returns `nil` as-is" do expect_result_to_be_returned(nil) end - - it "returns `nil` for non-string values" do - expect_result_to_be_replaced_with_nil(3) - expect_result_to_be_replaced_with_nil(3.7) - expect_result_to_be_replaced_with_nil(false) - expect_result_to_be_replaced_with_nil([1, 2, 3]) - expect_result_to_be_replaced_with_nil(["a", "b"]) - expect_result_to_be_replaced_with_nil({"a" => 1, "b" => "foo"}) - end - - it "returns `nil` for strings that are not properly encoded cursors" do - expect_result_to_be_replaced_with_nil("not a cursor") - end end end end diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb index 786fbf82a..f3a3de31e 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb @@ -519,7 +519,7 @@ def edge_type_for(type_name) f.documentation "The `#{type_name}` of this edge." end - t.field @state.schema_elements.cursor, "Cursor" do |f| + t.field @state.schema_elements.cursor, @state.type_namer.cursor_type_name do |f| f.documentation <<~EOS The `Cursor` of this `#{type_name}`. This can be passed in the next query as a `before` or `after` argument to continue paginating from this `#{type_name}`. diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index df513de53..2ad7ffa24 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -161,6 +161,14 @@ module SchemaElements # @!attribute [rw] names # @private class BuiltInTypes + # Built-in GraphQL scalar types that are part of the GraphQL specification. + # Used to detect when type_name_overrides maps to a built-in type that should + # not be registered. + BUILT_IN_SCALARS = %w[String Int Float Boolean ID].freeze + + # Valid types that can be used for cursor fields (string-compatible scalars). + VALID_CURSOR_TYPES = %w[String ID].freeze + attr_reader :schema_def_api, :schema_def_state, :names # @private @@ -184,6 +192,19 @@ def register_built_in_types private + def validate_cursor_type_override! + override_name = @schema_def_state.type_namer.cursor_type_name + return if override_name == "Cursor" + return if VALID_CURSOR_TYPES.include?(override_name) + + raise Errors::SchemaError, <<~ERROR + Invalid cursor type override: `Cursor` was overridden to `#{override_name}`, but cursor types must be string-compatible. + Valid cursor type overrides are: #{VALID_CURSOR_TYPES.join(", ")} (String recommended for federation compatibility). + + To fix: tasks.type_name_overrides = {Cursor: "String"} + ERROR + end + def register_directives # Note: The `eg` prefix is being used based on a GraphQL Spec recommendation: # http://spec.graphql.org/October2021/#sec-Type-System.Directives.Custom-Directives @@ -500,14 +521,14 @@ def register_standard_elastic_graph_types f.documentation "Indicates if there is another page of results available before the current one." end - t.field names.start_cursor, "Cursor", graphql_only: true do |f| + t.field names.start_cursor, @schema_def_state.type_namer.cursor_type_name, graphql_only: true do |f| f.documentation <<~EOS The `Cursor` of the first edge of the current page. This can be passed in the next query as a `before` argument to paginate backwards. EOS end - t.field names.end_cursor, "Cursor", graphql_only: true do |f| + t.field names.end_cursor, @schema_def_state.type_namer.cursor_type_name, graphql_only: true do |f| f.documentation <<~EOS The `Cursor` of the last edge of the current page. This can be passed in the next query as a `after` argument to paginate forwards. @@ -753,21 +774,29 @@ def register_standard_graphql_scalars end def register_custom_elastic_graph_scalars - schema_def_api.scalar_type "Cursor" do |t| - # Technically, we don't use the mapping or json_schema on this type since it's a return-only - # type and isn't indexed. However, `scalar_type` requires them to be set (since custom scalars - # defined by users will need those set) so we set them here to what they would be if we actually - # used them. - t.mapping type: "keyword" - t.json_schema type: "string" - t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", - defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" + # Validate that Cursor type override is compatible (must be string-like) + validate_cursor_type_override! + + # Only register the Cursor scalar if it's not overridden to a built-in type. + # When overridden to a built-in type like String, we use that type directly + # and skip scalar registration to avoid duplicate type definition errors. + unless BUILT_IN_SCALARS.include?(@schema_def_state.type_namer.cursor_type_name) + schema_def_api.scalar_type "Cursor" do |t| + # Technically, we don't use the mapping or json_schema on this type since it's a return-only + # type and isn't indexed. However, `scalar_type` requires them to be set (since custom scalars + # defined by users will need those set) so we set them here to what they would be if we actually + # used them. + t.mapping type: "keyword" + t.json_schema type: "string" + t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", + defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" - t.documentation <<~EOS - An opaque string value representing a specific location in a paginated connection type. - Returned cursors can be passed back in the next query via the `before` or `after` - arguments to continue paginating from that point. - EOS + t.documentation <<~EOS + An opaque string value representing a specific location in a paginated connection type. + Returned cursors can be passed back in the next query via the `before` or `after` + arguments to continue paginating from that point. + EOS + end end schema_def_api.scalar_type "Date" do |t| diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb index 3d66eeb62..0a6dc1eb8 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb @@ -963,7 +963,7 @@ def define_relay_pagination_arguments! EOS end - argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a| + argument schema_def_state.schema_elements.after.to_sym, schema_def_state.type_namer.cursor_type_name do |a| a.documentation <<~EOS Used to forward-paginate through the `#{name}`. When provided, the next page after the provided cursor will be returned. @@ -984,7 +984,7 @@ def define_relay_pagination_arguments! EOS end - argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a| + argument schema_def_state.schema_elements.before.to_sym, schema_def_state.type_namer.cursor_type_name do |a| a.documentation <<~EOS Used to backward-paginate through the `#{name}`. When provided, the previous page before the provided cursor will be returned. diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_namer.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_namer.rb index 462bd378e..c2f3a3839 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_namer.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_namer.rb @@ -58,6 +58,12 @@ def revert_override_for(potentially_overriden_name) reverse_overrides.fetch(potentially_overriden_name, potentially_overriden_name) end + # Returns the type name to use for cursor fields, respecting any type_name_overrides. + # @return [String] the cursor type name (e.g., "Cursor" or "String") + def cursor_type_name + name_for("Cursor") + end + # Generates a derived type name based on the provided format name and arguments. The given arguments must match # the placeholders in the format. If the format name is unknown or the arguments are invalid, a `Errors::ConfigError` is raised. # diff --git a/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/type_namer.rbs b/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/type_namer.rbs index d539d8533..6c8b473e0 100644 --- a/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/type_namer.rbs +++ b/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/type_namer.rbs @@ -25,6 +25,7 @@ module ElasticGraph def name_for: (::Symbol | ::String) -> ::String def revert_override_for: (::String) -> ::String + def cursor_type_name: () -> ::String def generate_name_for: (::Symbol, **::String) -> ::String def extract_base_from: (::String, format: ::Symbol) -> ::String? def matches_format?: (::String, ::Symbol) -> bool diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb index e25e95cee..75c71753c 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb @@ -75,6 +75,89 @@ module SchemaDefinition EOS end + it "allows overriding `Cursor` to `String` for federation compatibility" do + result = define_schema(type_name_overrides: {Cursor: "String"}) 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 String 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}: String + #{schema_elements.end_cursor}: String + } + EOS + + # Edge.cursor field should use String + expect(type_def_from(result, "WidgetEdge")).to include("#{schema_elements.cursor}: String") + + # Pagination arguments should use String + query_type = type_def_from(result, "Query") + expect(query_type).to include("after: String") + expect(query_type).to include("before: String") + end + + 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 + + it "allows overriding `Cursor` to `ID` (another string-like type)" do + result = define_schema(type_name_overrides: {Cursor: "ID"}) do |api| + api.object_type "Widget" do |t| + t.field "id", "ID!" + t.index "widgets" + end + end + + # The Cursor scalar should not be registered + expect(type_def_from(result, "Cursor")).to be_nil + + # 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 + + it "rejects overriding `Cursor` to a non-string-compatible type like Int" do + expect { + define_schema(type_name_overrides: {Cursor: "Int"}) + }.to raise_error( + Errors::SchemaError, + a_string_including( + "Invalid cursor type override", + "Cursor` was overridden to `Int`", + "cursor types must be string-compatible", + "String, ID (String recommended for federation compatibility)" + ) + ) + end + + it "rejects overriding `Cursor` to a non-string-compatible type like Boolean" do + expect { + define_schema(type_name_overrides: {Cursor: "Boolean"}) + }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) + end + + it "rejects overriding `Cursor` to a non-string-compatible type like Float" do + expect { + define_schema(type_name_overrides: {Cursor: "Float"}) + }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) + end + it "defines a `GeoLocation` object type and related filter types" do expect(type_named("GeoLocation", include_docs: true)).to eq(<<~EOS.strip) """ From de3d6b2dd4aa5b123d01b1e838967483e38f6ea8 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Tue, 2 Jun 2026 16:30:07 -0700 Subject: [PATCH 02/24] Fix integration tests to pass encoded cursor strings 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. --- .../datastore_query/aggregation_pagination_spec.rb | 12 ++++++------ .../datastore_query/pagination_shared_examples.rb | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb index 5faaa2039..dcc006bee 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb @@ -49,15 +49,15 @@ def index_doc_with_null_value expect(items.map(&:count)).to eq [6] expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) - items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: true) - items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: true, has_previous_page: false) - items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON, before: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON.encode, before: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) end @@ -69,15 +69,15 @@ def index_doc_with_null_value expect(items.map { |i| fetch_aggregated_values(i, "amount_cents", "sum") }).to eq [210] expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) - items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: true) - items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: true, has_previous_page: false) - items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON, before: DecodedCursor::SINGLETON) + items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON.encode, before: DecodedCursor::SINGLETON.encode) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) end diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb index d82abd0ea..3b8f68f7d 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb @@ -245,7 +245,7 @@ class GraphQL broken_cursor = DecodedCursor.new({"not" => "valid"}) expect { - paginated_search(first: 1, after: broken_cursor) + paginated_search(first: 1, after: broken_cursor.encode) }.to raise_error ::GraphQL::ExecutionError, a_string_including("`#{broken_cursor.encode}` is not a valid cursor") end From d0bcf914fda33e5db87b014c089388f3dc4a6135 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Tue, 2 Jun 2026 17:21:34 -0700 Subject: [PATCH 03/24] Fix pagination_spec cursor_of helper to return encoded strings 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. --- .../elastic_graph/graphql/datastore_query/pagination_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb index e7f4f4d66..63bee90c1 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb @@ -198,7 +198,7 @@ def cursor_of(widget, decoded_cursor_factory: self.decoded_cursor_factory) [field, value] end - DecodedCursor.new(values) + DecodedCursor.new(values).encode end end end From 5adb6e88c9f00633b7dee0920ea7cda91a0fe96d Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Tue, 2 Jun 2026 17:31:59 -0700 Subject: [PATCH 04/24] Add backward compatibility for DecodedCursor objects in Paginator 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 --- .../graphql/datastore_query/paginator.rb | 3 ++- .../graphql/datastore_query/paginator.rbs | 10 +++++----- .../datastore_query/aggregation_pagination_spec.rb | 12 ++++++------ .../datastore_query/pagination_shared_examples.rb | 2 +- .../graphql/datastore_query/pagination_spec.rb | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index 0061ac21e..8b003e57e 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -151,10 +151,11 @@ def desired_page_size private # Decodes a cursor string to a DecodedCursor object. - # @param cursor [String, nil] the cursor string to decode + # @param cursor [String, DecodedCursor, nil] the cursor to decode (accepts DecodedCursor for backward compatibility) # @return [DecodedCursor, nil] the decoded cursor def decode_cursor(cursor) return nil if cursor.nil? + return cursor if cursor.is_a?(DecodedCursor) DecodedCursor.decode!(cursor) end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs index 8e35a9946..ee18e848e 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs @@ -10,18 +10,18 @@ module ElasticGraph attr_reader default_page_size: ::Integer attr_reader max_page_size: ::Integer attr_reader first: ::Integer? - attr_reader after: (::String | nil) + attr_reader after: (::String | DecodedCursor | nil) attr_reader last: ::Integer? - attr_reader before: (::String | nil) + attr_reader before: (::String | DecodedCursor | nil) attr_reader schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames def initialize: ( default_page_size: ::Integer, max_page_size: ::Integer, first: ::Integer?, - after: (::String | nil), + after: (::String | DecodedCursor | nil), last: ::Integer?, - before: (::String | nil), + before: (::String | DecodedCursor | nil), schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames) -> void @decoded_after: DecodedCursor? @@ -42,7 +42,7 @@ module ElasticGraph private - def decode_cursor: (::String?) -> DecodedCursor? + def decode_cursor: (::String | DecodedCursor | nil) -> DecodedCursor? @first_n: ::Integer? def first_n: () -> ::Integer? diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb index dcc006bee..5faaa2039 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb @@ -49,15 +49,15 @@ def index_doc_with_null_value expect(items.map(&:count)).to eq [6] expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) - items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: true) - items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: true, has_previous_page: false) - items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON.encode, before: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON, before: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) end @@ -69,15 +69,15 @@ def index_doc_with_null_value expect(items.map { |i| fetch_aggregated_values(i, "amount_cents", "sum") }).to eq [210] expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) - items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(first: 2, after: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: true) - items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(last: 2, before: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: true, has_previous_page: false) - items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON.encode, before: DecodedCursor::SINGLETON.encode) + items, page_info = paginated_search.call(after: DecodedCursor::SINGLETON, before: DecodedCursor::SINGLETON) expect(items).to be_empty expect(page_info).to have_attributes(has_next_page: false, has_previous_page: false) end diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb index 3b8f68f7d..d82abd0ea 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_shared_examples.rb @@ -245,7 +245,7 @@ class GraphQL broken_cursor = DecodedCursor.new({"not" => "valid"}) expect { - paginated_search(first: 1, after: broken_cursor.encode) + paginated_search(first: 1, after: broken_cursor) }.to raise_error ::GraphQL::ExecutionError, a_string_including("`#{broken_cursor.encode}` is not a valid cursor") end diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb index 63bee90c1..e7f4f4d66 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb @@ -198,7 +198,7 @@ def cursor_of(widget, decoded_cursor_factory: self.decoded_cursor_factory) [field, value] end - DecodedCursor.new(values).encode + DecodedCursor.new(values) end end end From a45b85b5f6c5db758bb990b21804282e3e5a6693 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Tue, 2 Jun 2026 18:54:43 -0700 Subject: [PATCH 05/24] Fix test failures --- .../graphql/datastore_query/paginator.rb | 5 ++-- .../scalar_coercion_adapters/cursor.rb | 9 ++----- .../spec/acceptance/aggregations_spec.rb | 10 +++++--- .../acceptance/nested_relationships_spec.rb | 10 +++++--- .../graphql/datastore_query/paginator_spec.rb | 4 +-- .../scalar_coercion_adapters/cursor_spec.rb | 25 ++++++++----------- .../schema_definition/rake_tasks_spec.rb | 4 +++ 7 files changed, 32 insertions(+), 35 deletions(-) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index 8b003e57e..6b2b6daf0 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -150,13 +150,12 @@ def desired_page_size private - # Decodes a cursor string to a DecodedCursor object. - # @param cursor [String, DecodedCursor, nil] the cursor to decode (accepts DecodedCursor for backward compatibility) - # @return [DecodedCursor, nil] the decoded cursor def decode_cursor(cursor) return nil if cursor.nil? return cursor if cursor.is_a?(DecodedCursor) DecodedCursor.decode!(cursor) + rescue Errors::InvalidCursorError => e + raise ::GraphQL::ExecutionError, e.message end def first_n diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb index deb118b14..0953b0196 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb @@ -13,16 +13,11 @@ class GraphQL module ScalarCoercionAdapters class Cursor def self.coerce_input(value, ctx) - case value - when DecodedCursor - value.encode - when ::String - value - end + return value if value.nil? || value.is_a?(::String) + raise ::GraphQL::CoercionError, "Cursor must be a String, got #{value.class}" end def self.coerce_result(value, ctx) - # Pass-through: resolvers already encode cursors to strings value end end diff --git a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb index 0f7b88761..aacb18481 100644 --- a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb @@ -1404,16 +1404,18 @@ def forward_paginate_through_workspace_id_groupings {"count" => 2, grouped_by => {case_correctly("workspace_id") => "w2"}} ] + array_error = "Cursor must be a String, got Array" 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" + invalid_cursor_error = "`#{broken_cursor}` is an invalid cursor." 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" => invalid_cursor_error)) + }.to log_warning a_string_including(invalid_cursor_error) page_info, workspace_nodes = list_widget_workspace_id_groupings(first: 2, after: page_info.fetch(case_correctly("end_cursor"))) diff --git a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb index e158c4c95..f6d340f5d 100644 --- a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb @@ -307,24 +307,26 @@ module ElasticGraph }.to log_warning a_string_including("`first` cannot be negative, but is -2.") # Demonstrate how broken cursors behave. + array_error = "Cursor must be a String, got Array" 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" + invalid_cursor_error = "`#{broken_cursor}` is an invalid cursor." expect { response = query_widgets_and_components_including_page_info( widget_args: {first: 1, order_by: [:amount_cents_ASC]}, 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" => invalid_cursor_error)) + }.to log_warning a_string_including(invalid_cursor_error) # get next page of components (but still on the first page of widgets) results = query_widgets_and_components_including_page_info( diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb index da4012f34..efcf4518a 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb @@ -67,13 +67,13 @@ def build_paginator(after: nil, before: nil, first: nil, last: nil) expect(first_call).to be(second_call) end - it "raises InvalidCursorError when decoding an invalid cursor string" do + it "raises GraphQL::ExecutionError when decoding an invalid cursor string" do invalid_cursor = "invalid_base64_!@#%" paginator = build_paginator(after: invalid_cursor) expect { paginator.decoded_after - }.to raise_error(Errors::InvalidCursorError, a_string_including(invalid_cursor)) + }.to raise_error(::GraphQL::ExecutionError, a_string_including(invalid_cursor)) end end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb index 48dd0535f..e4f098447 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb @@ -15,46 +15,41 @@ module ScalarCoercionAdapters include_context "scalar coercion adapter support", "Cursor" context "input coercion" do - it "accepts a properly encoded string cursor and returns it as a string" do + it "accepts a properly encoded string cursor" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) encoded = cursor.encode - expect_input_value_to_be_accepted(encoded, as: encoded) + expect_input_value_to_be_accepted(encoded) end - it "accepts the special singleton cursor string value and returns it as a string" do + it "accepts the special singleton cursor string value" do encoded = DecodedCursor::SINGLETON.encode - expect_input_value_to_be_accepted(encoded, as: encoded) + expect_input_value_to_be_accepted(encoded) end - it "accepts a `nil` value as-is" do + it "accepts a `nil` value" do expect_input_value_to_be_accepted(nil) end - it "accepts broken string cursors (validation deferred to Paginator)" do + it "accepts broken string cursors" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}).encode broken_cursor = cursor + "-broken" - # Coercion passes it through; Paginator will raise InvalidCursorError when decoding - expect_input_value_to_be_accepted(broken_cursor, as: broken_cursor) + expect_input_value_to_be_accepted(broken_cursor) end end context "result coercion" do - # Note: Resolvers always return already-encoded cursor strings (via Edge#cursor - # which calls DecodedCursor#encode), so coerce_result just passes values through. - # These tests verify the pass-through behavior. - - it "returns a properly encoded cursor string as-is" do + it "returns a properly encoded cursor string" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) encoded = cursor.encode expect_result_to_be_returned(encoded, as: encoded) end - it "returns the singleton cursor string as-is" do + it "returns the singleton cursor string" do encoded = DecodedCursor::SINGLETON.encode expect_result_to_be_returned(encoded, as: encoded) end - it "returns `nil` as-is" do + it "returns `nil`" do expect_result_to_be_returned(nil) end end diff --git a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb index 709206b5d..71765bddb 100644 --- a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb +++ b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb @@ -314,6 +314,8 @@ module SchemaDefinition exclusions = SchemaElements::TypeNamer::TYPES_THAT_CANNOT_BE_OVERRIDDEN expect(original_types).to include(*exclusions.to_a) overrides = (original_types - exclusions.to_a).to_h { |name| [name, "Pre#{name}"] } + # Cursor can only be overridden to String or ID + overrides["Cursor"] = "String" if overrides.key?("Cursor") output = run_rake( "schema_artifacts:dump", @@ -366,6 +368,8 @@ module SchemaDefinition EOS overrides = original_core_types.to_h { |name| [name, "Pre#{name}"] } + # Cursor can only be overridden to String or ID + overrides["Cursor"] = "String" if overrides.key?("Cursor") output = run_rake("schema_artifacts:dump", type_name_overrides: overrides) expect(output).to exclude(does_not_match_warning_snippet) From 98d1e79e038032db842a365c7a2b755190df74c4 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 3 Jun 2026 09:23:46 -0700 Subject: [PATCH 06/24] Add missing import --- .../lib/elastic_graph/graphql/datastore_query/paginator.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index 6b2b6daf0..71b81ff80 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -8,6 +8,7 @@ require "elastic_graph/errors" require "elastic_graph/support/memoizable_data" +require "graphql" module ElasticGraph class GraphQL From 2cd0bee9cf7745b27665b968fe33cc10a37aeb5b Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 3 Jun 2026 10:57:34 -0700 Subject: [PATCH 07/24] Fix awkward punctuation --- config/site/src/guides/customizing-the-graphql-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site/src/guides/customizing-the-graphql-schema.md b/config/site/src/guides/customizing-the-graphql-schema.md index 46086ebc0..df6fed86b 100644 --- a/config/site/src/guides/customizing-the-graphql-schema.md +++ b/config/site/src/guides/customizing-the-graphql-schema.md @@ -90,7 +90,7 @@ This configuration causes ElasticGraph to: - Use `String` for pagination arguments (`before`, `after`) 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 +structure. This decoding is transparent: pagination continues to work exactly as before, including forward and backward pagination, cursor validation, and aggregation pagination. {: .alert-note} From 392e6bce152eba261f163bd3e9585b73e28b3ace Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 3 Jun 2026 10:57:58 -0700 Subject: [PATCH 08/24] Increse branch coverage to 100% --- .../graphql/scalar_coercion_adapters/cursor_spec.rb | 8 ++++---- .../elastic_graph/schema_definition/rake_tasks_spec.rb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb index e4f098447..058685825 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb @@ -18,22 +18,22 @@ module ScalarCoercionAdapters it "accepts a properly encoded string cursor" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) encoded = cursor.encode - expect_input_value_to_be_accepted(encoded) + expect_input_value_to_be_accepted(encoded, only_test_variable: true) end it "accepts the special singleton cursor string value" do encoded = DecodedCursor::SINGLETON.encode - expect_input_value_to_be_accepted(encoded) + expect_input_value_to_be_accepted(encoded, only_test_variable: true) end it "accepts a `nil` value" do - expect_input_value_to_be_accepted(nil) + expect_input_value_to_be_accepted(nil, only_test_variable: true) end it "accepts broken string cursors" do cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}).encode broken_cursor = cursor + "-broken" - expect_input_value_to_be_accepted(broken_cursor) + expect_input_value_to_be_accepted(broken_cursor, only_test_variable: true) end end diff --git a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb index 71765bddb..368b6351a 100644 --- a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb +++ b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb @@ -314,8 +314,8 @@ module SchemaDefinition exclusions = SchemaElements::TypeNamer::TYPES_THAT_CANNOT_BE_OVERRIDDEN expect(original_types).to include(*exclusions.to_a) overrides = (original_types - exclusions.to_a).to_h { |name| [name, "Pre#{name}"] } - # Cursor can only be overridden to String or ID - overrides["Cursor"] = "String" if overrides.key?("Cursor") + # Cursor can only be overridden to String or ID (not "PreCursor") + overrides["Cursor"] = "String" output = run_rake( "schema_artifacts:dump", @@ -368,8 +368,8 @@ module SchemaDefinition EOS overrides = original_core_types.to_h { |name| [name, "Pre#{name}"] } - # Cursor can only be overridden to String or ID - overrides["Cursor"] = "String" if overrides.key?("Cursor") + # Cursor can only be overridden to String or ID (not "PreCursor") + overrides["Cursor"] = "String" output = run_rake("schema_artifacts:dump", type_name_overrides: overrides) expect(output).to exclude(does_not_match_warning_snippet) From 1b191cd899ecd3a47e6048f01ad8d42d6f119846 Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:12:28 -0400 Subject: [PATCH 09/24] Update config/site/src/guides/customizing-the-graphql-schema.md Co-authored-by: Myron Marston --- config/site/src/guides/customizing-the-graphql-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site/src/guides/customizing-the-graphql-schema.md b/config/site/src/guides/customizing-the-graphql-schema.md index df6fed86b..765e5677a 100644 --- a/config/site/src/guides/customizing-the-graphql-schema.md +++ b/config/site/src/guides/customizing-the-graphql-schema.md @@ -73,7 +73,7 @@ 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 (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), 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. From 3535167c0a03879023c3133e89f1e8b1d4b89985 Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:13:35 -0400 Subject: [PATCH 10/24] Update config/site/src/guides/customizing-the-graphql-schema.md Co-authored-by: Myron Marston --- config/site/src/guides/customizing-the-graphql-schema.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config/site/src/guides/customizing-the-graphql-schema.md b/config/site/src/guides/customizing-the-graphql-schema.md index 765e5677a..bc8c2c62e 100644 --- a/config/site/src/guides/customizing-the-graphql-schema.md +++ b/config/site/src/guides/customizing-the-graphql-schema.md @@ -89,10 +89,6 @@ This configuration causes ElasticGraph to: - Use `String` for all cursor-related fields (`PageInfo.startCursor`, `PageInfo.endCursor`, `Edge.cursor`) - Use `String` for pagination arguments (`before`, `after`) -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. - {: .alert-note} **Note**{: .alert-title} The `Cursor` scalar and `String` are semantically identical on the wire—both are opaque base64-encoded strings. The From e52058adbdfbefbfea73705428969cd86e971c26 Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:16:34 -0400 Subject: [PATCH 11/24] Update elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb Co-authored-by: Myron Marston --- .../lib/elastic_graph/graphql/datastore_query/paginator.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index 71b81ff80..e50f166ec 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -68,12 +68,14 @@ class Paginator < Support::MemoizableData.define(:default_page_size, :max_page_s # @return [DecodedCursor, nil] the decoded after cursor def decoded_after - @decoded_after ||= decode_cursor(after) + return @decoded_after if defined?(@decoded_after) + @decoded_after = decode_cursor(after) end # @return [DecodedCursor, nil] the decoded before cursor def decoded_before - @decoded_before ||= decode_cursor(before) + return @decoded_before if defined?(@decoded_before) + @decoded_before = decode_cursor(before) end def requested_page_size From d569758642da443e10de05163b95f5b303a1ad91 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 10 Jun 2026 10:46:57 -0400 Subject: [PATCH 12/24] Require cursor to always be a string, remove coercion --- config/schema/artifacts/runtime_metadata.yaml | 4 +- .../runtime_metadata.yaml | 4 +- .../graphql/datastore_query/paginator.rb | 1 - .../elastic_graph/graphql/decoded_cursor.rb | 4 + .../scalar_coercion_adapters/cursor.rb | 26 ---- .../graphql/datastore_query/paginator.rbs | 10 +- .../scalar_coercion_adapters/cursor.rbs | 9 -- .../spec/acceptance/aggregations_spec.rb | 15 +- ...elasticgraph_graphql_acceptance_support.rb | 3 +- .../spec/acceptance/hidden_types_spec.rb | 7 +- .../acceptance/nested_relationships_spec.rb | 15 +- .../aggregation_pagination_spec.rb | 4 + .../datastore_query/pagination_spec.rb | 4 + .../graphql/datastore_query/paginator_spec.rb | 128 ------------------ .../scalar_coercion_adapters/cursor_spec.rb | 59 -------- .../schema_definition/factory.rb | 2 +- .../schema_elements/built_in_types.rb | 26 ++-- .../schema_elements/field.rb | 4 +- .../schema_definition/rake_tasks_spec.rb | 4 - .../graphql_schema/built_in_types_spec.rb | 92 ++++--------- .../spec_support/builds_datastore_core.rb | 2 + spec_support/spec_helper.rb | 2 + 22 files changed, 97 insertions(+), 328 deletions(-) delete mode 100644 elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb delete mode 100644 elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs delete mode 100644 elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb delete mode 100644 elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb diff --git a/config/schema/artifacts/runtime_metadata.yaml b/config/schema/artifacts/runtime_metadata.yaml index 19a30bf8f..3a45dae91 100644 --- a/config/schema/artifacts/runtime_metadata.yaml +++ b/config/schema/artifacts/runtime_metadata.yaml @@ -9268,8 +9268,8 @@ scalar_types_by_name: require_path: elastic_graph/indexer/indexing_preparers/no_op Cursor: coercion_adapter: - name: ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor - require_path: elastic_graph/graphql/scalar_coercion_adapters/cursor + name: ElasticGraph::GraphQL::ScalarCoercionAdapters::NoOp + require_path: elastic_graph/graphql/scalar_coercion_adapters/no_op grouping_missing_value_placeholder: "$SECURE_RANDOM_VALUE" indexing_preparer: name: ElasticGraph::Indexer::IndexingPreparers::NoOp diff --git a/config/schema/artifacts_with_apollo/runtime_metadata.yaml b/config/schema/artifacts_with_apollo/runtime_metadata.yaml index 8e7ff906c..e5706f3be 100644 --- a/config/schema/artifacts_with_apollo/runtime_metadata.yaml +++ b/config/schema/artifacts_with_apollo/runtime_metadata.yaml @@ -9405,8 +9405,8 @@ scalar_types_by_name: require_path: elastic_graph/indexer/indexing_preparers/no_op Cursor: coercion_adapter: - name: ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor - require_path: elastic_graph/graphql/scalar_coercion_adapters/cursor + name: ElasticGraph::GraphQL::ScalarCoercionAdapters::NoOp + require_path: elastic_graph/graphql/scalar_coercion_adapters/no_op grouping_missing_value_placeholder: "$SECURE_RANDOM_VALUE" indexing_preparer: name: ElasticGraph::Indexer::IndexingPreparers::NoOp diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb index e50f166ec..ca813a3ca 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/paginator.rb @@ -155,7 +155,6 @@ def desired_page_size def decode_cursor(cursor) return nil if cursor.nil? - return cursor if cursor.is_a?(DecodedCursor) DecodedCursor.decode!(cursor) rescue Errors::InvalidCursorError => e raise ::GraphQL::ExecutionError, e.message diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb index 1ab3d3b53..743bce052 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb @@ -45,6 +45,10 @@ def self.try_decode(string) # Tries to decode the given string cursor, raising an `Errors::InvalidCursorError` if it's invalid. def self.decode!(string) + unless string.is_a?(::String) + raise Errors::InvalidCursorError, "Cursor must be a String, got #{string.class}" + end + return SINGLETON if string == SINGLETON_CURSOR json = ::Base64.urlsafe_decode64(string) new(::JSON.parse(json)) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb deleted file mode 100644 index 0953b0196..000000000 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2024 - 2026 Block, Inc. -# -# Use of this source code is governed by an MIT-style -# license that can be found in the LICENSE file or at -# https://opensource.org/licenses/MIT. -# -# frozen_string_literal: true - -require "elastic_graph/graphql/decoded_cursor" - -module ElasticGraph - class GraphQL - module ScalarCoercionAdapters - class Cursor - def self.coerce_input(value, ctx) - return value if value.nil? || value.is_a?(::String) - raise ::GraphQL::CoercionError, "Cursor must be a String, got #{value.class}" - end - - def self.coerce_result(value, ctx) - value - end - end - end - end -end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs index ee18e848e..5a3c7a0b3 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/paginator.rbs @@ -10,18 +10,18 @@ module ElasticGraph attr_reader default_page_size: ::Integer attr_reader max_page_size: ::Integer attr_reader first: ::Integer? - attr_reader after: (::String | DecodedCursor | nil) + attr_reader after: ::String? attr_reader last: ::Integer? - attr_reader before: (::String | DecodedCursor | nil) + 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: (::String | DecodedCursor | nil), + after: ::String?, last: ::Integer?, - before: (::String | DecodedCursor | nil), + before: ::String?, schema_element_names: SchemaArtifacts::RuntimeMetadata::SchemaElementNames) -> void @decoded_after: DecodedCursor? @@ -42,7 +42,7 @@ module ElasticGraph private - def decode_cursor: (::String | DecodedCursor | nil) -> DecodedCursor? + def decode_cursor: (::String?) -> DecodedCursor? @first_n: ::Integer? def first_n: () -> ::Integer? diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs deleted file mode 100644 index e212bd14b..000000000 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs +++ /dev/null @@ -1,9 +0,0 @@ -module ElasticGraph - class GraphQL - module ScalarCoercionAdapters - class Cursor - extend SchemaArtifacts::_ScalarCoercionAdapter[::String, ::String] - end - end - end -end diff --git a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb index aacb18481..4ddf29bbf 100644 --- a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb @@ -1404,18 +1404,25 @@ def forward_paginate_through_workspace_id_groupings {"count" => 2, grouped_by => {case_correctly("workspace_id") => "w2"}} ] - array_error = "Cursor must be a String, got Array" + # When Cursor is overridden to String (camelCase context), GraphQL validates the type and rejects arrays + # at the schema validation level. When Cursor is a custom scalar (snake_case context), GraphQL doesn't + # validate input types for custom scalars, so the array reaches our decode logic which validates the type. + array_error = if is_a?(CamelCaseGraphQLAcceptanceAdapter) + "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type 'String'." + else + "Cursor must be a String, got Array" + end + 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" => array_error)) }.to log_warning a_string_including(array_error) broken_cursor = page_info.fetch(case_correctly("end_cursor")) + "-broken" - invalid_cursor_error = "`#{broken_cursor}` is an invalid cursor." 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" => invalid_cursor_error)) - }.to log_warning a_string_including(invalid_cursor_error) + 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"))) diff --git a/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb b/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb index 9956e95e9..2611d1976 100644 --- a/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb +++ b/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb @@ -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, @@ -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. diff --git a/elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb b/elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb index e25c611df..b61dd8667 100644 --- a/elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/hidden_types_spec.rb @@ -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 @@ -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[ diff --git a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb index f6d340f5d..603f9664a 100644 --- a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb @@ -307,7 +307,15 @@ module ElasticGraph }.to log_warning a_string_including("`first` cannot be negative, but is -2.") # Demonstrate how broken cursors behave. - array_error = "Cursor must be a String, got Array" + # When Cursor is overridden to String (camelCase context), GraphQL validates the type and rejects arrays + # at the schema validation level. When Cursor is a custom scalar (snake_case context), GraphQL doesn't + # validate input types for custom scalars, so the array reaches our decode logic which validates the type. + array_error = if is_a?(CamelCaseGraphQLAcceptanceAdapter) + "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type 'String'." + else + "Cursor must be a String, got Array" + end + expect { response = query_widgets_and_components_including_page_info( widget_args: {first: 1, order_by: [:amount_cents_ASC]}, @@ -318,15 +326,14 @@ module ElasticGraph }.to log_warning a_string_including(array_error) broken_cursor = results["edges"][0]["node"]["components"][case_correctly "page_info"][case_correctly "end_cursor"] + "-broken" - invalid_cursor_error = "`#{broken_cursor}` is an invalid cursor." expect { response = query_widgets_and_components_including_page_info( widget_args: {first: 1, order_by: [:amount_cents_ASC]}, component_args: {first: 1, after: broken_cursor, order_by: [:name_ASC]}, expect_errors: true ) - expect(response["errors"]).to contain_exactly(a_hash_including("message" => invalid_cursor_error)) - }.to log_warning a_string_including(invalid_cursor_error) + 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( diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb index 5faaa2039..dca5bb1ff 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/aggregation_pagination_spec.rb @@ -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, diff --git a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb index e7f4f4d66..234108d1f 100644 --- a/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb +++ b/elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/pagination_spec.rb @@ -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( diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb deleted file mode 100644 index efcf4518a..000000000 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/paginator_spec.rb +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright 2024 - 2026 Block, Inc. -# -# Use of this source code is governed by an MIT-style -# license that can be found in the LICENSE file or at -# https://opensource.org/licenses/MIT. -# -# frozen_string_literal: true - -require "elastic_graph/graphql/datastore_query/paginator" -require "elastic_graph/graphql/decoded_cursor" -require "elastic_graph/schema_artifacts/runtime_metadata/schema_element_names" - -module ElasticGraph - class GraphQL - class DatastoreQuery - RSpec.describe Paginator do - let(:schema_element_names) { SchemaArtifacts::RuntimeMetadata::SchemaElementNames.new(form: "snake_case") } - let(:sort_values) { {"id" => "abc123", "created_at" => "2024-01-01T00:00:00Z"} } - let(:decoded_cursor) { DecodedCursor.new(sort_values) } - let(:encoded_cursor_string) { decoded_cursor.encode } - - def build_paginator(after: nil, before: nil, first: nil, last: nil) - Paginator.new( - default_page_size: 25, - max_page_size: 100, - first: first, - after: after, - last: last, - before: before, - schema_element_names: schema_element_names - ) - end - - describe "lazy cursor decoding" do - it "accepts a String for `after` and lazily decodes it in `decoded_after`" do - paginator = build_paginator(after: encoded_cursor_string) - - expect(paginator.decoded_after).to be_a(DecodedCursor) - expect(paginator.decoded_after.sort_values).to eq(sort_values) - end - - it "accepts a String for `before` and lazily decodes it in `decoded_before`" do - paginator = build_paginator(before: encoded_cursor_string) - - expect(paginator.decoded_before).to be_a(DecodedCursor) - expect(paginator.decoded_before.sort_values).to eq(sort_values) - end - - it "returns nil from `decoded_after` when `after` is nil" do - paginator = build_paginator - - expect(paginator.decoded_after).to be_nil - end - - it "returns nil from `decoded_before` when `before` is nil" do - paginator = build_paginator - - expect(paginator.decoded_before).to be_nil - end - - it "memoizes the decoded cursor to avoid repeated decoding" do - paginator = build_paginator(after: encoded_cursor_string) - - first_call = paginator.decoded_after - second_call = paginator.decoded_after - - expect(first_call).to be(second_call) - end - - it "raises GraphQL::ExecutionError when decoding an invalid cursor string" do - invalid_cursor = "invalid_base64_!@#%" - paginator = build_paginator(after: invalid_cursor) - - expect { - paginator.decoded_after - }.to raise_error(::GraphQL::ExecutionError, a_string_including(invalid_cursor)) - end - end - - describe "#search_after" do - it "returns the decoded after cursor when not searching in reverse" do - paginator = build_paginator(first: 10, after: encoded_cursor_string) - - expect(paginator.search_in_reverse?).to be false - expect(paginator.search_after).to be_a(DecodedCursor) - expect(paginator.search_after.sort_values).to eq(sort_values) - end - - it "returns the decoded before cursor when searching in reverse" do - paginator = build_paginator(last: 10, before: encoded_cursor_string) - - expect(paginator.search_in_reverse?).to be_truthy - expect(paginator.search_after).to be_a(DecodedCursor) - expect(paginator.search_after.sort_values).to eq(sort_values) - end - end - - describe "#paginated_from_singleton_cursor?" do - it "returns true when decoded_after is the singleton cursor" do - singleton_cursor_string = DecodedCursor::SINGLETON.encode - paginator = build_paginator(after: singleton_cursor_string) - - expect(paginator.paginated_from_singleton_cursor?).to be true - end - - it "returns true when decoded_before is the singleton cursor" do - singleton_cursor_string = DecodedCursor::SINGLETON.encode - paginator = build_paginator(before: singleton_cursor_string) - - expect(paginator.paginated_from_singleton_cursor?).to be true - end - - it "returns false when neither cursor is the singleton" do - paginator = build_paginator(after: encoded_cursor_string) - - expect(paginator.paginated_from_singleton_cursor?).to be false - end - - it "returns false when both cursors are nil" do - paginator = build_paginator - - expect(paginator.paginated_from_singleton_cursor?).to be false - end - end - end - end - end -end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb deleted file mode 100644 index 058685825..000000000 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright 2024 - 2026 Block, Inc. -# -# Use of this source code is governed by an MIT-style -# license that can be found in the LICENSE file or at -# https://opensource.org/licenses/MIT. -# -# frozen_string_literal: true - -require "support/scalar_coercion_adapter" - -module ElasticGraph - class GraphQL - module ScalarCoercionAdapters - RSpec.describe "Cursor" do - include_context "scalar coercion adapter support", "Cursor" - - context "input coercion" do - it "accepts a properly encoded string cursor" do - cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - encoded = cursor.encode - expect_input_value_to_be_accepted(encoded, only_test_variable: true) - end - - it "accepts the special singleton cursor string value" do - encoded = DecodedCursor::SINGLETON.encode - expect_input_value_to_be_accepted(encoded, only_test_variable: true) - end - - it "accepts a `nil` value" do - expect_input_value_to_be_accepted(nil, only_test_variable: true) - end - - it "accepts broken string cursors" do - cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}).encode - broken_cursor = cursor + "-broken" - expect_input_value_to_be_accepted(broken_cursor, only_test_variable: true) - end - end - - context "result coercion" do - it "returns a properly encoded cursor string" do - cursor = DecodedCursor.new({"a" => 1, "b" => "foo"}) - encoded = cursor.encode - expect_result_to_be_returned(encoded, as: encoded) - end - - it "returns the singleton cursor string" do - encoded = DecodedCursor::SINGLETON.encode - expect_result_to_be_returned(encoded, as: encoded) - end - - it "returns `nil`" do - expect_result_to_be_returned(nil) - end - end - end - end - end -end diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb index f3a3de31e..786fbf82a 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb @@ -519,7 +519,7 @@ def edge_type_for(type_name) f.documentation "The `#{type_name}` of this edge." end - t.field @state.schema_elements.cursor, @state.type_namer.cursor_type_name do |f| + t.field @state.schema_elements.cursor, "Cursor" do |f| f.documentation <<~EOS The `Cursor` of this `#{type_name}`. This can be passed in the next query as a `before` or `after` argument to continue paginating from this `#{type_name}`. diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 2ad7ffa24..1036ba01a 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -162,12 +162,9 @@ module SchemaElements # @private class BuiltInTypes # Built-in GraphQL scalar types that are part of the GraphQL specification. - # Used to detect when type_name_overrides maps to a built-in type that should - # not be registered. - BUILT_IN_SCALARS = %w[String Int Float Boolean ID].freeze - - # Valid types that can be used for cursor fields (string-compatible scalars). - VALID_CURSOR_TYPES = %w[String ID].freeze + # Non-string standard GraphQL scalars that cannot be used for cursor overrides. + # String and ID are valid, as are any custom scalar names (e.g., PaginationCursor). + INVALID_CURSOR_TYPE_OVERRIDES = (STOCK_GRAPHQL_SCALARS - %w[ID String]).freeze attr_reader :schema_def_api, :schema_def_state, :names @@ -195,14 +192,13 @@ def register_built_in_types def validate_cursor_type_override! override_name = @schema_def_state.type_namer.cursor_type_name return if override_name == "Cursor" - return if VALID_CURSOR_TYPES.include?(override_name) - - raise Errors::SchemaError, <<~ERROR - Invalid cursor type override: `Cursor` was overridden to `#{override_name}`, but cursor types must be string-compatible. - Valid cursor type overrides are: #{VALID_CURSOR_TYPES.join(", ")} (String recommended for federation compatibility). - To fix: tasks.type_name_overrides = {Cursor: "String"} - ERROR + if INVALID_CURSOR_TYPE_OVERRIDES.include?(override_name) + raise Errors::SchemaError, <<~ERROR + Invalid cursor type override: `Cursor` was overridden to `#{override_name}`, but cursor types must be string-compatible. + Valid options include: String, ID, or any custom scalar name (e.g., PaginationCursor). + ERROR + end end def register_directives @@ -780,7 +776,7 @@ def register_custom_elastic_graph_scalars # Only register the Cursor scalar if it's not overridden to a built-in type. # When overridden to a built-in type like String, we use that type directly # and skip scalar registration to avoid duplicate type definition errors. - unless BUILT_IN_SCALARS.include?(@schema_def_state.type_namer.cursor_type_name) + unless STOCK_GRAPHQL_SCALARS.include?(@schema_def_state.type_namer.cursor_type_name) schema_def_api.scalar_type "Cursor" do |t| # Technically, we don't use the mapping or json_schema on this type since it's a return-only # type and isn't indexed. However, `scalar_type` requires them to be set (since custom scalars @@ -788,8 +784,6 @@ def register_custom_elastic_graph_scalars # used them. t.mapping type: "keyword" t.json_schema type: "string" - t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", - defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb index 0a6dc1eb8..3d66eeb62 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb @@ -963,7 +963,7 @@ def define_relay_pagination_arguments! EOS end - argument schema_def_state.schema_elements.after.to_sym, schema_def_state.type_namer.cursor_type_name do |a| + argument schema_def_state.schema_elements.after.to_sym, "Cursor" do |a| a.documentation <<~EOS Used to forward-paginate through the `#{name}`. When provided, the next page after the provided cursor will be returned. @@ -984,7 +984,7 @@ def define_relay_pagination_arguments! EOS end - argument schema_def_state.schema_elements.before.to_sym, schema_def_state.type_namer.cursor_type_name do |a| + argument schema_def_state.schema_elements.before.to_sym, "Cursor" do |a| a.documentation <<~EOS Used to backward-paginate through the `#{name}`. When provided, the previous page before the provided cursor will be returned. diff --git a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb index 368b6351a..709206b5d 100644 --- a/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb +++ b/elasticgraph-schema_definition/spec/integration/elastic_graph/schema_definition/rake_tasks_spec.rb @@ -314,8 +314,6 @@ module SchemaDefinition exclusions = SchemaElements::TypeNamer::TYPES_THAT_CANNOT_BE_OVERRIDDEN expect(original_types).to include(*exclusions.to_a) overrides = (original_types - exclusions.to_a).to_h { |name| [name, "Pre#{name}"] } - # Cursor can only be overridden to String or ID (not "PreCursor") - overrides["Cursor"] = "String" output = run_rake( "schema_artifacts:dump", @@ -368,8 +366,6 @@ module SchemaDefinition EOS overrides = original_core_types.to_h { |name| [name, "Pre#{name}"] } - # Cursor can only be overridden to String or ID (not "PreCursor") - overrides["Cursor"] = "String" output = run_rake("schema_artifacts:dump", type_name_overrides: overrides) expect(output).to exclude(does_not_match_warning_snippet) diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb index 75c71753c..7211c1c10 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb @@ -75,34 +75,36 @@ module SchemaDefinition EOS end - it "allows overriding `Cursor` to `String` for federation compatibility" do - result = define_schema(type_name_overrides: {Cursor: "String"}) do |api| - api.object_type "Widget" do |t| - t.field "id", "ID!" - t.index "widgets" + %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 - end - # The Cursor scalar should not be registered when overridden to a built-in type - expect(type_def_from(result, "Cursor")).to be_nil + # 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 String 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}: String - #{schema_elements.end_cursor}: String - } - EOS + # 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 String - expect(type_def_from(result, "WidgetEdge")).to include("#{schema_elements.cursor}: String") + # Edge.cursor field should use the override + expect(type_def_from(result, "WidgetEdge")).to include("#{schema_elements.cursor}: #{cursor_override}") - # Pagination arguments should use String - query_type = type_def_from(result, "Query") - expect(query_type).to include("after: String") - expect(query_type).to include("before: String") + # 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 it "keeps the `Cursor` scalar when not overridden" do @@ -116,46 +118,12 @@ module SchemaDefinition expect(type_def_from(result, "PageInfo")).to include("#{schema_elements.end_cursor}: Cursor") end - it "allows overriding `Cursor` to `ID` (another string-like type)" do - result = define_schema(type_name_overrides: {Cursor: "ID"}) do |api| - api.object_type "Widget" do |t| - t.field "id", "ID!" - t.index "widgets" - end + %w[Boolean Float Int].each do |invalid_cursor_override| + it "rejects overriding `Cursor` to a non-string-compatible type like #{invalid_cursor_override}" do + expect { + define_schema(type_name_overrides: {Cursor: invalid_cursor_override}) + }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) end - - # The Cursor scalar should not be registered - expect(type_def_from(result, "Cursor")).to be_nil - - # 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 - - it "rejects overriding `Cursor` to a non-string-compatible type like Int" do - expect { - define_schema(type_name_overrides: {Cursor: "Int"}) - }.to raise_error( - Errors::SchemaError, - a_string_including( - "Invalid cursor type override", - "Cursor` was overridden to `Int`", - "cursor types must be string-compatible", - "String, ID (String recommended for federation compatibility)" - ) - ) - end - - it "rejects overriding `Cursor` to a non-string-compatible type like Boolean" do - expect { - define_schema(type_name_overrides: {Cursor: "Boolean"}) - }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) - end - - it "rejects overriding `Cursor` to a non-string-compatible type like Float" do - expect { - define_schema(type_name_overrides: {Cursor: "Float"}) - }.to raise_error(Errors::SchemaError, a_string_including("cursor types must be string-compatible")) end it "defines a `GeoLocation` object type and related filter types" do diff --git a/spec_support/lib/elastic_graph/spec_support/builds_datastore_core.rb b/spec_support/lib/elastic_graph/spec_support/builds_datastore_core.rb index bca599c27..df5e50e8b 100644 --- a/spec_support/lib/elastic_graph/spec_support/builds_datastore_core.rb +++ b/spec_support/lib/elastic_graph/spec_support/builds_datastore_core.rb @@ -24,6 +24,7 @@ def build_datastore_core( schema_element_name_form: :snake_case, schema_element_name_overrides: {}, derived_type_name_formats: {}, + type_name_overrides: {}, enum_value_overrides_by_type: {}, index_definitions: nil, clusters: nil, @@ -58,6 +59,7 @@ def build_datastore_core( schema_element_name_form: schema_element_name_form, schema_element_name_overrides: schema_element_name_overrides, derived_type_name_formats: derived_type_name_formats, + type_name_overrides: type_name_overrides, enum_value_overrides_by_type: enum_value_overrides_by_type, reload_schema_artifacts: reload_schema_artifacts, &schema_definition diff --git a/spec_support/spec_helper.rb b/spec_support/spec_helper.rb index 26c0b7227..e62430bd5 100644 --- a/spec_support/spec_helper.rb +++ b/spec_support/spec_helper.rb @@ -360,6 +360,7 @@ def generate_schema_artifacts( schema_element_name_form: :snake_case, schema_element_name_overrides: {}, derived_type_name_formats: {}, + type_name_overrides: {}, enum_value_overrides_by_type: {}, reload_schema_artifacts: false ) @@ -371,6 +372,7 @@ def generate_schema_artifacts( schema_element_name_form: schema_element_name_form, schema_element_name_overrides: schema_element_name_overrides, derived_type_name_formats: derived_type_name_formats, + type_name_overrides: type_name_overrides, enum_value_overrides_by_type: enum_value_overrides_by_type, reload_schema_artifacts: reload_schema_artifacts, output: output From f1d830d5120919ac8b0d8b5e639789809792fd78 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 10 Jun 2026 16:17:46 -0700 Subject: [PATCH 13/24] Revert unnecessary cursor_type_name call --- .../schema_definition/schema_elements/built_in_types.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 1036ba01a..9c882bcb9 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -517,14 +517,14 @@ def register_standard_elastic_graph_types f.documentation "Indicates if there is another page of results available before the current one." end - t.field names.start_cursor, @schema_def_state.type_namer.cursor_type_name, graphql_only: true do |f| + t.field names.start_cursor, "Cursor", graphql_only: true do |f| f.documentation <<~EOS The `Cursor` of the first edge of the current page. This can be passed in the next query as a `before` argument to paginate backwards. EOS end - t.field names.end_cursor, @schema_def_state.type_namer.cursor_type_name, graphql_only: true do |f| + t.field names.end_cursor, "Cursor", graphql_only: true do |f| f.documentation <<~EOS The `Cursor` of the last edge of the current page. This can be passed in the next query as a `after` argument to paginate forwards. From 8291c7ed9f750481910b921932eb89a7c08f7d07 Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:20:34 -0700 Subject: [PATCH 14/24] Apply suggestion from @myronmarston Co-authored-by: Myron Marston --- .../graphql_schema/built_in_types_spec.rb | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb index 7211c1c10..956c5305c 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb @@ -107,17 +107,6 @@ module SchemaDefinition end end - 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 - %w[Boolean Float Int].each do |invalid_cursor_override| it "rejects overriding `Cursor` to a non-string-compatible type like #{invalid_cursor_override}" do expect { From ef4ad1afbc2fbd3888b39b4d2322088563d2c740 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 10 Jun 2026 17:09:33 -0700 Subject: [PATCH 15/24] Use simple coercion adapter to validate cursor scalars --- config/schema/artifacts/runtime_metadata.yaml | 4 +- .../runtime_metadata.yaml | 4 +- .../elastic_graph/graphql/decoded_cursor.rb | 4 -- .../scalar_coercion_adapters/cursor.rb | 27 ++++++++++++ .../scalar_coercion_adapters/cursor.rbs | 11 +++++ .../spec/acceptance/aggregations_spec.rb | 13 +++--- .../acceptance/nested_relationships_spec.rb | 13 +++--- .../scalar_coercion_adapters/cursor_spec.rb | 43 +++++++++++++++++++ .../schema_elements/built_in_types.rb | 2 + 9 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb create mode 100644 elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs create mode 100644 elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb diff --git a/config/schema/artifacts/runtime_metadata.yaml b/config/schema/artifacts/runtime_metadata.yaml index 3a45dae91..19a30bf8f 100644 --- a/config/schema/artifacts/runtime_metadata.yaml +++ b/config/schema/artifacts/runtime_metadata.yaml @@ -9268,8 +9268,8 @@ scalar_types_by_name: require_path: elastic_graph/indexer/indexing_preparers/no_op Cursor: coercion_adapter: - name: ElasticGraph::GraphQL::ScalarCoercionAdapters::NoOp - require_path: elastic_graph/graphql/scalar_coercion_adapters/no_op + name: ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor + require_path: elastic_graph/graphql/scalar_coercion_adapters/cursor grouping_missing_value_placeholder: "$SECURE_RANDOM_VALUE" indexing_preparer: name: ElasticGraph::Indexer::IndexingPreparers::NoOp diff --git a/config/schema/artifacts_with_apollo/runtime_metadata.yaml b/config/schema/artifacts_with_apollo/runtime_metadata.yaml index e5706f3be..8e7ff906c 100644 --- a/config/schema/artifacts_with_apollo/runtime_metadata.yaml +++ b/config/schema/artifacts_with_apollo/runtime_metadata.yaml @@ -9405,8 +9405,8 @@ scalar_types_by_name: require_path: elastic_graph/indexer/indexing_preparers/no_op Cursor: coercion_adapter: - name: ElasticGraph::GraphQL::ScalarCoercionAdapters::NoOp - require_path: elastic_graph/graphql/scalar_coercion_adapters/no_op + name: ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor + require_path: elastic_graph/graphql/scalar_coercion_adapters/cursor grouping_missing_value_placeholder: "$SECURE_RANDOM_VALUE" indexing_preparer: name: ElasticGraph::Indexer::IndexingPreparers::NoOp diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb index 743bce052..1ab3d3b53 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/decoded_cursor.rb @@ -45,10 +45,6 @@ def self.try_decode(string) # Tries to decode the given string cursor, raising an `Errors::InvalidCursorError` if it's invalid. def self.decode!(string) - unless string.is_a?(::String) - raise Errors::InvalidCursorError, "Cursor must be a String, got #{string.class}" - end - return SINGLETON if string == SINGLETON_CURSOR json = ::Base64.urlsafe_decode64(string) new(::JSON.parse(json)) diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb new file mode 100644 index 000000000..df82fe78e --- /dev/null +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb @@ -0,0 +1,27 @@ +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +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) + 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) + value + end + end + end + end +end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs new file mode 100644 index 000000000..11e31661d --- /dev/null +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs @@ -0,0 +1,11 @@ +module ElasticGraph + class GraphQL + module ScalarCoercionAdapters + class Cursor + def self.coerce_input: (untyped, untyped) -> ::String? + + def self.coerce_result: (untyped, untyped) -> untyped + end + end + end +end diff --git a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb index 4ddf29bbf..a7a83c563 100644 --- a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb @@ -1404,14 +1404,11 @@ def forward_paginate_through_workspace_id_groupings {"count" => 2, grouped_by => {case_correctly("workspace_id") => "w2"}} ] - # When Cursor is overridden to String (camelCase context), GraphQL validates the type and rejects arrays - # at the schema validation level. When Cursor is a custom scalar (snake_case context), GraphQL doesn't - # validate input types for custom scalars, so the array reaches our decode logic which validates the type. - array_error = if is_a?(CamelCaseGraphQLAcceptanceAdapter) - "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type 'String'." - else - "Cursor must be a String, got Array" - end + # 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. + cursor_type = is_a?(CamelCaseGraphQLAcceptanceAdapter) ? "String" : "Cursor" + 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) diff --git a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb index 603f9664a..6d49555a4 100644 --- a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb @@ -307,14 +307,11 @@ module ElasticGraph }.to log_warning a_string_including("`first` cannot be negative, but is -2.") # Demonstrate how broken cursors behave. - # When Cursor is overridden to String (camelCase context), GraphQL validates the type and rejects arrays - # at the schema validation level. When Cursor is a custom scalar (snake_case context), GraphQL doesn't - # validate input types for custom scalars, so the array reaches our decode logic which validates the type. - array_error = if is_a?(CamelCaseGraphQLAcceptanceAdapter) - "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type 'String'." - else - "Cursor must be a String, got Array" - end + # 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. + cursor_type = is_a?(CamelCaseGraphQLAcceptanceAdapter) ? "String" : "Cursor" + 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( diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb new file mode 100644 index 000000000..4722d664a --- /dev/null +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb @@ -0,0 +1,43 @@ +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +require "elastic_graph/graphql/scalar_coercion_adapters/cursor" + +module ElasticGraph + class GraphQL + module ScalarCoercionAdapters + RSpec.describe Cursor do + describe ".coerce_input" do + it "accepts string values" do + result = Cursor.coerce_input("abc123", nil) + expect(result).to eq("abc123") + end + + it "accepts nil" do + result = Cursor.coerce_input(nil, nil) + expect(result).to be_nil + end + + it "rejects non-string values by returning nil" do + expect(Cursor.coerce_input(123, nil)).to be_nil + expect(Cursor.coerce_input([1, 2, 3], nil)).to be_nil + expect(Cursor.coerce_input({key: "value"}, nil)).to be_nil + expect(Cursor.coerce_input(true, nil)).to be_nil + end + end + + describe ".coerce_result" do + it "returns the value as-is" do + expect(Cursor.coerce_result("abc123", nil)).to eq("abc123") + expect(Cursor.coerce_result(nil, nil)).to be_nil + end + end + end + end + end +end diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 9c882bcb9..4e59d5b6a 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -784,6 +784,8 @@ def register_custom_elastic_graph_scalars # used them. t.mapping type: "keyword" t.json_schema type: "string" + t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", + defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. From ab59a883208ff5847050346a315ab40f9d773ed9 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Wed, 10 Jun 2026 17:38:38 -0700 Subject: [PATCH 16/24] Allow overriding cursor scalar with custom type name --- .../schema_elements/built_in_types.rb | 12 +++--- .../graphql_schema/built_in_types_spec.rb | 38 +++++++++++++++++++ .../schema_definition/api_extension.rb | 7 +++- .../scalar_type_extension_spec.rb | 16 ++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 4e59d5b6a..73d7de3e4 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -773,11 +773,12 @@ def register_custom_elastic_graph_scalars # Validate that Cursor type override is compatible (must be string-like) validate_cursor_type_override! - # Only register the Cursor scalar if it's not overridden to a built-in type. - # When overridden to a built-in type like String, we use that type directly - # and skip scalar registration to avoid duplicate type definition errors. - unless STOCK_GRAPHQL_SCALARS.include?(@schema_def_state.type_namer.cursor_type_name) - schema_def_api.scalar_type "Cursor" do |t| + # Register the cursor scalar unless it's overridden to a built-in type. + # When overridden to a built-in type like String or ID, we use that type directly. + # When overridden to a custom scalar (e.g., PaginationCursor), we register it automatically. + cursor_type_name = @schema_def_state.type_namer.cursor_type_name + unless STOCK_GRAPHQL_SCALARS.include?(cursor_type_name) + schema_def_api.scalar_type cursor_type_name do |t| # Technically, we don't use the mapping or json_schema on this type since it's a return-only # type and isn't indexed. However, `scalar_type` requires them to be set (since custom scalars # defined by users will need those set) so we set them here to what they would be if we actually @@ -786,6 +787,7 @@ def register_custom_elastic_graph_scalars t.json_schema type: "string" t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" + t.warehouse_column type: "STRING" t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb index 956c5305c..281861dbe 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb @@ -115,6 +115,44 @@ module SchemaDefinition end end + it "allows overriding `Cursor` to a custom scalar like `PaginationCursor`" do + result = define_schema(type_name_overrides: {Cursor: "PaginationCursor"}) do |api| + # User must define their custom cursor scalar + api.scalar_type "PaginationCursor" do |t| + t.mapping type: "keyword" + t.json_schema type: "string" + t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", + defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" + end + + 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 custom scalar + expect(type_def_from(result, "Cursor")).to be_nil + + # PageInfo fields should use the custom scalar + 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}: PaginationCursor + #{schema_elements.end_cursor}: PaginationCursor + } + EOS + + # Edge.cursor field should use the custom scalar + expect(type_def_from(result, "WidgetEdge")).to include("#{schema_elements.cursor}: PaginationCursor") + + # Pagination arguments should use the custom scalar + query_type = type_def_from(result, "Query") + expect(query_type).to include("after: PaginationCursor") + expect(query_type).to include("before: PaginationCursor") + end + it "defines a `GeoLocation` object type and related filter types" do expect(type_named("GeoLocation", include_docs: true)).to eq(<<~EOS.strip) """ diff --git a/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb b/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb index 26cc0cb0e..c5079d739 100644 --- a/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb +++ b/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb @@ -60,7 +60,12 @@ def self.extended(api) api.on_built_in_types do |type| case type when ScalarTypeExtension - type.warehouse_column type: COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.name) + # Only configure warehouse_column if not already set (e.g., by the scalar definition itself). + # This allows custom cursor scalars to configure themselves while providing defaults for + # standard built-in types. + unless type.warehouse_column_type + type.warehouse_column type: COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.name) + end end end end diff --git a/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb b/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb index b513b4480..c2f7d3d92 100644 --- a/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb +++ b/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb @@ -71,6 +71,22 @@ module SchemaDefinition "call `warehouse_column type:" )) end + + it "respects warehouse_column configuration from custom cursor scalar overrides (e.g., PaginationCursor)" do + results = define_warehouse_schema(type_name_overrides: {Cursor: "PaginationCursor"}) do |s| + # Define a custom type that includes a PaginationCursor field so we can verify + # the warehouse column type is configured correctly. + s.object_type "CursorTest" do |t| + t.field "id", "ID" + t.field "cursor_value", "PaginationCursor" + t.index "cursor_tests" + end + end + + # The PaginationCursor scalar is automatically registered in built_in_types.rb with + # warehouse_column type: "STRING", and the warehouse callback respects that configuration. + expect(warehouse_column_def_from(results, "cursor_tests", "cursor_value")).to eq "cursor_value STRING" + end end end end From ebf552412fc1fbe41ef5f96071f5687369ece8d0 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 07:42:14 -0700 Subject: [PATCH 17/24] Conditionally configure warehouse type to avoid when the module isn't loaded --- .../schema_elements/built_in_types.rb | 4 +++- .../graphql_schema/built_in_types_spec.rb | 14 ++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 73d7de3e4..8068b6984 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -787,7 +787,9 @@ def register_custom_elastic_graph_scalars t.json_schema type: "string" t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" - t.warehouse_column type: "STRING" + + # Configure warehouse column type if the warehouse extension is loaded. + t.warehouse_column type: "STRING" if t.respond_to?(:warehouse_column) t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb index 281861dbe..01637cad6 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/built_in_types_spec.rb @@ -117,13 +117,8 @@ module SchemaDefinition it "allows overriding `Cursor` to a custom scalar like `PaginationCursor`" do result = define_schema(type_name_overrides: {Cursor: "PaginationCursor"}) do |api| - # User must define their custom cursor scalar - api.scalar_type "PaginationCursor" do |t| - t.mapping type: "keyword" - t.json_schema type: "string" - t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", - defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" - end + # Custom cursor scalars are automatically registered by built_in_types.rb + # when type_name_overrides is used, so no manual definition is needed. api.object_type "Widget" do |t| t.field "id", "ID!" @@ -131,9 +126,12 @@ module SchemaDefinition end end - # The Cursor scalar should not be registered when overridden to a custom scalar + # The standard Cursor scalar should not be registered when overridden expect(type_def_from(result, "Cursor")).to be_nil + # PaginationCursor should be auto-registered + expect(type_def_from(result, "PaginationCursor")).to eq("scalar PaginationCursor") + # PageInfo fields should use the custom scalar expect(type_def_from(result, "PageInfo")).to eq(<<~EOS.strip) type PageInfo { From e745c6dbd72a6c7a87c0f2502b0686d9cedbc83e Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 10:07:43 -0700 Subject: [PATCH 18/24] Remove unused support method parameter --- .../spec/support/scalar_coercion_adapter.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/elasticgraph-graphql/spec/support/scalar_coercion_adapter.rb b/elasticgraph-graphql/spec/support/scalar_coercion_adapter.rb index f3a0e8c7f..1261290b5 100644 --- a/elasticgraph-graphql/spec/support/scalar_coercion_adapter.rb +++ b/elasticgraph-graphql/spec/support/scalar_coercion_adapter.rb @@ -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` From b8b6cd724f549f5edac7fbc6311cadcbec8e7c79 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 11:12:31 -0700 Subject: [PATCH 19/24] Add nocov annotation to branch that is conditionally executed when the warehouse extension is loaded --- .../schema_definition/schema_elements/built_in_types.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 8068b6984..82d75367a 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -789,7 +789,10 @@ def register_custom_elastic_graph_scalars defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" # Configure warehouse column type if the warehouse extension is loaded. + # :nocov: Not all test configurations load the warehouse extension, so this branch isn't always hit. + # It is covered by elasticgraph-warehouse tests where the extension is loaded. t.warehouse_column type: "STRING" if t.respond_to?(:warehouse_column) + # :nocov: t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. From d53fb38082a355eb41cfc643636381742831395f Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:05:08 -0700 Subject: [PATCH 20/24] Apply suggestions from code review Co-authored-by: Myron Marston --- .../scalar_coercion_adapters/cursor.rbs | 4 +--- .../schema_elements/built_in_types.rb | 1 - .../schema_definition/api_extension.rb | 7 +----- .../scalar_type_extension_spec.rb | 24 +++++++++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs index 11e31661d..e212bd14b 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/scalar_coercion_adapters/cursor.rbs @@ -2,9 +2,7 @@ module ElasticGraph class GraphQL module ScalarCoercionAdapters class Cursor - def self.coerce_input: (untyped, untyped) -> ::String? - - def self.coerce_result: (untyped, untyped) -> untyped + extend SchemaArtifacts::_ScalarCoercionAdapter[::String, ::String] end end end diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 82d75367a..6d02af28d 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -161,7 +161,6 @@ module SchemaElements # @!attribute [rw] names # @private class BuiltInTypes - # Built-in GraphQL scalar types that are part of the GraphQL specification. # Non-string standard GraphQL scalars that cannot be used for cursor overrides. # String and ID are valid, as are any custom scalar names (e.g., PaginationCursor). INVALID_CURSOR_TYPE_OVERRIDES = (STOCK_GRAPHQL_SCALARS - %w[ID String]).freeze diff --git a/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb b/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb index c5079d739..d5db4d36a 100644 --- a/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb +++ b/elasticgraph-warehouse/lib/elastic_graph/warehouse/schema_definition/api_extension.rb @@ -60,12 +60,7 @@ def self.extended(api) api.on_built_in_types do |type| case type when ScalarTypeExtension - # Only configure warehouse_column if not already set (e.g., by the scalar definition itself). - # This allows custom cursor scalars to configure themselves while providing defaults for - # standard built-in types. - unless type.warehouse_column_type - type.warehouse_column type: COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.name) - end + type.warehouse_column type: COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.type_ref.with_reverted_override.name) end end end diff --git a/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb b/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb index c2f7d3d92..8839a646a 100644 --- a/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb +++ b/elasticgraph-warehouse/spec/unit/elastic_graph/warehouse/schema_definition/scalar_type_extension_spec.rb @@ -72,20 +72,30 @@ module SchemaDefinition )) end - it "respects warehouse_column configuration from custom cursor scalar overrides (e.g., PaginationCursor)" do - results = define_warehouse_schema(type_name_overrides: {Cursor: "PaginationCursor"}) do |s| + it "respects type name overrides for all built in types (except stock GraphQL scalars that can't be renamed)" do + overrides = APIExtension::COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.except(*STOCK_GRAPHQL_SCALARS).keys.to_h do |original_type_name| + [original_type_name.to_sym, "Overridden#{original_type_name}"] + end + + results = define_warehouse_schema(type_name_overrides: overrides) do |s| # Define a custom type that includes a PaginationCursor field so we can verify # the warehouse column type is configured correctly. s.object_type "CursorTest" do |t| t.field "id", "ID" - t.field "cursor_value", "PaginationCursor" - t.index "cursor_tests" + + overrides.each do |original_type, overridden_type| + t.field "overridden_#{original_type}", overridden_type + end + + t.index "override_tests" end end - # The PaginationCursor scalar is automatically registered in built_in_types.rb with - # warehouse_column type: "STRING", and the warehouse callback respects that configuration. - expect(warehouse_column_def_from(results, "cursor_tests", "cursor_value")).to eq "cursor_value STRING" + overrides.each do |original_type, overridden_type| + field_name = "overridden_#{original_type}" + expected_column_type = APIExtension::COLUMN_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(original_type.to_s) + expect(warehouse_column_def_from(results, "override_tests", field_name)).to eq "#{field_name} #{expected_column_type}" + end end end end From 3f457b53e743ecb5a980ff2d4eff85cb8ac0f8cb Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 22:14:11 -0700 Subject: [PATCH 21/24] Add support method --- .../spec/acceptance/aggregations_spec.rb | 1 - .../elasticgraph_graphql_acceptance_support.rb | 12 ++++++++++++ .../spec/acceptance/nested_relationships_spec.rb | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb index a7a83c563..d3cf356d5 100644 --- a/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/aggregations_spec.rb @@ -1407,7 +1407,6 @@ def forward_paginate_through_workspace_id_groupings # 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. - cursor_type = is_a?(CamelCaseGraphQLAcceptanceAdapter) ? "String" : "Cursor" array_error = "Argument 'after' on Field '#{case_correctly("widget_aggregations")}' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'." expect { diff --git a/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb b/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb index 2611d1976..c384a1dd0 100644 --- a/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb +++ b/elasticgraph-graphql/spec/acceptance/elasticgraph_graphql_acceptance_support.rb @@ -165,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 @@ -185,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 diff --git a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb index 6d49555a4..e13ca2109 100644 --- a/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb +++ b/elasticgraph-graphql/spec/acceptance/nested_relationships_spec.rb @@ -310,7 +310,6 @@ module ElasticGraph # 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. - cursor_type = is_a?(CamelCaseGraphQLAcceptanceAdapter) ? "String" : "Cursor" array_error = "Argument 'after' on Field 'components' has an invalid value ([1, 2, 3]). Expected type '#{cursor_type}'." expect { From e23423d2fca2245ec7842a0aafa4ba3fd9c4195c Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 22:29:24 -0700 Subject: [PATCH 22/24] Link documentation to validated snippet --- config/site/src/guides/customizing-the-graphql-schema.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/config/site/src/guides/customizing-the-graphql-schema.md b/config/site/src/guides/customizing-the-graphql-schema.md index bc8c2c62e..60712c684 100644 --- a/config/site/src/guides/customizing-the-graphql-schema.md +++ b/config/site/src/guides/customizing-the-graphql-schema.md @@ -79,10 +79,7 @@ for cursor fields by default, which provides better type safety and documentatio To resolve this, override the `Cursor` type to `String`: -```ruby -# Within `ElasticGraph::Local::RakeTasks.new { ... }` in your `Rakefile`: -tasks.type_name_overrides = {Cursor: "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) From 0afb62739fc36c65aa6e8fcaf36796da6c62d472 Mon Sep 17 00:00:00 2001 From: Anthony Castiglia <98043508+anthonycastiglia-toast@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:32:24 -0700 Subject: [PATCH 23/24] Apply suggestions from code review Co-authored-by: Myron Marston --- .../schema_definition/schema_elements/built_in_types.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 6d02af28d..7aee7630d 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -777,7 +777,7 @@ def register_custom_elastic_graph_scalars # When overridden to a custom scalar (e.g., PaginationCursor), we register it automatically. cursor_type_name = @schema_def_state.type_namer.cursor_type_name unless STOCK_GRAPHQL_SCALARS.include?(cursor_type_name) - schema_def_api.scalar_type cursor_type_name do |t| + schema_def_api.scalar_type "Cursor" do |t| # Technically, we don't use the mapping or json_schema on this type since it's a return-only # type and isn't indexed. However, `scalar_type` requires them to be set (since custom scalars # defined by users will need those set) so we set them here to what they would be if we actually @@ -787,12 +787,6 @@ def register_custom_elastic_graph_scalars t.coerce_with "ElasticGraph::GraphQL::ScalarCoercionAdapters::Cursor", defined_at: "elastic_graph/graphql/scalar_coercion_adapters/cursor" - # Configure warehouse column type if the warehouse extension is loaded. - # :nocov: Not all test configurations load the warehouse extension, so this branch isn't always hit. - # It is covered by elasticgraph-warehouse tests where the extension is loaded. - t.warehouse_column type: "STRING" if t.respond_to?(:warehouse_column) - # :nocov: - t.documentation <<~EOS An opaque string value representing a specific location in a paginated connection type. Returned cursors can be passed back in the next query via the `before` or `after` From e586d2ae1852be97871d3a78805967f1257f6d00 Mon Sep 17 00:00:00 2001 From: anthonycastiglia-toast Date: Thu, 11 Jun 2026 22:54:25 -0700 Subject: [PATCH 24/24] Use scalar coercion adapter shared context in cursor spec --- .../scalar_coercion_adapters/cursor_spec.rb | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb index 4722d664a..d52ef7512 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/scalar_coercion_adapters/cursor_spec.rb @@ -6,35 +6,39 @@ # # frozen_string_literal: true -require "elastic_graph/graphql/scalar_coercion_adapters/cursor" +require "support/scalar_coercion_adapter" module ElasticGraph class GraphQL module ScalarCoercionAdapters - RSpec.describe Cursor do - describe ".coerce_input" do + RSpec.describe "Cursor" do + include_context "scalar coercion adapter support", "Cursor" + + context "input coercion" do it "accepts string values" do - result = Cursor.coerce_input("abc123", nil) - expect(result).to eq("abc123") + expect_input_value_to_be_accepted("abc123") end it "accepts nil" do - result = Cursor.coerce_input(nil, nil) - expect(result).to be_nil + expect_input_value_to_be_accepted(nil) end - it "rejects non-string values by returning nil" do - expect(Cursor.coerce_input(123, nil)).to be_nil - expect(Cursor.coerce_input([1, 2, 3], nil)).to be_nil - expect(Cursor.coerce_input({key: "value"}, nil)).to be_nil - expect(Cursor.coerce_input(true, nil)).to be_nil + it "rejects non-string values" do + expect_input_value_to_be_rejected(123) + expect_input_value_to_be_rejected([1, 2, 3]) + expect_input_value_to_be_rejected({"key" => "value"}) + expect_input_value_to_be_rejected(true) + expect_input_value_to_be_rejected(false) end end - describe ".coerce_result" do - it "returns the value as-is" do - expect(Cursor.coerce_result("abc123", nil)).to eq("abc123") - expect(Cursor.coerce_result(nil, nil)).to be_nil + context "result coercion" do + it "returns string values as-is" do + expect_result_to_be_returned("abc123", as: "abc123") + end + + it "returns nil as-is" do + expect_result_to_be_returned(nil) end end end