From a516dcaa413d950c6c8513c28337d3912c71dc2f Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Wed, 18 Jun 2025 09:41:55 -0400 Subject: [PATCH] add support for deferred scope loaders. --- lib/graphql/cardinal.rb | 3 +- lib/graphql/cardinal/depth_executor.rb | 4 +- lib/graphql/cardinal/executor.rb | 170 ++++++++---------- lib/graphql/cardinal/executor/coercion.rb | 28 --- .../cardinal/executor/execution_field.rb | 3 +- .../cardinal/executor/execution_scope.rb | 39 +++- lib/graphql/cardinal/executor/hot_paths.rb | 75 ++++++++ .../cardinal/executor/response_shape.rb | 6 +- lib/graphql/cardinal/loader.rb | 58 ++++++ lib/graphql/cardinal/promise.rb | 11 +- lib/graphql/cardinal/set_loader.rb | 42 ----- test/fixtures.rb | 10 -- .../cardinal/executor/scope_loader_test.rb | 98 ++++++++++ 13 files changed, 364 insertions(+), 183 deletions(-) delete mode 100644 lib/graphql/cardinal/executor/coercion.rb create mode 100644 lib/graphql/cardinal/executor/hot_paths.rb create mode 100644 lib/graphql/cardinal/loader.rb delete mode 100644 lib/graphql/cardinal/set_loader.rb create mode 100644 test/graphql/cardinal/executor/scope_loader_test.rb diff --git a/lib/graphql/cardinal.rb b/lib/graphql/cardinal.rb index 0f182fa..a91c887 100644 --- a/lib/graphql/cardinal.rb +++ b/lib/graphql/cardinal.rb @@ -10,8 +10,9 @@ module Cardinal end end -require_relative "cardinal/promise" require_relative "cardinal/errors" +require_relative "cardinal/promise" +require_relative "cardinal/loader" require_relative "cardinal/field_resolvers" require_relative "cardinal/executor" require_relative "cardinal/depth_executor" diff --git a/lib/graphql/cardinal/depth_executor.rb b/lib/graphql/cardinal/depth_executor.rb index 79ccd4e..2956ffe 100644 --- a/lib/graphql/cardinal/depth_executor.rb +++ b/lib/graphql/cardinal/depth_executor.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require_relative "./executor/coercion" +require_relative "./executor/hot_paths" module GraphQL module Cardinal class DepthExecutor - include GraphQL::Cardinal::Executor::Coercion + include GraphQL::Cardinal::Executor::HotPaths attr_reader :exec_count diff --git a/lib/graphql/cardinal/executor.rb b/lib/graphql/cardinal/executor.rb index 4caf348..54b5f02 100644 --- a/lib/graphql/cardinal/executor.rb +++ b/lib/graphql/cardinal/executor.rb @@ -4,40 +4,39 @@ require_relative "./executor/execution_field" require_relative "./executor/authorization" require_relative "./executor/tracer" -require_relative "./executor/coercion" +require_relative "./executor/hot_paths" require_relative "./executor/response_hash" require_relative "./executor/response_shape" module GraphQL module Cardinal class Executor - include Coercion + include HotPaths include ResponseShape TYPENAME_FIELD = "__typename" + TYPENAME_FIELD_RESOLVER = TypenameResolver.new attr_reader :exec_count def initialize(schema, resolvers, document, root_object) - @schema = schema + @query = GraphQL::Query.new(schema, document: document) # << for schema reference @resolvers = resolvers @document = document @root_object = root_object @tracer = Tracer.new @variables = {} - @context = {} + @context = { query: @query } @data = {} @errors = [] @inline_errors = false + @unordered_keys = false @path = [] @exec_queue = [] @exec_count = 0 - @non_null_violation = false end def perform - @query = GraphQL::Query.new(@schema, document: @document) # << for schema reference - @context[:query] = @query operation = @query.selected_operation root_scopes = case operation.operation_type @@ -73,7 +72,7 @@ def perform end response = { - "data" => @inline_errors ? shape_response(@data) : @data, + "data" => @inline_errors || @unordered_keys ? shape_response(@data) : @data, } response["errors"] = @errors.map(&:to_h) unless @errors.empty? response @@ -82,57 +81,74 @@ def perform private def execute_scope(exec_scope) - lazy_execution_fields = [] - execution_fields_by_key(exec_scope.parent_type, exec_scope.selections).each_value do |exec_field| - @path.push(exec_field.key) - parent_type = exec_scope.parent_type - parent_sources = exec_scope.sources - field_name = exec_field.name - - exec_field.type = @query.get_field(parent_type, field_name).type - value_type = exec_field.type.unwrap - - field_resolver = @resolvers.dig(parent_type.graphql_name, field_name) - unless field_resolver - raise NotImplementedError, "No field resolver for `#{parent_type.graphql_name}.#{field_name}`" - end + unless exec_scope.fields + exec_scope.fields = execution_fields_by_key(exec_scope.parent_type, exec_scope.selections) + exec_scope.fields.each_value do |exec_field| + @path.push(exec_field.key) + parent_type = exec_scope.parent_type + parent_sources = exec_scope.sources + field_name = exec_field.name + + exec_field.type = @query.get_field(parent_type, field_name).type + value_type = exec_field.type.unwrap + + field_resolver = @resolvers.dig(parent_type.graphql_name, field_name) + unless field_resolver + if field_name == TYPENAME_FIELD + field_resolver = TYPENAME_FIELD_RESOLVER + else + raise NotImplementedError, "No field resolver for `#{parent_type.graphql_name}.#{field_name}`" + end + end - resolved_sources = if !field_resolver.authorized?(@context) - @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup) - Array.new(parent_sources.length, nil) - elsif !Authorization.can_access_type?(value_type, @context) - @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup) - Array.new(parent_sources.length, nil) - else - begin - @tracer&.before_resolve_field(parent_type, field_name, parent_sources.length, @context) - field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope) - rescue StandardError => e - report_exception(e.message) - @errors << InternalError.new(e.message, path: @path.dup) + resolved_sources = if !field_resolver.authorized?(@context) + @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup) Array.new(parent_sources.length, nil) - ensure - @tracer&.after_resolve_field(parent_type, field_name, parent_sources.length, @context) - @exec_count += 1 + elsif !Authorization.can_access_type?(value_type, @context) + @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup) + Array.new(parent_sources.length, nil) + else + begin + @tracer&.before_resolve_field(parent_type, field_name, parent_sources.length, @context) + field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope) + rescue StandardError => e + raise e + report_exception(e.message) + @errors << InternalError.new(e.message, path: @path.dup) + Array.new(parent_sources.length, nil) + ensure + @tracer&.after_resolve_field(parent_type, field_name, parent_sources.length, @context) + @exec_count += 1 + end end - end - if resolved_sources.is_a?(Promise) - resolved_sources.source = exec_field - lazy_execution_fields << resolved_sources - else - resolve_execution_field(exec_scope, exec_field, resolved_sources) + if resolved_sources.is_a?(Promise) + exec_field.promise = resolved_sources + else + resolve_execution_field(exec_scope, exec_field, resolved_sources) + end + + @path.pop end - @path.pop end - # --- RUN LAZY CALLBACKS!! + if exec_scope.lazy_fields_pending? + if exec_scope.lazy_fields_ready? + exec_scope.method(:lazy_exec!).call # << noop for loaders that have already run + exec_scope.fields.each_value do |exec_field| + next unless exec_field.promise - lazy_execution_fields.each do |promise| - exec_field = promise.source - @path.push(exec_field.key) - resolve_execution_field(exec_scope, exec_field, promise.value) - @path.pop + @path.push(exec_field.key) + resolve_execution_field(exec_scope, exec_field, exec_field.promise.value) + @path.pop + + # could be smarter about tracking key order and checking if we got it right... + @unordered_keys = true + end + else + # requeue the scope to wait on others that haven't built fields yet + @exec_queue << exec_scope + end end nil @@ -156,6 +172,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources) next_sources = [] next_responses = [] resolved_sources.each_with_index do |source, i| + # DANGER: HOT PATH! parent_responses[i][field_key] = build_composite_response(field_type, source, next_sources, next_responses) end @@ -168,26 +185,33 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources) next_sources_by_type = Hash.new { |h, k| h[k] = [] } next_responses_by_type = Hash.new { |h, k| h[k] = [] } next_sources.each_with_index do |source, i| + # DANGER: HOT PATH! impl_type = type_resolver.call(source, @context) next_sources_by_type[impl_type] << (field_name == TYPENAME_FIELD ? impl_type.graphql_name : source) next_responses_by_type[impl_type] << next_responses[i].tap { |r| r.typename = impl_type.graphql_name } end + loader_cache = {} # << all scopes in the abstract generation share a loader cache + loader_group = [] next_sources_by_type.each do |impl_type, impl_type_sources| # check concrete type access only once per resolved type... unless Authorization.can_access_type?(impl_type, @context) @errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup) - impl_type_sources = Array.new(impl_type_sources.length, AuthorizationError.new(path: @path.dup)) + impl_type_sources = Array.new(impl_type_sources.length, nil) end - @exec_queue << ExecutionScope.new( + loader_group << ExecutionScope.new( parent_type: impl_type, selections: exec_field.selections, sources: impl_type_sources, responses: next_responses_by_type[impl_type], + loader_cache: loader_cache, + loader_group: loader_group, parent: exec_scope, ) end + + @exec_queue.concat(loader_group) else @exec_queue << ExecutionScope.new( parent_type: return_type, @@ -200,6 +224,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources) else # build leaf results resolved_sources.each_with_index do |val, i| + # DANGER: HOT PATH! parent_responses[i][field_key] = if val.nil? || val.is_a?(StandardError) build_missing_value(field_type, val) elsif return_type.kind.scalar? @@ -213,45 +238,6 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources) end end - def build_composite_response(field_type, source, next_sources, next_responses) - # if object authorization check implemented, then... - # unless Authorization.can_access_object?(return_type, source, @context) - - if source.nil? || source.is_a?(ExecutionError) - build_missing_value(field_type, source) - elsif field_type.list? - unless source.is_a?(Array) - report_exception("Incorrect result for list field. Expected Array, got #{source.class}") - return build_missing_value(field_type, nil) - end - - field_type = field_type.of_type while field_type.non_null? - - source.map do |src| - build_composite_response(field_type.of_type, src, next_sources, next_responses) - end - else - next_sources << source - next_responses << ResponseHash.new - next_responses.last - end - end - - def build_missing_value(field_type, val) - if field_type.non_null? - # upgrade nil in non-null positions to an error - val = InvalidNullError.new(path: @path.dup, original_error: val) - end - - if val - # assure all errors have paths, and note inline error additions - val = val.path ? val : ExecutionError.new(val.message, path: @path.dup) - @inline_errors = true - end - - val - end - def execution_fields_by_key(parent_type, selections, map: Hash.new { |h, k| h[k] = ExecutionField.new(k) }) selections.each do |node| next if node_skipped?(node) diff --git a/lib/graphql/cardinal/executor/coercion.rb b/lib/graphql/cardinal/executor/coercion.rb deleted file mode 100644 index 8b1cc1b..0000000 --- a/lib/graphql/cardinal/executor/coercion.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module GraphQL::Cardinal - class Executor - module Coercion - def coerce_scalar_value(type, value) - case type.graphql_name - when "String" - value.is_a?(String) ? value : value.to_s - when "ID" - value.is_a?(String) || value.is_a?(Numeric) ? value : value.to_s - when "Int" - value.is_a?(Integer) ? value : Integer(value) - when "Float" - value.is_a?(Float) ? value : Float(value) - when "Boolean" - value == TrueClass || value == FalseClass ? value : !!value - else - value - end - end - - def coerce_enum_value(type, value) - value - end - end - end -end diff --git a/lib/graphql/cardinal/executor/execution_field.rb b/lib/graphql/cardinal/executor/execution_field.rb index 18cc9be..fbeecc5 100644 --- a/lib/graphql/cardinal/executor/execution_field.rb +++ b/lib/graphql/cardinal/executor/execution_field.rb @@ -4,13 +4,14 @@ module GraphQL::Cardinal class Executor class ExecutionField attr_reader :key, :node - attr_accessor :type + attr_accessor :type, :promise def initialize(key) @key = key.freeze @node = nil @nodes = nil @arguments = nil + @promise = nil end def name diff --git a/lib/graphql/cardinal/executor/execution_scope.rb b/lib/graphql/cardinal/executor/execution_scope.rb index a2d92e1..a6909c7 100644 --- a/lib/graphql/cardinal/executor/execution_scope.rb +++ b/lib/graphql/cardinal/executor/execution_scope.rb @@ -4,13 +4,50 @@ module GraphQL::Cardinal class Executor class ExecutionScope attr_reader :parent_type, :selections, :sources, :responses, :parent + attr_accessor :fields - def initialize(parent_type:, selections:, sources:, responses:, parent: nil) + def initialize( + parent_type:, + selections:, + sources:, + responses:, + loader_cache: nil, + loader_group: nil, + parent: nil + ) @parent_type = parent_type @selections = selections @sources = sources @responses = responses + @loader_cache = loader_cache + @loader_group = loader_group @parent = parent + @fields = nil + end + + def defer(loader_class, keys:, group: nil) + loader = loader_cache[[loader_class, group]] ||= loader_class.new(group) + loader.load(keys) + end + + # does any field in this scope have a pending promise? + def lazy_fields_pending? + @fields&.each_value&.any? { _1.promise&.pending? } || false + end + + # is this scope ungrouped, or have all scopes in the group built their fields? + def lazy_fields_ready? + !@loader_group || @loader_group.all?(&:fields) + end + + private + + def loader_cache + @loader_cache ||= {} + end + + def lazy_exec! + loader_cache.each_value { |loader| loader.method(:lazy_exec!).call } end end end diff --git a/lib/graphql/cardinal/executor/hot_paths.rb b/lib/graphql/cardinal/executor/hot_paths.rb new file mode 100644 index 0000000..d914c25 --- /dev/null +++ b/lib/graphql/cardinal/executor/hot_paths.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module GraphQL::Cardinal + class Executor + module HotPaths + # DANGER: HOT PATH! + # Overhead added here scales dramatically... + def build_composite_response(field_type, source, next_sources, next_responses) + # if object authorization check implemented, then... + # unless Authorization.can_access_object?(return_type, source, @context) + + if source.nil? || source.is_a?(ExecutionError) + build_missing_value(field_type, source) + elsif field_type.list? + unless source.is_a?(Array) + report_exception("Incorrect result for list field. Expected Array, got #{source.class}") + return build_missing_value(field_type, nil) + end + + field_type = field_type.of_type while field_type.non_null? + + source.map do |src| + build_composite_response(field_type.of_type, src, next_sources, next_responses) + end + else + next_sources << source + next_responses << ResponseHash.new + next_responses.last + end + end + + # DANGER: HOT PATH! + # Overhead added here scales dramatically... + def build_missing_value(field_type, val) + if field_type.non_null? + # upgrade nil in non-null positions to an error + val = InvalidNullError.new(path: @path.dup, original_error: val) + end + + if val + # assure all errors have paths, and note inline error additions + val = val.path ? val : ExecutionError.new(val.message, path: @path.dup) + @inline_errors = true + end + + val + end + + # DANGER: HOT PATH! + # Overhead added here scales dramatically... + def coerce_scalar_value(type, value) + case type.graphql_name + when "String" + value.is_a?(String) ? value : value.to_s + when "ID" + value.is_a?(String) || value.is_a?(Numeric) ? value : value.to_s + when "Int" + value.is_a?(Integer) ? value : Integer(value) + when "Float" + value.is_a?(Float) ? value : Float(value) + when "Boolean" + value == TrueClass || value == FalseClass ? value : !!value + else + value + end + end + + # DANGER: HOT PATH! + # Overhead added here scales dramatically... + def coerce_enum_value(type, value) + value + end + end + end +end diff --git a/lib/graphql/cardinal/executor/response_shape.rb b/lib/graphql/cardinal/executor/response_shape.rb index 4598460..d331299 100644 --- a/lib/graphql/cardinal/executor/response_shape.rb +++ b/lib/graphql/cardinal/executor/response_shape.rb @@ -26,7 +26,9 @@ def resolve_object_scope(raw_object, parent_type, selections) begin node_type = @query.get_field(parent_type, node.name).type named_type = node_type.unwrap - raw_value = raw_object[field_name] + + # delete and re-add to order result keys... + raw_value = raw_object.delete(field_name) raw_object[field_name] = if raw_value.is_a?(ExecutionError) # capture errors encountered in the response with proper path @@ -114,7 +116,7 @@ def resolve_list_scope(raw_list, current_node_type, selections) def typename_in_type?(typename, type) return true if type.graphql_name == typename - type.kind.abstract? && @@query.possible_types(type).any? do |t| + type.kind.abstract? && @query.possible_types(type).any? do |t| t.graphql_name == typename end end diff --git a/lib/graphql/cardinal/loader.rb b/lib/graphql/cardinal/loader.rb new file mode 100644 index 0000000..d601f68 --- /dev/null +++ b/lib/graphql/cardinal/loader.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module GraphQL + module Cardinal + class Loader + attr_reader :group + + def initialize(group) + @group = group + @map = {} + @promised = {} + @performed = false + end + + def perform(keys) + raise NotImplementedError + end + + def map_key(key) + key + end + + def load(keys) + keys.each do |key| + @map[map_key(key)] ||= key + end + + promised = @promised + Promise.new do |resolve, reject| + promised[resolve] = keys + end + end + + private + + def lazy_exec! + return if @performed + + @performed = true + all_keys = @map.values.freeze + all_results = perform(all_keys) + unless all_keys.size == all_results.size + raise "Wrong number of results. Expected #{all_keys.size}, got #{all_results.size}" + end + + all_keys.each_with_index do |key, index| + @map[map_key(key)] = all_results[index] + end + + @promised.each do |resolve, keys| + resolve.call(keys.map { |key| @map[map_key(key)] }) + end + + nil + end + end + end +end diff --git a/lib/graphql/cardinal/promise.rb b/lib/graphql/cardinal/promise.rb index ed15b61..a16df1d 100644 --- a/lib/graphql/cardinal/promise.rb +++ b/lib/graphql/cardinal/promise.rb @@ -43,10 +43,13 @@ def self.all(promises) end end - def then(on_fulfilled = nil, on_rejected = nil) + def then(on_fulfilled = nil, on_rejected = nil, &block) + raise ArgumentError, "Either on_fulfilled or block is required" unless on_fulfilled || block_given? + raise ArgumentError, "Exactly one of on_fulfilled or block is required" if on_fulfilled && block_given? + Promise.new do |resolve, reject| handle_then( - on_fulfilled || ->(value) { value }, + on_fulfilled || block, on_rejected || ->(reason) { raise reason }, resolve, reject @@ -78,6 +81,8 @@ def reason @reason if rejected? end + private + def resolve(value) return unless pending? @@ -98,8 +103,6 @@ def reject(reason) @on_rejected.clear end - private - def handle_then(on_fulfilled, on_rejected, resolve, reject) if resolved? begin diff --git a/lib/graphql/cardinal/set_loader.rb b/lib/graphql/cardinal/set_loader.rb deleted file mode 100644 index 9dd2587..0000000 --- a/lib/graphql/cardinal/set_loader.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Cardinal - class SetLoader - class << self - def for(scope, key) - scope[self][key] ||= new(key) - end - end - - def initialize - @promised_sets = nil - @mapping = nil - end - - def perform(keys) - raise NotImplementedError - end - - def promised_sets - @promised_sets ||= {} - end - - def mapping - @mapping ||= {} - end - - def mapping_key(item) - item - end - - def load(items) - items.each do |item| - mapping[mapping_key(item)] = nil - end - - ::Promise.new.tap { |p| p.source = self } - end - end - end -end diff --git a/test/fixtures.rb b/test/fixtures.rb index 3266801..9108ea6 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -59,18 +59,15 @@ def resolve(objects, _args, _ctx, _scope) BREADTH_RESOLVERS = { "Node" => { "id" => GraphQL::Cardinal::HashKeyResolver.new("id"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, "__type__" => ->(obj, ctx) { ctx[:query].get_type(obj["__typename__"]) }, }, "HasMetafields" => { "metafield" => GraphQL::Cardinal::HashKeyResolver.new("metafield"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, "__type__" => ->(obj, ctx) { ctx[:query].get_type(obj["__typename__"]) }, }, "Metafield" => { "key" => GraphQL::Cardinal::HashKeyResolver.new("key"), "value" => GraphQL::Cardinal::HashKeyResolver.new("value"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "Product" => { "id" => GraphQL::Cardinal::HashKeyResolver.new("id"), @@ -79,34 +76,27 @@ def resolve(objects, _args, _ctx, _scope) "must" => GraphQL::Cardinal::HashKeyResolver.new("must"), "variants" => GraphQL::Cardinal::HashKeyResolver.new("variants"), "metafield" => GraphQL::Cardinal::HashKeyResolver.new("metafield"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "ProductConnection" => { "nodes" => GraphQL::Cardinal::HashKeyResolver.new("nodes"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "Variant" => { "id" => GraphQL::Cardinal::HashKeyResolver.new("id"), "title" => GraphQL::Cardinal::HashKeyResolver.new("title"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "VariantConnection" => { "nodes" => GraphQL::Cardinal::HashKeyResolver.new("nodes"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "WriteValuePayload" => { "value" => GraphQL::Cardinal::HashKeyResolver.new("value"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "Query" => { "products" => GraphQL::Cardinal::HashKeyResolver.new("products"), "nodes" => GraphQL::Cardinal::HashKeyResolver.new("nodes"), "node" => GraphQL::Cardinal::HashKeyResolver.new("node"), - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, "Mutation" => { "writeValue" => WriteValueResolver.new, - "__typename" => GraphQL::Cardinal::TypenameResolver.new, }, }.freeze diff --git a/test/graphql/cardinal/executor/scope_loader_test.rb b/test/graphql/cardinal/executor/scope_loader_test.rb new file mode 100644 index 0000000..1b38bad --- /dev/null +++ b/test/graphql/cardinal/executor/scope_loader_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::ScopeLoaderTest < Minitest::Test + + class FancyLoader < GraphQL::Cardinal::Loader + class << self + attr_accessor :perform_keys + end + + self.perform_keys = [] + + def perform(keys) + self.class.perform_keys << keys.dup + keys.map { |key| "#{key}-#{group}" } + end + end + + class FirstResolver < GraphQL::Cardinal::FieldResolver + def resolve(objects, _args, _ctx, scope) + scope.defer(FancyLoader, group: "a", keys: objects.map { _1["first"] }) + end + end + + class SecondResolver < GraphQL::Cardinal::FieldResolver + def resolve(objects, _args, _ctx, scope) + scope.defer(FancyLoader, group: "a", keys: objects.map { _1["second"] }) + end + end + + class ThirdResolver < GraphQL::Cardinal::FieldResolver + def resolve(objects, _args, _ctx, scope) + scope.defer(FancyLoader, group: "b", keys: objects.map { _1["third"] }).then do |values| + values + end + end + end + + LOADER_SCHEMA = GraphQL::Schema.from_definition(%| + type Widget { + first: String + second: String + third: String + } + + type Query { + widget: Widget + } + |) + + LOADER_RESOLVERS = { + "Widget" => { + "first" => FirstResolver.new, + "second" => SecondResolver.new, + "third" => ThirdResolver.new, + }, + "Query" => { + "widget" => GraphQL::Cardinal::HashKeyResolver.new("widget"), + }, + }.freeze + + def setup + FancyLoader.perform_keys = [] + end + + def test_runs + document = GraphQL.parse(%|{ + widget { + first + second + third + } + }|) + + source = { + "widget" => { + "first" => "Apple", + "second" => "Banana", + "third" => "Coconut", + }, + } + + expected = { + "data" => { + "widget" => { + "first" => "Apple-a", + "second" => "Banana-a", + "third" => "Coconut-b" + } + } + } + + executor = GraphQL::Cardinal::BreadthExecutor.new(LOADER_SCHEMA, LOADER_RESOLVERS, document, source) + assert_equal expected, executor.perform + assert_equal [["Apple", "Banana"], ["Coconut"]], FancyLoader.perform_keys + end +end