From 1fdc9cd52bec9496a9e9af41229d4cce23d487a9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 19 Jun 2025 16:43:08 -0400 Subject: [PATCH 01/20] Start on a run_queue --- lib/graphql/execution/interpreter/runtime.rb | 320 ++++++++++++------- 1 file changed, 211 insertions(+), 109 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index d055186ed6..906c03b460 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -54,9 +54,13 @@ def initialize(query:, lazies_at_depth:) end # { Class => Boolean } @lazy_cache = {}.compare_by_identity + @run_queue = [] end def final_result + while (step = @run_queue.shift) + step.run + end @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end @@ -64,6 +68,130 @@ def inspect "#<#{self.class.name} response=#{@response.inspect}>" end + class ObjectStep + def initialize(runtime, response, runtime_state) + @runtime = runtime + @response = response + @runtime_state = runtime_state + end + + def run + @runtime.each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys + if is_selection_array + this_result = GraphQLResultHash.new( + @response.graphql_response_name, + @response.graphql_result_type, + @response.graphql_application_value, + @response.graphql_parent, + @response.graphql_is_non_null_in_parent, + selections, + false, + @response.ast_node, + @response.graphql_arguments, + @response.graphql_field) + this_result.ordered_result_keys = ordered_result_keys + final_result = @response + else + this_result = @response + final_result = nil + end + @runtime.evaluate_selections( + selections, + this_result, + final_result, + nil, + ) + end + end + end + + class ListStep + def initialize(runtime, runtime_state, response_list, list_object, was_scoped) + @runtime = runtime + @runtime_state = runtime_state + @response_list = response_list + @list_object = list_object + @was_scoped = was_scoped + end + + def run + current_type = @response_list.graphql_result_type + inner_type = current_type.of_type + # This is true for objects, unions, and interfaces + # use_dataloader_job = !inner_type.unwrap.kind.input? + inner_type_non_null = inner_type.non_null? + idx = nil + list_value = begin + begin + @list_object.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + # TODO if use_dataloader_job ... ?? + # Better would be to extract a ListValueStep + @runtime.resolve_list_item( + inner_value, + inner_type, + inner_type_non_null, + @response_list.ast_node, + @response_list.graphql_field, + @response_list.graphql_application_value, + @response_list.graphql_arguments, + this_idx, + @response_list, + @was_scoped, + @runtime_state + ) + end + + @response_list + rescue NoMethodError => err + # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) + if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) + # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. + raise ListResultFailedError.new(value: @list_object, field: @response_list.graphql_field, path: @runtime.current_path) + else + # This was some other NoMethodError -- let it bubble to reveal the real error. + raise + end + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + ex_err + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) + error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + @runtime.continue_value(list_value, @response_list.graphql_field, error_is_non_null, @response_list.ast_node, @response_list.graphql_result_name, @response_list.graphql_parent) + end + end + + class DirectivesStep + def initialize(runtime, object, ast_node, next_step) + @runtime = runtime + @object = object + @ast_node = ast_node + @next_step = next_step + end + + def run + @runtime.call_method_on_directives(:resolve, @object, @ast_node.directives) do + next_step.call + end + end + end + # @return [void] def run_eager root_type = query.root_type @@ -95,27 +223,12 @@ def run_eager @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) @response.base_path = base_path runtime_state.current_result = @response - call_method_on_directives(:resolve, object, ast_node.directives) do - each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - @response.ordered_result_keys ||= ordered_result_keys - if is_selection_array - selection_response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) - selection_response.ordered_result_keys = ordered_result_keys - final_response = @response - else - selection_response = @response - final_response = nil - end - - @dataloader.append_job { - evaluate_selections( - selections, - selection_response, - final_response, - nil, - ) - } - end + obj_step = ObjectStep.new(self, @response, nil) + if !ast_node.directives.empty? + dir_step = DirectivesStep.new(self, object, ast_node, obj_step) + @run_queue << dir_step + else + @run_queue << obj_step end end when "LIST" @@ -128,31 +241,31 @@ def run_eager selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) selection_result.base_path = base_path selection_result.ordered_result_keys = [result_name] - runtime_state = get_current_runtime_state runtime_state.current_result = selection_result runtime_state.current_result_name = result_name continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) if HALT != continue_value - continue_field(continue_value, owner_type, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + continue_field(continue_value, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists end @response = selection_result[result_name] else @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) @response.base_path = base_path - idx = nil - object.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - @dataloader.append_job do - runtime_state.current_result_name = this_idx - runtime_state.current_result = @response - continue_field( - inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, - nil, this_idx, @response, false, runtime_state - ) - end - end + @run_queue << ListStep.new(self, @response, object, false, runtime_state) + # idx = nil + # object.each do |inner_value| + # idx ||= 0 + # this_idx = idx + # idx += 1 + # @dataloader.append_job do + # runtime_state.current_result_name = this_idx + # runtime_state.current_result = @response + # continue_field( + # inner_value, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, + # nil, this_idx, @response, false, runtime_state + # ) + # end + # end end when "SCALAR", "ENUM" result_name = ast_node.alias || ast_node.name @@ -166,7 +279,7 @@ def run_eager runtime_state.current_result_name = result_name continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) if HALT != continue_value - continue_field(continue_value, owner_type, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + continue_field(continue_value, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists end @response = selection_result[result_name] when "UNION", "INTERFACE" @@ -194,6 +307,9 @@ def run_eager else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end + while (run_step = @run_queue.shift) + run_step.run + end nil end @@ -489,7 +605,7 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu if HALT != continue_value was_scoped = runtime_state.was_authorized_by_scope_items runtime_state.was_authorized_by_scope_items = nil - continue_field(continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) + continue_field(continue_value, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) else nil end @@ -665,7 +781,7 @@ def continue_value(value, field, is_non_null, ast_node, result_name, selection_r # Location information from `path` and `ast_node`. # # @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later - def continue_field(value, owner_type, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + def continue_field(value, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists if current_type.non_null? current_type = current_type.of_type is_non_null = true @@ -711,7 +827,7 @@ def continue_field(value, owner_type, field, current_type, ast_node, next_select set_result(selection_result, result_name, nil, false, is_non_null) nil else - continue_field(resolved_value, owner_type, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) + continue_field(resolved_value, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) end end when "OBJECT" @@ -725,84 +841,70 @@ def continue_field(value, owner_type, field, current_type, ast_node, next_select if HALT != continue_value response_hash = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_hash, true, is_non_null) - each_gathered_selections(response_hash) do |selections, is_selection_array, ordered_result_keys| - response_hash.ordered_result_keys ||= ordered_result_keys - if is_selection_array - this_result = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, selections, false, ast_node, arguments, field) - this_result.ordered_result_keys = ordered_result_keys - final_result = response_hash - else - this_result = response_hash - final_result = nil - end - - evaluate_selections( - selections, - this_result, - final_result, - runtime_state, - ) - end + @run_queue << ObjectStep.new(self, response_hash, runtime_state) end end when "LIST" - inner_type = current_type.of_type - # This is true for objects, unions, and interfaces - use_dataloader_job = !inner_type.unwrap.kind.input? - inner_type_non_null = inner_type.non_null? response_list = GraphQLResultArray.new(result_name, current_type, owner_object, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_list, true, is_non_null) - idx = nil - list_value = begin - begin - value.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - if use_dataloader_job - @dataloader.append_job do - resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) - end - else - resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) - end - end + @run_queue << ListStep.new(self, runtime_state, response_list, value, was_scoped) - response_list - rescue NoMethodError => err - # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) - if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) - # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. - raise ListResultFailedError.new(value: value, field: field, path: current_path) - else - # This was some other NoMethodError -- let it bubble to reveal the real error. - raise - end - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - ex_err - rescue StandardError => err - begin - query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end - rescue StandardError => err - begin - query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end - # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) - error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? - continue_value(list_value, field, error_is_non_null, ast_node, result_name, selection_result) + # inner_type = current_type.of_type + # # This is true for objects, unions, and interfaces + # use_dataloader_job = !inner_type.unwrap.kind.input? + # inner_type_non_null = inner_type.non_null? + # set_result(selection_result, result_name, response_list, true, is_non_null) + # idx = nil + # list_value = begin + # begin + # value.each do |inner_value| + # idx ||= 0 + # this_idx = idx + # idx += 1 + # if use_dataloader_job + # @dataloader.append_job do + # resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) + # end + # else + # resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) + # end + # end + + # response_list + # rescue NoMethodError => err + # # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) + # if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) + # # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. + # raise ListResultFailedError.new(value: value, field: field, path: current_path) + # else + # # This was some other NoMethodError -- let it bubble to reveal the real error. + # raise + # end + # rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + # ex_err + # rescue StandardError => err + # begin + # query.handle_or_reraise(err) + # rescue GraphQL::ExecutionError => ex_err + # ex_err + # end + # end + # rescue StandardError => err + # begin + # query.handle_or_reraise(err) + # rescue GraphQL::ExecutionError => ex_err + # ex_err + # end + # end + # # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) + # error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + # continue_value(list_value, field, error_is_non_null, ast_node, result_name, selection_result) else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end end - def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists runtime_state.current_result_name = this_idx runtime_state.current_result = response_list call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do @@ -810,7 +912,7 @@ def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, fi after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| continue_value = continue_value(inner_inner_value, field, inner_type_non_null, ast_node, this_idx, response_list) if HALT != continue_value - continue_field(continue_value, owner_type, field, inner_type, ast_node, response_list.graphql_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) + continue_field(continue_value, field, inner_type, ast_node, response_list.graphql_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) end end end From 9d5b6da16000a184b7cd6797aec3d1490d503123 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 20 Jun 2025 15:05:22 -0400 Subject: [PATCH 02/20] Add resolve_type step --- lib/graphql/execution/interpreter/runtime.rb | 242 +++++++++---------- 1 file changed, 119 insertions(+), 123 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 906c03b460..f88ef4f5a9 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true require "graphql/execution/interpreter/runtime/graphql_result" +##### +# Next thoughts +# +# - `continue_field` is probably a step of its own -- that method can somehow be factored out +# - UNION/INTERFACE should initialize the ResultHash that the resolved object type will eventually use. +# That would simplify the method call a lot. And then it could add a new step itself. +# - It seems like Dataloader/Lazy will fit in at the queue level, so the flow would be: +# - Run jobs from queue +# - Then, run dataloader/lazies +# - Repeat module GraphQL module Execution class Interpreter @@ -192,6 +202,51 @@ def run end end + class ResolveTypeStep + def initialize(runtime, response_hash, was_scoped) + @runtime = runtime + @response_hash = response_hash + @was_scoped = was_scoped + end + + def run + current_type = @response_hash.graphql_result_type + value = @response_hash.graphql_application_value + resolved_type_result = begin + @runtime.resolve_type(current_type, value) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) + end + end + + # after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| + if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 + resolved_type, resolved_value = resolved_type_result + else + resolved_type = resolved_type_result + resolved_value = value + end + + possible_types = @runtime.query.types.possible_types(current_type) + if !possible_types.include?(resolved_type) + parent_type = @field.owner_type + err_class = current_type::UnresolvedTypeError + type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) + @runtime.schema.type_error(type_error, context) + @runtime.set_result(selection_result, result_name, nil, false, is_non_null) + nil + else + # TODO create the response_hash ahead of time which contains all this metadata + @runtime.continue_field(resolved_value, @response_hash.graphql_field, resolved_type, @response_hash.ast_node, @response_hash.graphql_selections, @response_hash.graphql_is_non_null_in_parent, @response_hash.graphql_arguments, @response_hash.graphql_result_name, @response_hash.graphql_parent, @was_scoped, @runtime_state) + end + end + end + # @return [void] def run_eager root_type = query.root_type @@ -245,27 +300,13 @@ def run_eager runtime_state.current_result_name = result_name continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) if HALT != continue_value - continue_field(continue_value, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + continue_field(continue_value, field_defn, root_type, ast_node, nil, false, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists end @response = selection_result[result_name] else @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) @response.base_path = base_path - @run_queue << ListStep.new(self, @response, object, false, runtime_state) - # idx = nil - # object.each do |inner_value| - # idx ||= 0 - # this_idx = idx - # idx += 1 - # @dataloader.append_job do - # runtime_state.current_result_name = this_idx - # runtime_state.current_result = @response - # continue_field( - # inner_value, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, - # nil, this_idx, @response, false, runtime_state - # ) - # end - # end + @run_queue << ListStep.new(self, runtime_state, @response, object, false) end when "SCALAR", "ENUM" result_name = ast_node.alias || ast_node.name @@ -279,31 +320,36 @@ def run_eager runtime_state.current_result_name = result_name continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) if HALT != continue_value - continue_field(continue_value, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + continue_field(continue_value, field_defn, query.root_type, ast_node, nil, false, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists end @response = selection_result[result_name] when "UNION", "INTERFACE" - resolved_type, _resolved_obj = resolve_type(root_type, object) - resolved_type = schema.sync_lazy(resolved_type) - object_proxy = resolved_type.wrap(object, context) - object_proxy = schema.sync_lazy(object_proxy) - @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil) + + @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false, query.ast_nodes.first, nil, nil) @response.base_path = base_path - each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - @response.ordered_result_keys ||= ordered_result_keys - if is_selection_array == true - raise "This isn't supported yet" - end - @dataloader.append_job { - evaluate_selections( - selections, - @response, - nil, - runtime_state, - ) - } - end + @run_queue << ResolveTypeStep.new(self, @response, false) + + # resolved_type, _resolved_obj = resolve_type(root_type, object) + # resolved_type = schema.sync_lazy(resolved_type) + # object_proxy = resolved_type.wrap(object, context) + # object_proxy = schema.sync_lazy(object_proxy) + + # each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + # @response.ordered_result_keys ||= ordered_result_keys + # if is_selection_array == true + # raise "This isn't supported yet" + # end + + # @dataloader.append_job { + # evaluate_selections( + # selections, + # @response, + # nil, + # runtime_state, + # ) + # } + # end else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end @@ -599,13 +645,12 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu end @current_trace.end_execute_field(field_defn, object, kwarg_arguments, query, app_result) after_lazy(app_result, field: field_defn, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_result, runtime_state| - owner_type = selection_result.graphql_result_type return_type = field_defn.type continue_value = continue_value(inner_result, field_defn, return_type.non_null?, ast_node, result_name, selection_result) if HALT != continue_value was_scoped = runtime_state.was_authorized_by_scope_items runtime_state.was_authorized_by_scope_items = nil - continue_field(continue_value, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) + continue_field(continue_value, field_defn, return_type, ast_node, next_selections, false, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) else nil end @@ -781,7 +826,7 @@ def continue_value(value, field, is_non_null, ast_node, result_name, selection_r # Location information from `path` and `ast_node`. # # @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later - def continue_field(value, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + def continue_field(value, field, current_type, ast_node, next_selections, is_non_null, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists if current_type.non_null? current_type = current_type.of_type is_non_null = true @@ -799,44 +844,46 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non set_result(selection_result, result_name, r, false, is_non_null) r when "UNION", "INTERFACE" - resolved_type_or_lazy = begin - resolve_type(current_type, value) - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) - rescue StandardError => err - begin - query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) - end - end - after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| - if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 - resolved_type, resolved_value = resolved_type_result - else - resolved_type = resolved_type_result - resolved_value = value - end + response_hash = GraphQLResultHash.new(result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + @run_queue << ResolveTypeStep.new(self, response_hash, was_scoped) + # resolved_type_or_lazy = begin + # resolve_type(current_type, value) + # rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + # return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + # rescue StandardError => err + # begin + # query.handle_or_reraise(err) + # rescue GraphQL::ExecutionError => ex_err + # return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + # end + # end + # after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| + # if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 + # resolved_type, resolved_value = resolved_type_result + # else + # resolved_type = resolved_type_result + # resolved_value = value + # end - possible_types = query.types.possible_types(current_type) - if !possible_types.include?(resolved_type) - parent_type = field.owner_type - err_class = current_type::UnresolvedTypeError - type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - schema.type_error(type_error, context) - set_result(selection_result, result_name, nil, false, is_non_null) - nil - else - continue_field(resolved_value, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) - end - end + # possible_types = query.types.possible_types(current_type) + # if !possible_types.include?(resolved_type) + # parent_type = field.owner_type + # err_class = current_type::UnresolvedTypeError + # type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) + # schema.type_error(type_error, context) + # set_result(selection_result, result_name, nil, false, is_non_null) + # nil + # else + # continue_field(resolved_value, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) + # end + # end when "OBJECT" object_proxy = begin was_scoped ? current_type.wrap_scoped(value, context) : current_type.wrap(value, context) rescue GraphQL::ExecutionError => err err end - after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| + after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: selection_result.graphql_application_value, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| continue_value = continue_value(inner_object, field, is_non_null, ast_node, result_name, selection_result) if HALT != continue_value response_hash = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) @@ -845,60 +892,9 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end end when "LIST" - response_list = GraphQLResultArray.new(result_name, current_type, owner_object, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + response_list = GraphQLResultArray.new(result_name, current_type, selection_result.graphql_application_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_list, true, is_non_null) @run_queue << ListStep.new(self, runtime_state, response_list, value, was_scoped) - - # inner_type = current_type.of_type - # # This is true for objects, unions, and interfaces - # use_dataloader_job = !inner_type.unwrap.kind.input? - # inner_type_non_null = inner_type.non_null? - # set_result(selection_result, result_name, response_list, true, is_non_null) - # idx = nil - # list_value = begin - # begin - # value.each do |inner_value| - # idx ||= 0 - # this_idx = idx - # idx += 1 - # if use_dataloader_job - # @dataloader.append_job do - # resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) - # end - # else - # resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) - # end - # end - - # response_list - # rescue NoMethodError => err - # # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) - # if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) - # # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. - # raise ListResultFailedError.new(value: value, field: field, path: current_path) - # else - # # This was some other NoMethodError -- let it bubble to reveal the real error. - # raise - # end - # rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - # ex_err - # rescue StandardError => err - # begin - # query.handle_or_reraise(err) - # rescue GraphQL::ExecutionError => ex_err - # ex_err - # end - # end - # rescue StandardError => err - # begin - # query.handle_or_reraise(err) - # rescue GraphQL::ExecutionError => ex_err - # ex_err - # end - # end - # # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) - # error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? - # continue_value(list_value, field, error_is_non_null, ast_node, result_name, selection_result) else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end @@ -912,7 +908,7 @@ def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, fi after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| continue_value = continue_value(inner_inner_value, field, inner_type_non_null, ast_node, this_idx, response_list) if HALT != continue_value - continue_field(continue_value, field, inner_type, ast_node, response_list.graphql_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) + continue_field(continue_value, field, inner_type, ast_node, response_list.graphql_selections, false, arguments, this_idx, response_list, was_scoped, runtime_state) end end end From fdbfd2fe31e77e21b87ab6f712fc279a0ff5cf63 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 20 Jun 2025 15:47:50 -0400 Subject: [PATCH 03/20] Move #run into Result classes --- lib/graphql/execution/interpreter/runtime.rb | 242 ++---------------- .../interpreter/runtime/graphql_result.rb | 138 +++++++++- 2 files changed, 155 insertions(+), 225 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index f88ef4f5a9..35b68eac62 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -5,8 +5,6 @@ # Next thoughts # # - `continue_field` is probably a step of its own -- that method can somehow be factored out -# - UNION/INTERFACE should initialize the ResultHash that the resolved object type will eventually use. -# That would simplify the method call a lot. And then it could add a new step itself. # - It seems like Dataloader/Lazy will fit in at the queue level, so the flow would be: # - Run jobs from queue # - Then, run dataloader/lazies @@ -45,6 +43,8 @@ def current_object # @return [GraphQL::Query::Context] attr_reader :context + attr_reader :dataloader + def initialize(query:, lazies_at_depth:) @query = query @current_trace = query.current_trace @@ -78,115 +78,6 @@ def inspect "#<#{self.class.name} response=#{@response.inspect}>" end - class ObjectStep - def initialize(runtime, response, runtime_state) - @runtime = runtime - @response = response - @runtime_state = runtime_state - end - - def run - @runtime.each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - @response.ordered_result_keys ||= ordered_result_keys - if is_selection_array - this_result = GraphQLResultHash.new( - @response.graphql_response_name, - @response.graphql_result_type, - @response.graphql_application_value, - @response.graphql_parent, - @response.graphql_is_non_null_in_parent, - selections, - false, - @response.ast_node, - @response.graphql_arguments, - @response.graphql_field) - this_result.ordered_result_keys = ordered_result_keys - final_result = @response - else - this_result = @response - final_result = nil - end - @runtime.evaluate_selections( - selections, - this_result, - final_result, - nil, - ) - end - end - end - - class ListStep - def initialize(runtime, runtime_state, response_list, list_object, was_scoped) - @runtime = runtime - @runtime_state = runtime_state - @response_list = response_list - @list_object = list_object - @was_scoped = was_scoped - end - - def run - current_type = @response_list.graphql_result_type - inner_type = current_type.of_type - # This is true for objects, unions, and interfaces - # use_dataloader_job = !inner_type.unwrap.kind.input? - inner_type_non_null = inner_type.non_null? - idx = nil - list_value = begin - begin - @list_object.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - # TODO if use_dataloader_job ... ?? - # Better would be to extract a ListValueStep - @runtime.resolve_list_item( - inner_value, - inner_type, - inner_type_non_null, - @response_list.ast_node, - @response_list.graphql_field, - @response_list.graphql_application_value, - @response_list.graphql_arguments, - this_idx, - @response_list, - @was_scoped, - @runtime_state - ) - end - - @response_list - rescue NoMethodError => err - # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) - if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) - # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. - raise ListResultFailedError.new(value: @list_object, field: @response_list.graphql_field, path: @runtime.current_path) - else - # This was some other NoMethodError -- let it bubble to reveal the real error. - raise - end - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - ex_err - rescue StandardError => err - begin - @runtime.query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end - rescue StandardError => err - begin - @runtime.query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end - # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) - error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? - @runtime.continue_value(list_value, @response_list.graphql_field, error_is_non_null, @response_list.ast_node, @response_list.graphql_result_name, @response_list.graphql_parent) - end - end - class DirectivesStep def initialize(runtime, object, ast_node, next_step) @runtime = runtime @@ -197,7 +88,7 @@ def initialize(runtime, object, ast_node, next_step) def run @runtime.call_method_on_directives(:resolve, @object, @ast_node.directives) do - next_step.call + @runtime.run_queue << @next_step end end end @@ -234,14 +125,14 @@ def run possible_types = @runtime.query.types.possible_types(current_type) if !possible_types.include?(resolved_type) - parent_type = @field.owner_type + field = @response_hash.graphql_field + parent_type = field.owner_type err_class = current_type::UnresolvedTypeError type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - @runtime.schema.type_error(type_error, context) + @runtime.schema.type_error(type_error, @runtime.context) @runtime.set_result(selection_result, result_name, nil, false, is_non_null) nil else - # TODO create the response_hash ahead of time which contains all this metadata @runtime.continue_field(resolved_value, @response_hash.graphql_field, resolved_type, @response_hash.ast_node, @response_hash.graphql_selections, @response_hash.graphql_is_non_null_in_parent, @response_hash.graphql_arguments, @response_hash.graphql_result_name, @response_hash.graphql_parent, @was_scoped, @runtime_state) end end @@ -275,15 +166,14 @@ def run_eager if object_proxy.nil? @response = nil else - @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + @response = GraphQLResultHash.new(self, nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) @response.base_path = base_path runtime_state.current_result = @response - obj_step = ObjectStep.new(self, @response, nil) if !ast_node.directives.empty? dir_step = DirectivesStep.new(self, object, ast_node, obj_step) @run_queue << dir_step else - @run_queue << obj_step + @run_queue << @response end end when "LIST" @@ -304,9 +194,9 @@ def run_eager end @response = selection_result[result_name] else - @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) + @response = GraphQLResultArray.new(self, nil, root_type, object, nil, false, selections, false, ast_node, nil, nil) @response.base_path = base_path - @run_queue << ListStep.new(self, runtime_state, @response, object, false) + @run_queue << @response end when "SCALAR", "ENUM" result_name = ast_node.alias || ast_node.name @@ -329,27 +219,6 @@ def run_eager @response.base_path = base_path @run_queue << ResolveTypeStep.new(self, @response, false) - - # resolved_type, _resolved_obj = resolve_type(root_type, object) - # resolved_type = schema.sync_lazy(resolved_type) - # object_proxy = resolved_type.wrap(object, context) - # object_proxy = schema.sync_lazy(object_proxy) - - # each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - # @response.ordered_result_keys ||= ordered_result_keys - # if is_selection_array == true - # raise "This isn't supported yet" - # end - - # @dataloader.append_job { - # evaluate_selections( - # selections, - # @response, - # nil, - # runtime_state, - # ) - # } - # end else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end @@ -451,51 +320,7 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s # @return [void] def evaluate_selections(gathered_selections, selections_result, target_result, runtime_state) # rubocop:disable Metrics/ParameterLists - runtime_state ||= get_current_runtime_state - runtime_state.current_result_name = nil - runtime_state.current_result = selections_result - # This is a less-frequent case; use a fast check since it's often not there. - if (directives = gathered_selections[:graphql_directives]) - gathered_selections.delete(:graphql_directives) - end - call_method_on_directives(:resolve, selections_result.graphql_application_value, directives) do - finished_jobs = 0 - enqueued_jobs = gathered_selections.size - gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| - # Field resolution may pause the fiber, - # so it wouldn't get to the `Resolve` call that happens below. - # So instead trigger a run from this outer context. - if selections_result.graphql_is_eager - @dataloader.clear_cache - @dataloader.run_isolated { - evaluate_selection( - result_name, field_ast_nodes_or_ast_node, selections_result - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if target_result - selections_result.merge_into(target_result) - end - end - @dataloader.clear_cache - } - else - @dataloader.append_job { - evaluate_selection( - result_name, field_ast_nodes_or_ast_node, selections_result - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if target_result - selections_result.merge_into(target_result) - end - end - } - end - end - selections_result - end end # @return [void] @@ -586,7 +411,11 @@ def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_node extra_args[:argument_details] = :__arguments_add_self when :parent parent_result = selection_result.graphql_parent - extra_args[:parent] = parent_result&.graphql_application_value&.object + if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) + parent_result = parent_result.graphql_parent + end + parent_value = parent_result&.graphql_application_value&.object + extra_args[:parent] = parent_value else extra_args[extra] = field_defn.fetch_extra(extra, context) end @@ -844,39 +673,8 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non set_result(selection_result, result_name, r, false, is_non_null) r when "UNION", "INTERFACE" - response_hash = GraphQLResultHash.new(result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + response_hash = GraphQLResultHash.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) @run_queue << ResolveTypeStep.new(self, response_hash, was_scoped) - # resolved_type_or_lazy = begin - # resolve_type(current_type, value) - # rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - # return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) - # rescue StandardError => err - # begin - # query.handle_or_reraise(err) - # rescue GraphQL::ExecutionError => ex_err - # return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) - # end - # end - # after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| - # if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 - # resolved_type, resolved_value = resolved_type_result - # else - # resolved_type = resolved_type_result - # resolved_value = value - # end - - # possible_types = query.types.possible_types(current_type) - # if !possible_types.include?(resolved_type) - # parent_type = field.owner_type - # err_class = current_type::UnresolvedTypeError - # type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - # schema.type_error(type_error, context) - # set_result(selection_result, result_name, nil, false, is_non_null) - # nil - # else - # continue_field(resolved_value, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) - # end - # end when "OBJECT" object_proxy = begin was_scoped ? current_type.wrap_scoped(value, context) : current_type.wrap(value, context) @@ -886,15 +684,15 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: selection_result.graphql_application_value, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| continue_value = continue_value(inner_object, field, is_non_null, ast_node, result_name, selection_result) if HALT != continue_value - response_hash = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + response_hash = GraphQLResultHash.new(self, result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_hash, true, is_non_null) - @run_queue << ObjectStep.new(self, response_hash, runtime_state) + @run_queue << response_hash end end when "LIST" - response_list = GraphQLResultArray.new(result_name, current_type, selection_result.graphql_application_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + response_list = GraphQLResultArray.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_list, true, is_non_null) - @run_queue << ListStep.new(self, runtime_state, response_list, value, was_scoped) + @run_queue << response_list else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index fdba7911bb..d709711734 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -5,7 +5,8 @@ module Execution class Interpreter class Runtime module GraphQLResult - def initialize(result_name, result_type, application_value, parent_result, is_non_null_in_parent, selections, is_eager, ast_node, graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + def initialize(runtime_instance, result_name, result_type, application_value, parent_result, is_non_null_in_parent, selections, is_eager, ast_node, graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + @runtime = runtime_instance @ast_node = ast_node @graphql_arguments = graphql_arguments @graphql_field = graphql_field @@ -51,12 +52,81 @@ def build_path(path_array) end class GraphQLResultHash - def initialize(_result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + def initialize(_runtime_inst, _result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists super @graphql_result_data = {} @ordered_result_keys = nil end + def run + @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| + @ordered_result_keys ||= ordered_result_keys + if is_selection_array + selections_result = GraphQLResultHash.new( + @graphql_response_name, + @graphql_result_type, + @graphql_application_value, + @graphql_parent, + @graphql_is_non_null_in_parent, + gathered_selections, + false, + @ast_node, + @graphql_arguments, + @graphql_field) + selections_result.ordered_result_keys = ordered_result_keys + target_result = self + else + selections_result = self + target_result = nil + end + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_result_name = nil + runtime_state.current_result = selections_result + # This is a less-frequent case; use a fast check since it's often not there. + if (directives = gathered_selections[:graphql_directives]) + gathered_selections.delete(:graphql_directives) + end + + @runtime.call_method_on_directives(:resolve, selections_result.graphql_application_value, directives) do + finished_jobs = 0 + enqueued_jobs = gathered_selections.size + gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| + # Field resolution may pause the fiber, + # so it wouldn't get to the `Resolve` call that happens below. + # So instead trigger a run from this outer context. + if selections_result.graphql_is_eager + @runtime.dataloader.clear_cache + @runtime.dataloader.run_isolated { + @runtime.evaluate_selection( + result_name, field_ast_nodes_or_ast_node, selections_result + ) + finished_jobs += 1 + if finished_jobs == enqueued_jobs + if target_result + selections_result.merge_into(target_result) + end + end + @runtime.dataloader.clear_cache + } + else + @runtime.dataloader.append_job { + @runtime.evaluate_selection( + result_name, field_ast_nodes_or_ast_node, selections_result + ) + finished_jobs += 1 + if finished_jobs == enqueued_jobs + if target_result + selections_result.merge_into(target_result) + end + end + } + end + end + end + end + end + + attr_accessor :ordered_result_keys include GraphQLResult @@ -162,11 +232,73 @@ def fix_result_order class GraphQLResultArray include GraphQLResult - def initialize(_result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + def initialize(_runtime_inst, _result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists super @graphql_result_data = [] end + def run + current_type = @graphql_result_type + inner_type = current_type.of_type + # This is true for objects, unions, and interfaces + # use_dataloader_job = !inner_type.unwrap.kind.input? + inner_type_non_null = inner_type.non_null? + idx = nil + rts = @runtime.get_current_runtime_state + list_value = begin + begin + @graphql_application_value.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + # TODO if use_dataloader_job ... ?? + # Better would be to extract a ListValueStep? + @runtime.resolve_list_item( + inner_value, + inner_type, + inner_type_non_null, + @ast_node, + @graphql_field, + @graphql_application_value, + @graphql_arguments, + this_idx, + self, + @was_scoped, # TODO + rts, + ) + end + + self + rescue NoMethodError => err + # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) + if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == @graphql_application_value : true) + # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. + raise ListResultFailedError.new(value: @graphql_application_value, field: @graphql_field, path: @runtime.current_path) + else + # This was some other NoMethodError -- let it bubble to reveal the real error. + raise + end + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + ex_err + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) + error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + @runtime.continue_value(list_value, @graphql_field, error_is_non_null, @ast_node, @graphql_result_name, @graphql_parent) + end + def graphql_skip_at(index) # Mark this index as dead. It's tricky because some indices may already be storing # `Lazy`s. So the runtime is still holding indexes _before_ skipping, From 8f04cc9370bcc4a3ad460af3400c60a483076077 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 20 Jun 2025 16:02:41 -0400 Subject: [PATCH 04/20] Make a RunQueue object --- lib/graphql/execution/interpreter/runtime.rb | 32 +++++++++++++++---- .../interpreter/runtime/graphql_result.rb | 2 +- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 35b68eac62..7578fab290 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -34,6 +34,28 @@ def current_object :current_arguments, :current_field, :was_authorized_by_scope_items end + class RunQueue + def initialize(dataloader:, lazies_at_depth:) + @next_flush = [] + @dataloader = dataloader + @lazies_at_depth = lazies_at_depth + end + + def <<(step) + @next_flush << step + end + + def complete + while (fl = @next_flush) && !fl.empty? + @next_flush = [] + while (step = fl.shift) + step.run + end + Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + end + end + end + # @return [GraphQL::Query] attr_reader :query @@ -64,13 +86,11 @@ def initialize(query:, lazies_at_depth:) end # { Class => Boolean } @lazy_cache = {}.compare_by_identity - @run_queue = [] + @run_queue = RunQueue.new(dataloader: @dataloader, lazies_at_depth: @lazies_at_depth) end def final_result - while (step = @run_queue.shift) - step.run - end + @run_queue.complete @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end @@ -222,9 +242,7 @@ def run_eager else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end - while (run_step = @run_queue.shift) - run_step.run - end + @run_queue.complete nil end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index d709711734..5b53ef08f0 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -295,7 +295,7 @@ def run end end # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) - error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + error_is_non_null = idx.nil? ? @graphql_is_non_null_in_parent : inner_type.non_null? @runtime.continue_value(list_value, @graphql_field, error_is_non_null, @ast_node, @graphql_result_name, @graphql_parent) end From c45f628feeb839adf33db3f1fccb24d462df13ef Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 11:58:14 -0400 Subject: [PATCH 05/20] support sequential mutation fields --- lib/graphql/execution/interpreter/runtime.rb | 15 ++++++++++++--- .../interpreter/runtime/graphql_result.rb | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 7578fab290..294ace31d0 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -39,13 +39,20 @@ def initialize(dataloader:, lazies_at_depth:) @next_flush = [] @dataloader = dataloader @lazies_at_depth = lazies_at_depth + @running_eagerly = false end def <<(step) - @next_flush << step + if @running_eagerly + step.run + else + @next_flush << step + end end - def complete + def complete(eager: false) + prev_eagerly = @running_eagerly + @running_eagerly = eager while (fl = @next_flush) && !fl.empty? @next_flush = [] while (step = fl.shift) @@ -53,6 +60,8 @@ def complete end Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) end + ensure + @running_eagerly = prev_eagerly end end @@ -65,7 +74,7 @@ def complete # @return [GraphQL::Query::Context] attr_reader :context - attr_reader :dataloader + attr_reader :dataloader, :run_queue def initialize(query:, lazies_at_depth:) @query = query diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 5b53ef08f0..83f403e404 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -63,13 +63,13 @@ def run @ordered_result_keys ||= ordered_result_keys if is_selection_array selections_result = GraphQLResultHash.new( - @graphql_response_name, + @graphql_result_name, @graphql_result_type, @graphql_application_value, @graphql_parent, @graphql_is_non_null_in_parent, gathered_selections, - false, + @graphql_is_eager, @ast_node, @graphql_arguments, @graphql_field) @@ -106,6 +106,7 @@ def run selections_result.merge_into(target_result) end end + @runtime.run_queue.complete(eager: true) @runtime.dataloader.clear_cache } else From 32ea55c62c293b7913e9f7af1ae695abe20bd42c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 12:10:01 -0400 Subject: [PATCH 06/20] Fix dataloader integration --- lib/graphql/execution/interpreter/runtime.rb | 24 +++++++++++++++---- .../interpreter/runtime/graphql_result.rb | 12 ++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 294ace31d0..671ffbfa69 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -43,22 +43,28 @@ def initialize(dataloader:, lazies_at_depth:) end def <<(step) + # p [:push_step, step.inspect_step] if @running_eagerly - step.run + step.run_step else @next_flush << step end end def complete(eager: false) + # p [self.class, __method__, eager, caller(1,1).first, @next_flush.size] prev_eagerly = @running_eagerly @running_eagerly = eager while (fl = @next_flush) && !fl.empty? @next_flush = [] while (step = fl.shift) - step.run + # p [:shift_step, step.inspect_step] + step.run_step end - Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + @dataloader.append_job { + Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + } + @dataloader.run end ensure @running_eagerly = prev_eagerly @@ -115,11 +121,15 @@ def initialize(runtime, object, ast_node, next_step) @next_step = next_step end - def run + def run_step @runtime.call_method_on_directives(:resolve, @object, @ast_node.directives) do @runtime.run_queue << @next_step end end + + def inspect_step + "#{self.class}(#{ast_node.directives.map(&:name).join(", ")}) => #{@next_step.inspect_step}" + end end class ResolveTypeStep @@ -129,7 +139,11 @@ def initialize(runtime, response_hash, was_scoped) @was_scoped = was_scoped end - def run + def inspect_step + "#{self.class}(#{@response_hash.graphql_result_type}, #{@response_hash.graphql_application_value})" + end + + def run_step current_type = @response_hash.graphql_result_type value = @response_hash.graphql_application_value resolved_type_result = begin diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 83f403e404..da0dd97525 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -58,7 +58,11 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @ordered_result_keys = nil end - def run + def inspect_step + "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_selections.size})" + end + + def run_step @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| @ordered_result_keys ||= ordered_result_keys if is_selection_array @@ -238,7 +242,11 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @graphql_result_data = [] end - def run + def inspect_step + "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_application_value.size})" + end + + def run_step current_type = @graphql_result_type inner_type = current_type.of_type # This is true for objects, unions, and interfaces From c188312687ecf51ba5a8909b67edb16e9a1e5fb1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 13:15:31 -0400 Subject: [PATCH 07/20] Implement resolve_each --- lib/graphql/execution/interpreter/runtime.rb | 46 +++++- .../interpreter/runtime/graphql_result.rb | 154 +++++++++--------- spec/graphql/schema/directive_spec.rb | 5 +- 3 files changed, 124 insertions(+), 81 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 671ffbfa69..989beac95a 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -114,16 +114,18 @@ def inspect end class DirectivesStep - def initialize(runtime, object, ast_node, next_step) + def initialize(runtime, object, method_to_call, directives, next_step) @runtime = runtime @object = object - @ast_node = ast_node + @method_to_call = method_to_call + @directives = directives @next_step = next_step end def run_step - @runtime.call_method_on_directives(:resolve, @object, @ast_node.directives) do + @runtime.call_method_on_directives(@method_to_call, @object, @directives) do @runtime.run_queue << @next_step + @next_step end end @@ -213,7 +215,7 @@ def run_eager @response.base_path = base_path runtime_state.current_result = @response if !ast_node.directives.empty? - dir_step = DirectivesStep.new(self, object, ast_node, obj_step) + dir_step = DirectivesStep.new(self, object, :resolve, ast_node.directives, @response) @run_queue << dir_step else @run_queue << @response @@ -734,6 +736,7 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non response_list = GraphQLResultArray.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_list, true, is_non_null) @run_queue << response_list + response_list # TODO smell this is used because its returned by `yield` inside a directive else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end @@ -753,6 +756,41 @@ def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, fi end end + class ListItemDirectivesStep < DirectivesStep + def run_step + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_result_name = @next_step.index + runtime_state.current_result = @next_step.list_result + super + end + end + + class ListItemStep + def initialize(runtime, list_result, index, value) + @runtime = runtime + @list_result = list_result + @index = index + @value = value + end + + attr_reader :index, :list_result + + def inspect_step + "#{self.class}@#{@index}" + end + + def run_step + item_type = @list_result.graphql_result_type.of_type + item_type_non_null = item_type.non_null? + # This will update `response_list` with the lazy + continue_value = @runtime.continue_value(@value, @list_result.graphql_field, item_type_non_null, @list_result.ast_node, @index, @list_result) + if HALT != continue_value + was_scoped = false # TODO!! + @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, nil) + end + end + end + def call_method_on_directives(method_name, object, directives, &block) return yield if directives.nil? || directives.empty? run_directive(method_name, object, directives, 0, &block) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index da0dd97525..02ecd87275 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -56,6 +56,7 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p super @graphql_result_data = {} @ordered_result_keys = nil + @target_result = nil end def inspect_step @@ -63,76 +64,79 @@ def inspect_step end def run_step - @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| - @ordered_result_keys ||= ordered_result_keys - if is_selection_array - selections_result = GraphQLResultHash.new( - @graphql_result_name, - @graphql_result_type, - @graphql_application_value, - @graphql_parent, - @graphql_is_non_null_in_parent, - gathered_selections, - @graphql_is_eager, - @ast_node, - @graphql_arguments, - @graphql_field) - selections_result.ordered_result_keys = ordered_result_keys - target_result = self - else - selections_result = self - target_result = nil - end - runtime_state = @runtime.get_current_runtime_state - runtime_state.current_result_name = nil - runtime_state.current_result = selections_result - # This is a less-frequent case; use a fast check since it's often not there. - if (directives = gathered_selections[:graphql_directives]) - gathered_selections.delete(:graphql_directives) - end - - @runtime.call_method_on_directives(:resolve, selections_result.graphql_application_value, directives) do - finished_jobs = 0 - enqueued_jobs = gathered_selections.size - gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| - # Field resolution may pause the fiber, - # so it wouldn't get to the `Resolve` call that happens below. - # So instead trigger a run from this outer context. - if selections_result.graphql_is_eager - @runtime.dataloader.clear_cache - @runtime.dataloader.run_isolated { - @runtime.evaluate_selection( - result_name, field_ast_nodes_or_ast_node, selections_result - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if target_result - selections_result.merge_into(target_result) - end + if @ordered_result_keys + finished_jobs = 0 + enqueued_jobs = @graphql_selections.size # TODO needless? + @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| + # Field resolution may pause the fiber, + # so it wouldn't get to the `Resolve` call that happens below. + # So instead trigger a run from this outer context. + if @graphql_is_eager + @runtime.dataloader.clear_cache + @runtime.dataloader.run_isolated { + @runtime.evaluate_selection( + result_name, field_ast_nodes_or_ast_node, self + ) + finished_jobs += 1 + if finished_jobs == enqueued_jobs + if @target_result + self.merge_into(@target_result) end - @runtime.run_queue.complete(eager: true) - @runtime.dataloader.clear_cache - } - else - @runtime.dataloader.append_job { - @runtime.evaluate_selection( - result_name, field_ast_nodes_or_ast_node, selections_result - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if target_result - selections_result.merge_into(target_result) - end + end + @runtime.run_queue.complete(eager: true) + @runtime.dataloader.clear_cache + } + else + @runtime.dataloader.append_job { + @runtime.evaluate_selection( + result_name, field_ast_nodes_or_ast_node, self + ) + finished_jobs += 1 + if finished_jobs == enqueued_jobs + if @target_result + self.merge_into(@target_result) end - } - end + end + } + end + end + else + @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| + @ordered_result_keys ||= ordered_result_keys + if is_selection_array + selections_result = GraphQLResultHash.new( + @runtime, + @graphql_result_name, + @graphql_result_type, + @graphql_application_value, + @graphql_parent, + @graphql_is_non_null_in_parent, + gathered_selections, + @graphql_is_eager, + @ast_node, + @graphql_arguments, + @graphql_field) + selections_result.target_result = self + selections_result.ordered_result_keys = ordered_result_keys + else + selections_result = self + @target_result = nil + @graphql_selections = gathered_selections + end + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_result_name = nil + runtime_state.current_result = selections_result + # This is a less-frequent case; use a fast check since it's often not there. + if (directives = gathered_selections[:graphql_directives]) + gathered_selections.delete(:graphql_directives) end + + @runtime.run_queue << DirectivesStep.new(@runtime, selections_result.graphql_application_value, :resolve, directives, selections_result) end end end - - attr_accessor :ordered_result_keys + attr_accessor :ordered_result_keys, :target_result include GraphQLResult @@ -251,9 +255,9 @@ def run_step inner_type = current_type.of_type # This is true for objects, unions, and interfaces # use_dataloader_job = !inner_type.unwrap.kind.input? - inner_type_non_null = inner_type.non_null? idx = nil - rts = @runtime.get_current_runtime_state + dirs = ast_node.directives + make_dir_step = !dirs.empty? list_value = begin begin @graphql_application_value.each do |inner_value| @@ -262,19 +266,17 @@ def run_step idx += 1 # TODO if use_dataloader_job ... ?? # Better would be to extract a ListValueStep? - @runtime.resolve_list_item( - inner_value, - inner_type, - inner_type_non_null, - @ast_node, - @graphql_field, - @graphql_application_value, - @graphql_arguments, - this_idx, + list_item_step = ListItemStep.new( + @runtime, self, - @was_scoped, # TODO - rts, + this_idx, + inner_value ) + @runtime.run_queue << if make_dir_step + ListItemDirectivesStep.new(@runtime, @graphql_application_value, :resolve_each, dirs, list_item_step) + else + list_item_step + end end self diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index 6b1d7b1faf..bac6e4cb19 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -337,8 +337,11 @@ def self.resolve_each(object, args, context) end end - def self.resolve(obj, args, ctx) + def self.resolve(object, arguments, context) value = yield + # Previously, `yield` returned a finished value. But it doesn't anymore. + runtime_instance = context.namespace(:interpreter_runtime)[:runtime] + runtime_instance.run_queue.complete value.values.compact! value end From d9f4ebb97d99873b63854d2272cd0823214505f6 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 15:29:18 -0400 Subject: [PATCH 08/20] Isolate field resolution in step object --- lib/graphql/execution/interpreter/runtime.rb | 107 ++++++++++-------- .../interpreter/runtime/graphql_result.rb | 2 - 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 989beac95a..8857826259 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -80,7 +80,7 @@ def complete(eager: false) # @return [GraphQL::Query::Context] attr_reader :context - attr_reader :dataloader, :run_queue + attr_reader :dataloader, :run_queue, :current_trace def initialize(query:, lazies_at_depth:) @query = query @@ -474,10 +474,7 @@ def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_node end def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists - runtime_state.current_field = field_defn - runtime_state.current_arguments = resolved_arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = selection_result + # Optimize for the case that field is selected only once if field_ast_nodes.nil? || field_ast_nodes.size == 1 next_selections = ast_node.selections @@ -491,20 +488,49 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu } end - field_result = call_method_on_directives(:resolve, object, directives) do - if !directives.empty? + + resolve_field_step = FieldResolveStep.new(self, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) + @run_queue << if !directives.empty? + # TODO this will get clobbered by other steps in the queue + runtime_state.current_field = field_defn + runtime_state.current_arguments = resolved_arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selection_result + DirectivesStep.new(self, object, :resolve, directives, resolve_field_step) + else + resolve_field_step + end + end + + class FieldResolveStep + def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) + @runtime = runtime + @field = field + @object = object + @ast_node = ast_node + @kwarg_arguments = kwarg_arguments + @resolved_arguments = resolved_arguments + @result_name = result_name + @selection_result = selection_result + @next_selections = next_selections + end + + def run_step + # if !directives.empty? # This might be executed in a different context; reset this info - runtime_state = get_current_runtime_state - runtime_state.current_field = field_defn - runtime_state.current_arguments = resolved_arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = selection_result - end + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_field = @field + runtime_state.current_arguments = @resolved_arguments + runtime_state.current_result_name = @result_name + runtime_state.current_result = @selection_result + # end + # Actually call the field resolver and capture the result + query = @runtime.query app_result = begin - @current_trace.begin_execute_field(field_defn, object, kwarg_arguments, query) - @current_trace.execute_field(field: field_defn, ast_node: ast_node, query: query, object: object, arguments: kwarg_arguments) do - field_defn.resolve(object, kwarg_arguments, context) + @runtime.current_trace.begin_execute_field(@field, @object, @kwarg_arguments, query) + @runtime.current_trace.execute_field(field: @field, ast_node: @ast_node, query: query, object: @object, arguments: @kwarg_arguments) do + @field.resolve(@object, @kwarg_arguments, query.context) end rescue GraphQL::ExecutionError => err err @@ -515,24 +541,25 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu ex_err end end - @current_trace.end_execute_field(field_defn, object, kwarg_arguments, query, app_result) - after_lazy(app_result, field: field_defn, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_result, runtime_state| - return_type = field_defn.type - continue_value = continue_value(inner_result, field_defn, return_type.non_null?, ast_node, result_name, selection_result) - if HALT != continue_value - was_scoped = runtime_state.was_authorized_by_scope_items - runtime_state.was_authorized_by_scope_items = nil - continue_field(continue_value, field_defn, return_type, ast_node, next_selections, false, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) - else - nil - end + @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) + + return_type = @field.type + continue_value = @runtime.continue_value(app_result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) + if HALT != continue_value + runtime_state = @runtime.get_current_runtime_state + was_scoped = runtime_state.was_authorized_by_scope_items + runtime_state.was_authorized_by_scope_items = nil + @runtime.continue_field(continue_value, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) + else + nil + end + + # If this field is a root mutation field, immediately resolve + # all of its child fields before moving on to the next root mutation field. + # (Subselections of this mutation will still be resolved level-by-level.) + if @selection_result.graphql_is_eager + Interpreter::Resolve.resolve_all([app_result], @runtime.dataloader) end - end - # If this field is a root mutation field, immediately resolve - # all of its child fields before moving on to the next root mutation field. - # (Subselections of this mutation will still be resolved level-by-level.) - if selection_result.graphql_is_eager - Interpreter::Resolve.resolve_all([field_result], @dataloader) end end @@ -742,20 +769,6 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end end - def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists - runtime_state.current_result_name = this_idx - runtime_state.current_result = response_list - call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do - # This will update `response_list` with the lazy - after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| - continue_value = continue_value(inner_inner_value, field, inner_type_non_null, ast_node, this_idx, response_list) - if HALT != continue_value - continue_field(continue_value, field, inner_type, ast_node, response_list.graphql_selections, false, arguments, this_idx, response_list, was_scoped, runtime_state) - end - end - end - end - class ListItemDirectivesStep < DirectivesStep def run_step runtime_state = @runtime.get_current_runtime_state diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 02ecd87275..78becf3938 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -264,8 +264,6 @@ def run_step idx ||= 0 this_idx = idx idx += 1 - # TODO if use_dataloader_job ... ?? - # Better would be to extract a ListValueStep? list_item_step = ListItemStep.new( @runtime, self, From 56f38c06a6329aa39f3d3cfe19fa8dabbc22cc23 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 15:59:12 -0400 Subject: [PATCH 09/20] Add some Lazy support --- lib/graphql/execution/interpreter/runtime.rb | 170 ++++++++++++------ .../interpreter/runtime/graphql_result.rb | 20 +++ 2 files changed, 136 insertions(+), 54 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 8857826259..b8e1e9f04c 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -35,10 +35,11 @@ def current_object end class RunQueue - def initialize(dataloader:, lazies_at_depth:) + def initialize(runtime:) + @runtime = runtime @next_flush = [] - @dataloader = dataloader - @lazies_at_depth = lazies_at_depth + @dataloader = runtime.dataloader + @lazies_at_depth = runtime.lazies_at_depth @running_eagerly = false end @@ -59,7 +60,15 @@ def complete(eager: false) @next_flush = [] while (step = fl.shift) # p [:shift_step, step.inspect_step] - step.run_step + step_result = step.run_step + if step.step_finished? + # nothing further + else + if @runtime.lazy?(step_result) + @lazies_at_depth[step.depth] << step + end + self << step + end end @dataloader.append_job { Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) @@ -80,7 +89,7 @@ def complete(eager: false) # @return [GraphQL::Query::Context] attr_reader :context - attr_reader :dataloader, :run_queue, :current_trace + attr_reader :dataloader, :run_queue, :current_trace, :lazies_at_depth def initialize(query:, lazies_at_depth:) @query = query @@ -101,7 +110,7 @@ def initialize(query:, lazies_at_depth:) end # { Class => Boolean } @lazy_cache = {}.compare_by_identity - @run_queue = RunQueue.new(dataloader: @dataloader, lazies_at_depth: @lazies_at_depth) + @run_queue = RunQueue.new(runtime: self) end def final_result @@ -129,6 +138,10 @@ def run_step end end + def step_finished? + true + end + def inspect_step "#{self.class}(#{ast_node.directives.map(&:name).join(", ")}) => #{@next_step.inspect_step}" end @@ -145,6 +158,10 @@ def inspect_step "#{self.class}(#{@response_hash.graphql_result_type}, #{@response_hash.graphql_application_value})" end + def step_finished? + true + end + def run_step current_type = @response_hash.graphql_result_type value = @response_hash.graphql_application_value @@ -513,53 +530,80 @@ def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_argum @result_name = result_name @selection_result = selection_result @next_selections = next_selections + @step = 0 + end + + def inspect_step + "#{self.class}(#{@field.path})" + end + + def step_finished? + @step == 2 + end + + def depth + @selection_result.depth + 1 + end + + attr_accessor :result + + def value # Lazy API + @result = @runtime.schema.sync_lazy(@result) end def run_step - # if !directives.empty? - # This might be executed in a different context; reset this info - runtime_state = @runtime.get_current_runtime_state - runtime_state.current_field = @field - runtime_state.current_arguments = @resolved_arguments - runtime_state.current_result_name = @result_name - runtime_state.current_result = @selection_result - # end - - # Actually call the field resolver and capture the result - query = @runtime.query - app_result = begin - @runtime.current_trace.begin_execute_field(@field, @object, @kwarg_arguments, query) - @runtime.current_trace.execute_field(field: @field, ast_node: @ast_node, query: query, object: @object, arguments: @kwarg_arguments) do - @field.resolve(@object, @kwarg_arguments, query.context) + case @step + when 0 + # if !directives.empty? + # This might be executed in a different context; reset this info + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_field = @field + runtime_state.current_arguments = @resolved_arguments + runtime_state.current_result_name = @result_name + runtime_state.current_result = @selection_result + # end + + # Actually call the field resolver and capture the result + query = @runtime.query + app_result = begin + @runtime.current_trace.begin_execute_field(@field, @object, @kwarg_arguments, query) + @runtime.current_trace.execute_field(field: @field, ast_node: @ast_node, query: query, object: @object, arguments: @kwarg_arguments) do + @field.resolve(@object, @kwarg_arguments, query.context) + end + rescue GraphQL::ExecutionError => err + err + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end end - rescue GraphQL::ExecutionError => err - err - rescue StandardError => err - begin - query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - ex_err + @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) + @result = app_result + @step += 1 + @result + when 1 + return_type = @field.type + continue_value = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) + if HALT != continue_value + runtime_state = @runtime.get_current_runtime_state + was_scoped = runtime_state.was_authorized_by_scope_items + runtime_state.was_authorized_by_scope_items = nil + @runtime.continue_field(continue_value, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) + else + nil end - end - @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) - return_type = @field.type - continue_value = @runtime.continue_value(app_result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) - if HALT != continue_value - runtime_state = @runtime.get_current_runtime_state - was_scoped = runtime_state.was_authorized_by_scope_items - runtime_state.was_authorized_by_scope_items = nil - @runtime.continue_field(continue_value, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) - else + # If this field is a root mutation field, immediately resolve + # all of its child fields before moving on to the next root mutation field. + # (Subselections of this mutation will still be resolved level-by-level.) + if @selection_result.graphql_is_eager + Interpreter::Resolve.resolve_all([@result], @runtime.dataloader) + end + @step += 1 nil end - - # If this field is a root mutation field, immediately resolve - # all of its child fields before moving on to the next root mutation field. - # (Subselections of this mutation will still be resolved level-by-level.) - if @selection_result.graphql_is_eager - Interpreter::Resolve.resolve_all([app_result], @runtime.dataloader) - end end end @@ -779,11 +823,17 @@ def run_step end class ListItemStep - def initialize(runtime, list_result, index, value) + def initialize(runtime, list_result, index, item_value) @runtime = runtime @list_result = list_result @index = index - @value = value + @item_value = item_value + @step_finished = false + @depth = nil + end + + def step_finished? + @step_finished end attr_reader :index, :list_result @@ -792,14 +842,26 @@ def inspect_step "#{self.class}@#{@index}" end + def value # Lazy API + @item_value = @runtime.schema.sync_lazy(@item_value) + end + + def depth + @depth ||= @list_result.depth + 1 + end + def run_step - item_type = @list_result.graphql_result_type.of_type - item_type_non_null = item_type.non_null? - # This will update `response_list` with the lazy - continue_value = @runtime.continue_value(@value, @list_result.graphql_field, item_type_non_null, @list_result.ast_node, @index, @list_result) - if HALT != continue_value - was_scoped = false # TODO!! - @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, nil) + if @runtime.lazy?(@item_value) + @item_value + else + item_type = @list_result.graphql_result_type.of_type + item_type_non_null = item_type.non_null? + continue_value = @runtime.continue_value(@item_value, @list_result.graphql_field, item_type_non_null, @list_result.ast_node, @index, @list_result) + if HALT != continue_value + was_scoped = false # TODO!! + @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, nil) + end + @step_finished = true end end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 78becf3938..ebb289e6b3 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -23,6 +23,7 @@ def initialize(runtime_instance, result_name, result_type, application_value, pa @graphql_selections = selections @graphql_is_eager = is_eager @base_path = nil + @graphql_depth = nil end # TODO test full path in Partial @@ -59,10 +60,21 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @target_result = nil end + def depth + @graphql_depth ||= begin + parent_depth = @graphql_parent ? @graphql_parent.depth : 0 + parent_depth + 1 + end + end + def inspect_step "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_selections.size})" end + def step_finished? + true + end + def run_step if @ordered_result_keys finished_jobs = 0 @@ -250,6 +262,14 @@ def inspect_step "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_application_value.size})" end + def depth + @graphql_depth ||= @graphql_parent.depth + 1 + end + + def step_finished? + true + end + def run_step current_type = @graphql_result_type inner_type = current_type.of_type From 186e8222fbb5be8fe1257fee18714e6e14ec6197 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 23 Jun 2025 17:28:07 -0400 Subject: [PATCH 10/20] Fix inspect output --- lib/graphql/execution/interpreter/runtime.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index b8e1e9f04c..8fcb8da1b8 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -143,7 +143,7 @@ def step_finished? end def inspect_step - "#{self.class}(#{ast_node.directives.map(&:name).join(", ")}) => #{@next_step.inspect_step}" + "#{self.class}(#{@directives ? @directives.map(&:name).join(", ") : nil}) => #{@next_step.inspect_step}" end end From caa754b3e88caf9c66a5278503519132b1abe120 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Jun 2025 10:51:34 -0400 Subject: [PATCH 11/20] More lazy support, better mutation eager execution --- lib/graphql/execution/interpreter/runtime.rb | 182 +++++++++++------- .../interpreter/runtime/graphql_result.rb | 1 - 2 files changed, 116 insertions(+), 67 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 8fcb8da1b8..ea59a40417 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -44,36 +44,46 @@ def initialize(runtime:) end def <<(step) - # p [:push_step, step.inspect_step] - if @running_eagerly - step.run_step - else - @next_flush << step - end + # p [:push_step, step.inspect_step, @running_eagerly ? :eager : :queuing] + @next_flush << step end def complete(eager: false) # p [self.class, __method__, eager, caller(1,1).first, @next_flush.size] prev_eagerly = @running_eagerly @running_eagerly = eager + while (fl = @next_flush) && !fl.empty? @next_flush = [] - while (step = fl.shift) - # p [:shift_step, step.inspect_step] - step_result = step.run_step - if step.step_finished? - # nothing further - else - if @runtime.lazy?(step_result) - @lazies_at_depth[step.depth] << step + steps_to_rerun = [] + @dataloader.append_job do + while (step = fl.shift) + # p [:shift_step, step.inspect_step] + step_finished = false + rerun_step = false + while !step_finished + step_result = step.run_step + step_finished = step.step_finished? + if !step_finished && @runtime.lazy?(step_result) + # p [:lazy, step_result.class, step.depth] + @lazies_at_depth[step.depth] << step + rerun_step = true + step_finished = true # we'll come back around to it + end + end + if @running_eagerly && @next_flush.any? + # p [:unshifting, @next_flush.size, :into, fl.size] + fl.unshift(*@next_flush) + @next_flush.clear + end + if rerun_step + steps_to_rerun << step end - self << step end + @next_flush.concat(steps_to_rerun) end - @dataloader.append_job { - Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) - } @dataloader.run + Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) end ensure @running_eagerly = prev_eagerly @@ -89,7 +99,9 @@ def complete(eager: false) # @return [GraphQL::Query::Context] attr_reader :context - attr_reader :dataloader, :run_queue, :current_trace, :lazies_at_depth + attr_reader :dataloader, :current_trace, :lazies_at_depth + + attr_accessor :run_queue def initialize(query:, lazies_at_depth:) @query = query @@ -114,7 +126,6 @@ def initialize(query:, lazies_at_depth:) end def final_result - @run_queue.complete @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end @@ -143,7 +154,7 @@ def step_finished? end def inspect_step - "#{self.class}(#{@directives ? @directives.map(&:name).join(", ") : nil}) => #{@next_step.inspect_step}" + "#{self.class.name.split("::").last}##{object_id}(#{@directives ? @directives.map(&:name).join(", ") : nil}) => #{@next_step.inspect_step}" end end @@ -152,50 +163,63 @@ def initialize(runtime, response_hash, was_scoped) @runtime = runtime @response_hash = response_hash @was_scoped = was_scoped + @step = 0 end def inspect_step - "#{self.class}(#{@response_hash.graphql_result_type}, #{@response_hash.graphql_application_value})" + "#{self.class.name.split("::").last}##{object_id}(#{@response_hash.graphql_result_type}, #{@response_hash.graphql_application_value})" + end + + def depth + @response_hash.depth end def step_finished? - true + @step == 2 + end + + def value + @result = @runtime.schema.sync_lazy(@result) end def run_step - current_type = @response_hash.graphql_result_type - value = @response_hash.graphql_application_value - resolved_type_result = begin - @runtime.resolve_type(current_type, value) - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) - rescue StandardError => err - begin - @runtime.query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err + case @step + when 0 + @step += 1 + current_type = @response_hash.graphql_result_type + value = @response_hash.graphql_application_value + @result = begin + @runtime.resolve_type(current_type, value) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) + end + end + when 1 + @step += 1 + if @result.is_a?(Array) && @result.length == 2 + resolved_type, resolved_value = @result + else + resolved_type = @result + resolved_value = value + end + current_type = @response_hash.graphql_result_type + possible_types = @runtime.query.types.possible_types(current_type) + if !possible_types.include?(resolved_type) + field = @response_hash.graphql_field + parent_type = field.owner_type + err_class = current_type::UnresolvedTypeError + type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) + @runtime.schema.type_error(type_error, @runtime.context) + @runtime.set_result(selection_result, result_name, nil, false, is_non_null) + nil + else + @runtime.continue_field(resolved_value, @response_hash.graphql_field, resolved_type, @response_hash.ast_node, @response_hash.graphql_selections, @response_hash.graphql_is_non_null_in_parent, @response_hash.graphql_arguments, @response_hash.graphql_result_name, @response_hash.graphql_parent, @was_scoped, @runtime.get_current_runtime_state) end - end - - # after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| - if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 - resolved_type, resolved_value = resolved_type_result - else - resolved_type = resolved_type_result - resolved_value = value - end - - possible_types = @runtime.query.types.possible_types(current_type) - if !possible_types.include?(resolved_type) - field = @response_hash.graphql_field - parent_type = field.owner_type - err_class = current_type::UnresolvedTypeError - type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - @runtime.schema.type_error(type_error, @runtime.context) - @runtime.set_result(selection_result, result_name, nil, false, is_non_null) - nil - else - @runtime.continue_field(resolved_value, @response_hash.graphql_field, resolved_type, @response_hash.ast_node, @response_hash.graphql_selections, @response_hash.graphql_is_non_null_in_parent, @response_hash.graphql_arguments, @response_hash.graphql_result_name, @response_hash.graphql_parent, @was_scoped, @runtime_state) end end end @@ -378,11 +402,6 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH - # @return [void] - def evaluate_selections(gathered_selections, selections_result, target_result, runtime_state) # rubocop:disable Metrics/ParameterLists - - end - # @return [void] def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_result) # rubocop:disable Metrics/ParameterLists return if selections_result.graphql_dead @@ -505,7 +524,6 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu } end - resolve_field_step = FieldResolveStep.new(self, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) @run_queue << if !directives.empty? # TODO this will get clobbered by other steps in the queue @@ -534,7 +552,7 @@ def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_argum end def inspect_step - "#{self.class}(#{@field.path})" + "#{self.class.name.split("::").last}##{object_id}(#{@field.path} @ #{@result_name})" end def step_finished? @@ -548,10 +566,25 @@ def depth attr_accessor :result def value # Lazy API - @result = @runtime.schema.sync_lazy(@result) + @result = begin + @runtime.schema.sync_lazy(@result) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end end def run_step + if @selection_result.graphql_dead + @step = 2 + return nil + end + case @step when 0 # if !directives.empty? @@ -586,6 +619,11 @@ def run_step when 1 return_type = @field.type continue_value = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) + if @selection_result.graphql_is_eager + prev_queue = @runtime.run_queue + @runtime.run_queue = RunQueue.new(runtime: @runtime) + end + if HALT != continue_value runtime_state = @runtime.get_current_runtime_state was_scoped = runtime_state.was_authorized_by_scope_items @@ -599,7 +637,9 @@ def run_step # all of its child fields before moving on to the next root mutation field. # (Subselections of this mutation will still be resolved level-by-level.) if @selection_result.graphql_is_eager - Interpreter::Resolve.resolve_all([@result], @runtime.dataloader) + @runtime.run_queue.complete(eager: true) + @runtime.run_queue = prev_queue + # Interpreter::Resolve.resolve_all([@result], @runtime.dataloader) end @step += 1 nil @@ -839,11 +879,21 @@ def step_finished? attr_reader :index, :list_result def inspect_step - "#{self.class}@#{@index}" + "#{self.class.name.split("::").last}##{object_id}@#{@index}" end def value # Lazy API - @item_value = @runtime.schema.sync_lazy(@item_value) + @item_value = begin + @runtime.schema.sync_lazy(@item_value) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end end def depth @@ -859,7 +909,7 @@ def run_step continue_value = @runtime.continue_value(@item_value, @list_result.graphql_field, item_type_non_null, @list_result.ast_node, @index, @list_result) if HALT != continue_value was_scoped = false # TODO!! - @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, nil) + @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, @runtime.get_current_runtime_state) end @step_finished = true end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index ebb289e6b3..ebdece311b 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -95,7 +95,6 @@ def run_step self.merge_into(@target_result) end end - @runtime.run_queue.complete(eager: true) @runtime.dataloader.clear_cache } else From 0f090cce129c2d84bdb18a3c45bef620304aa90e Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Jun 2025 14:06:17 -0400 Subject: [PATCH 12/20] Move execute field methods into ResultHash --- lib/graphql/execution/interpreter/runtime.rb | 252 ++++-------------- .../interpreter/runtime/graphql_result.rb | 241 ++++++++++++++--- 2 files changed, 248 insertions(+), 245 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index ea59a40417..92e2e09756 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -37,53 +37,57 @@ def current_object class RunQueue def initialize(runtime:) @runtime = runtime - @next_flush = [] + @current_flush = [] @dataloader = runtime.dataloader @lazies_at_depth = runtime.lazies_at_depth @running_eagerly = false end - def <<(step) - # p [:push_step, step.inspect_step, @running_eagerly ? :eager : :queuing] - @next_flush << step + def append_step(step) + @current_flush << step end def complete(eager: false) - # p [self.class, __method__, eager, caller(1,1).first, @next_flush.size] + # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] prev_eagerly = @running_eagerly @running_eagerly = eager - - while (fl = @next_flush) && !fl.empty? - @next_flush = [] - steps_to_rerun = [] - @dataloader.append_job do + while (fl = @current_flush) && fl.any? + @current_flush = [] + steps_to_rerun_after_lazy = [] + while fl.any? while (step = fl.shift) # p [:shift_step, step.inspect_step] step_finished = false - rerun_step = false while !step_finished + # p [:run_step, step.inspect_step] step_result = step.run_step step_finished = step.step_finished? if !step_finished && @runtime.lazy?(step_result) # p [:lazy, step_result.class, step.depth] @lazies_at_depth[step.depth] << step - rerun_step = true + steps_to_rerun_after_lazy << step step_finished = true # we'll come back around to it end end - if @running_eagerly && @next_flush.any? - # p [:unshifting, @next_flush.size, :into, fl.size] - fl.unshift(*@next_flush) - @next_flush.clear - end - if rerun_step - steps_to_rerun << step + + if @running_eagerly && @current_flush.any? + # This is for mutations. If a mutation parent field enqueues any child fields, + # we need to run those before running other mutation parent fields. + fl.unshift(*@current_flush) + @current_flush.clear end end - @next_flush.concat(steps_to_rerun) + + if @current_flush.any? + fl.concat(@current_flush) + @current_flush.clear + else + fl.concat(steps_to_rerun_after_lazy) + steps_to_rerun_after_lazy.clear + @dataloader.run + Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + end end - @dataloader.run - Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) end ensure @running_eagerly = prev_eagerly @@ -126,6 +130,7 @@ def initialize(query:, lazies_at_depth:) end def final_result + # TODO can `graphql_result_data` be set to `nil` when `.wrap` fails? @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end @@ -144,7 +149,7 @@ def initialize(runtime, object, method_to_call, directives, next_step) def run_step @runtime.call_method_on_directives(@method_to_call, @object, @directives) do - @runtime.run_queue << @next_step + @runtime.run_queue.append_step(@next_step) @next_step end end @@ -247,21 +252,16 @@ def run_eager runtime_state = get_current_runtime_state case root_type.kind.name when "OBJECT" - object_proxy = root_type.wrap(object, context) - object_proxy = schema.sync_lazy(object_proxy) - if object_proxy.nil? - @response = nil + # TODO: use `nil` for top-level result when `.wrap` returns `nil` + @response = GraphQLResultHash.new(self, nil, root_type, object, nil, false, selections, is_eager, ast_node, nil, nil) + @response.base_path = base_path + runtime_state.current_result = @response + next_step = if !ast_node.directives.empty? + DirectivesStep.new(self, object, :resolve, ast_node.directives, @response) else - @response = GraphQLResultHash.new(self, nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) - @response.base_path = base_path - runtime_state.current_result = @response - if !ast_node.directives.empty? - dir_step = DirectivesStep.new(self, object, :resolve, ast_node.directives, @response) - @run_queue << dir_step - else - @run_queue << @response - end + @response end + @run_queue.append_step(next_step) when "LIST" inner_type = root_type.unwrap case inner_type.kind.name @@ -282,7 +282,7 @@ def run_eager else @response = GraphQLResultArray.new(self, nil, root_type, object, nil, false, selections, false, ast_node, nil, nil) @response.base_path = base_path - @run_queue << @response + @run_queue.append_step(@response) end when "SCALAR", "ENUM" result_name = ast_node.alias || ast_node.name @@ -304,7 +304,7 @@ def run_eager @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false, query.ast_nodes.first, nil, nil) @response.base_path = base_path - @run_queue << ResolveTypeStep.new(self, @response, false) + @run_queue.append_step(ResolveTypeStep.new(self, @response, false)) else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end @@ -400,143 +400,6 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s selections_to_run || selections_by_name end - NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH - - # @return [void] - def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_result) # rubocop:disable Metrics/ParameterLists - return if selections_result.graphql_dead - # As a performance optimization, the hash key will be a `Node` if - # there's only one selection of the field. But if there are multiple - # selections of the field, it will be an Array of nodes - if field_ast_nodes_or_ast_node.is_a?(Array) - field_ast_nodes = field_ast_nodes_or_ast_node - ast_node = field_ast_nodes.first - else - field_ast_nodes = nil - ast_node = field_ast_nodes_or_ast_node - end - field_name = ast_node.name - owner_type = selections_result.graphql_result_type - field_defn = query.types.field(owner_type, field_name) - - # Set this before calling `run_with_directives`, so that the directive can have the latest path - runtime_state = get_current_runtime_state - runtime_state.current_field = field_defn - runtime_state.current_result = selections_result - runtime_state.current_result_name = result_name - - owner_object = selections_result.graphql_application_value - if field_defn.dynamic_introspection - owner_object = field_defn.owner.wrap(owner_object, context) - end - - if !field_defn.any_arguments? - resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY - if field_defn.extras.size == 0 - evaluate_selection_with_resolved_keyword_args( - NO_ARGS, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state - ) - else - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) - end - else - @query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| - runtime_state = get_current_runtime_state # This might be in a different fiber - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) - end - end - end - - def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists - after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_arguments, runtime_state| - if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) - return_type_non_null = field_defn.type.non_null? - continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, selection_result) - next - end - - kwarg_arguments = if field_defn.extras.empty? - if resolved_arguments.empty? - # We can avoid allocating the `{ Symbol => Object }` hash in this case - NO_ARGS - else - resolved_arguments.keyword_arguments - end - else - # Bundle up the extras, then make a new arguments instance - # that includes the extras, too. - extra_args = {} - field_defn.extras.each do |extra| - case extra - when :ast_node - extra_args[:ast_node] = ast_node - when :execution_errors - extra_args[:execution_errors] = ExecutionErrors.new(context, ast_node, current_path) - when :path - extra_args[:path] = current_path - when :lookahead - if !field_ast_nodes - field_ast_nodes = [ast_node] - end - - extra_args[:lookahead] = Execution::Lookahead.new( - query: query, - ast_nodes: field_ast_nodes, - field: field_defn, - ) - when :argument_details - # Use this flag to tell Interpreter::Arguments to add itself - # to the keyword args hash _before_ freezing everything. - extra_args[:argument_details] = :__arguments_add_self - when :parent - parent_result = selection_result.graphql_parent - if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) - parent_result = parent_result.graphql_parent - end - parent_value = parent_result&.graphql_application_value&.object - extra_args[:parent] = parent_value - else - extra_args[extra] = field_defn.fetch_extra(extra, context) - end - end - if !extra_args.empty? - resolved_arguments = resolved_arguments.merge_extras(extra_args) - end - resolved_arguments.keyword_arguments - end - - evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) - end - end - - def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists - - # Optimize for the case that field is selected only once - if field_ast_nodes.nil? || field_ast_nodes.size == 1 - next_selections = ast_node.selections - directives = ast_node.directives - else - next_selections = [] - directives = [] - field_ast_nodes.each { |f| - next_selections.concat(f.selections) - directives.concat(f.directives) - } - end - - resolve_field_step = FieldResolveStep.new(self, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) - @run_queue << if !directives.empty? - # TODO this will get clobbered by other steps in the queue - runtime_state.current_field = field_defn - runtime_state.current_arguments = resolved_arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = selection_result - DirectivesStep.new(self, object, :resolve, directives, resolve_field_step) - else - resolve_field_step - end - end - class FieldResolveStep def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) @runtime = runtime @@ -552,7 +415,7 @@ def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_argum end def inspect_step - "#{self.class.name.split("::").last}##{object_id}(#{@field.path} @ #{@result_name})" + "#{self.class.name.split("::").last}##{object_id}(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" end def step_finished? @@ -617,12 +480,13 @@ def run_step @step += 1 @result when 1 + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_field = @field + runtime_state.current_arguments = @resolved_arguments + runtime_state.current_result_name = @result_name + runtime_state.current_result = @selection_result return_type = @field.type continue_value = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) - if @selection_result.graphql_is_eager - prev_queue = @runtime.run_queue - @runtime.run_queue = RunQueue.new(runtime: @runtime) - end if HALT != continue_value runtime_state = @runtime.get_current_runtime_state @@ -633,14 +497,6 @@ def run_step nil end - # If this field is a root mutation field, immediately resolve - # all of its child fields before moving on to the next root mutation field. - # (Subselections of this mutation will still be resolved level-by-level.) - if @selection_result.graphql_is_eager - @runtime.run_queue.complete(eager: true) - @runtime.run_queue = prev_queue - # Interpreter::Resolve.resolve_all([@result], @runtime.dataloader) - end @step += 1 nil end @@ -828,25 +684,15 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non r when "UNION", "INTERFACE" response_hash = GraphQLResultHash.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) - @run_queue << ResolveTypeStep.new(self, response_hash, was_scoped) + @run_queue.append_step ResolveTypeStep.new(self, response_hash, was_scoped) when "OBJECT" - object_proxy = begin - was_scoped ? current_type.wrap_scoped(value, context) : current_type.wrap(value, context) - rescue GraphQL::ExecutionError => err - err - end - after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: selection_result.graphql_application_value, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| - continue_value = continue_value(inner_object, field, is_non_null, ast_node, result_name, selection_result) - if HALT != continue_value - response_hash = GraphQLResultHash.new(self, result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) - set_result(selection_result, result_name, response_hash, true, is_non_null) - @run_queue << response_hash - end - end + response_hash = GraphQLResultHash.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + response_hash.was_scoped = was_scoped + @run_queue.append_step response_hash when "LIST" response_list = GraphQLResultArray.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) set_result(selection_result, result_name, response_list, true, is_non_null) - @run_queue << response_list + @run_queue.append_step(response_list) response_list # TODO smell this is used because its returned by `yield` inside a directive else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index ebdece311b..08be62be99 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -53,11 +53,14 @@ def build_path(path_array) end class GraphQLResultHash + def initialize(_runtime_inst, _result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists super @graphql_result_data = {} @ordered_result_keys = nil @target_result = nil + @was_scoped = nil + @step = 0 end def depth @@ -68,50 +71,37 @@ def depth end def inspect_step - "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_selections.size})" + "#{self.class.name.split("::").last}##{object_id}(#{@graphql_result_type.to_type_signature} @ #{path.join(".")}})" end def step_finished? - true + @step == 4 + end + + def value + @graphql_application_value = @runtime.schema.sync_lazy(@graphql_application_value) end def run_step - if @ordered_result_keys - finished_jobs = 0 - enqueued_jobs = @graphql_selections.size # TODO needless? - @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| - # Field resolution may pause the fiber, - # so it wouldn't get to the `Resolve` call that happens below. - # So instead trigger a run from this outer context. - if @graphql_is_eager - @runtime.dataloader.clear_cache - @runtime.dataloader.run_isolated { - @runtime.evaluate_selection( - result_name, field_ast_nodes_or_ast_node, self - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if @target_result - self.merge_into(@target_result) - end - end - @runtime.dataloader.clear_cache - } - else - @runtime.dataloader.append_job { - @runtime.evaluate_selection( - result_name, field_ast_nodes_or_ast_node, self - ) - finished_jobs += 1 - if finished_jobs == enqueued_jobs - if @target_result - self.merge_into(@target_result) - end - end - } - end + @step += 1 + case @step + when 1 + @graphql_application_value = begin + value = @graphql_application_value + context = @runtime.context + @was_scoped ? @graphql_result_type.wrap_scoped(value, context) : @graphql_result_type.wrap(value, context) + rescue GraphQL::ExecutionError => err + err end - else + when 2 + @graphql_application_value = @runtime.continue_value(@graphql_application_value, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) + if HALT.equal?(@graphql_application_value) + @step = 4 + elsif @graphql_parent + @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) + end + nil + when 3 @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| @ordered_result_keys ||= ordered_result_keys if is_selection_array @@ -140,14 +130,181 @@ def run_step # This is a less-frequent case; use a fast check since it's often not there. if (directives = gathered_selections[:graphql_directives]) gathered_selections.delete(:graphql_directives) + dir_step = DirectivesStep.new(@runtime, selections_result.graphql_application_value, :resolve, directives, selections_result) + @runtime.run_queue.append_step(dir_step) + elsif @target_result.nil? + run_step # Run itself again + else + @runtime.run_queue.append_step(selections_result) + end + end + when 4 + @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| + # Field resolution may pause the fiber, + # so it wouldn't get to the `Resolve` call that happens below. + # So instead trigger a run from this outer context. + if @graphql_is_eager + prev_queue = @runtime.run_queue + @runtime.run_queue = RunQueue.new(runtime: @runtime) + @runtime.dataloader.clear_cache + @runtime.dataloader.run_isolated { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node + ) + @runtime.dataloader.clear_cache + } + @runtime.run_queue.complete(eager: true) + @runtime.run_queue = prev_queue + else + @runtime.dataloader.append_job { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node + ) + } + end + end + # TODO I'm pretty sure finished_jobs/enqueued_jobs actually did nothing + if @target_result + self.merge_into(@target_result) + end + end + end + + def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disable Metrics/ParameterLists + return if @graphql_dead + # As a performance optimization, the hash key will be a `Node` if + # there's only one selection of the field. But if there are multiple + # selections of the field, it will be an Array of nodes + if field_ast_nodes_or_ast_node.is_a?(Array) + field_ast_nodes = field_ast_nodes_or_ast_node + ast_node = field_ast_nodes.first + else + field_ast_nodes = nil + ast_node = field_ast_nodes_or_ast_node + end + field_name = ast_node.name + owner_type = @graphql_result_type + field_defn = @runtime.query.types.field(owner_type, field_name) + + # Set this before calling `run_with_directives`, so that the directive can have the latest path + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_field = field_defn + runtime_state.current_result = self + runtime_state.current_result_name = result_name + + owner_object = @graphql_application_value + if field_defn.dynamic_introspection + owner_object = field_defn.owner.wrap(owner_object, @runtime.context) + end + + if !field_defn.any_arguments? + resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY + if field_defn.extras.size == 0 + evaluate_selection_with_resolved_keyword_args( + EmptyObjects::EMPTY_HASH, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state + ) + else + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state) + end + else + @runtime.query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| + runtime_state = @runtime.get_current_runtime_state # This might be in a different fiber + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state) + end + end + end + + def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + # TODO make this a step + @runtime.after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_arguments, runtime_state| + if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) + return_type_non_null = field_defn.type.non_null? + continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, selection_result) + next + end + + kwarg_arguments = if field_defn.extras.empty? + if resolved_arguments.empty? + # We can avoid allocating the `{ Symbol => Object }` hash in this case + EmptyObjects::EMPTY_HASH + else + resolved_arguments.keyword_arguments end + else + # Bundle up the extras, then make a new arguments instance + # that includes the extras, too. + extra_args = {} + field_defn.extras.each do |extra| + case extra + when :ast_node + extra_args[:ast_node] = ast_node + when :execution_errors + extra_args[:execution_errors] = ExecutionErrors.new(@runtime.context, ast_node, @runtime.current_path) + when :path + extra_args[:path] = @runtime.current_path + when :lookahead + if !field_ast_nodes + field_ast_nodes = [ast_node] + end - @runtime.run_queue << DirectivesStep.new(@runtime, selections_result.graphql_application_value, :resolve, directives, selections_result) + extra_args[:lookahead] = Execution::Lookahead.new( + query: @runtime.query, + ast_nodes: field_ast_nodes, + field: field_defn, + ) + when :argument_details + # Use this flag to tell Interpreter::Arguments to add itself + # to the keyword args hash _before_ freezing everything. + extra_args[:argument_details] = :__arguments_add_self + when :parent + parent_result = selection_result.graphql_parent + if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) + parent_result = parent_result.graphql_parent + end + parent_value = parent_result&.graphql_application_value&.object + extra_args[:parent] = parent_value + else + extra_args[extra] = field_defn.fetch_extra(extra, @runtime.context) + end + end + if !extra_args.empty? + resolved_arguments = resolved_arguments.merge_extras(extra_args) + end + resolved_arguments.keyword_arguments end + + evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) end end - attr_accessor :ordered_result_keys, :target_result + def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + # Optimize for the case that field is selected only once + if field_ast_nodes.nil? || field_ast_nodes.size == 1 + next_selections = ast_node.selections + directives = ast_node.directives + else + next_selections = [] + directives = [] + field_ast_nodes.each { |f| + next_selections.concat(f.selections) + directives.concat(f.directives) + } + end + + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) + @runtime.run_queue.append_step(if !directives.empty? + # TODO this will get clobbered by other steps in the queue + runtime_state.current_field = field_defn + runtime_state.current_arguments = resolved_arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selection_result + DirectivesStep.new(@runtime, object, :resolve, directives, resolve_field_step) + else + resolve_field_step + end) + end + + attr_accessor :ordered_result_keys, :target_result, :was_scoped include GraphQLResult @@ -258,7 +415,7 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p end def inspect_step - "#{self.class}(#{@graphql_result_type.to_type_signature} #{@graphql_result_name} => #{@graphql_application_value.size})" + "#{self.class.name.split("::").last}##{object_id}(#{@graphql_result_type.to_type_signature} @ #{path.join(".")})" end def depth @@ -289,11 +446,11 @@ def run_step this_idx, inner_value ) - @runtime.run_queue << if make_dir_step + @runtime.run_queue.append_step(if make_dir_step ListItemDirectivesStep.new(@runtime, @graphql_application_value, :resolve_each, dirs, list_item_step) else list_item_step - end + end) end self From 325d5d8f3b479863dad66e4d4bd1615ae28303c9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Jun 2025 14:09:03 -0400 Subject: [PATCH 13/20] use self instead of selection result --- .../interpreter/runtime/graphql_result.rb | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 08be62be99..a121755521 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -201,25 +201,25 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY if field_defn.extras.size == 0 evaluate_selection_with_resolved_keyword_args( - EmptyObjects::EMPTY_HASH, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state + EmptyObjects::EMPTY_HASH, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state ) else - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state) + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state) end else @runtime.query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| runtime_state = @runtime.get_current_runtime_state # This might be in a different fiber - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, self, runtime_state) + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state) end end end - def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) # rubocop:disable Metrics/ParameterLists # TODO make this a step - @runtime.after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_arguments, runtime_state| + @runtime.after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: self, runtime_state: runtime_state) do |resolved_arguments, runtime_state| if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) return_type_non_null = field_defn.type.non_null? - continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, selection_result) + continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, self) next end @@ -257,7 +257,7 @@ def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_node # to the keyword args hash _before_ freezing everything. extra_args[:argument_details] = :__arguments_add_self when :parent - parent_result = selection_result.graphql_parent + parent_result = @graphql_parent if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) parent_result = parent_result.graphql_parent end @@ -273,11 +273,11 @@ def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_node resolved_arguments.keyword_arguments end - evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) + evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) end end - def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) # rubocop:disable Metrics/ParameterLists # Optimize for the case that field is selected only once if field_ast_nodes.nil? || field_ast_nodes.size == 1 next_selections = ast_node.selections @@ -291,13 +291,13 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu } end - resolve_field_step = FieldResolveStep.new(@runtime, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, self, next_selections) @runtime.run_queue.append_step(if !directives.empty? # TODO this will get clobbered by other steps in the queue runtime_state.current_field = field_defn runtime_state.current_arguments = resolved_arguments runtime_state.current_result_name = result_name - runtime_state.current_result = selection_result + runtime_state.current_result = self DirectivesStep.new(@runtime, object, :resolve, directives, resolve_field_step) else resolve_field_step From b233c92a341cd4ceedc3e5a26e046a9b35470a2a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 24 Jun 2025 14:50:03 -0400 Subject: [PATCH 14/20] Move resolution code into FieldResolveStep --- lib/graphql/execution/interpreter/runtime.rb | 92 +++++++++++--- .../interpreter/runtime/graphql_result.rb | 118 ++++-------------- lib/graphql/schema/build_from_definition.rb | 2 +- 3 files changed, 102 insertions(+), 110 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 92e2e09756..713d616f23 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -56,7 +56,6 @@ def complete(eager: false) steps_to_rerun_after_lazy = [] while fl.any? while (step = fl.shift) - # p [:shift_step, step.inspect_step] step_finished = false while !step_finished # p [:run_step, step.inspect_step] @@ -401,13 +400,12 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s end class FieldResolveStep - def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_arguments, result_name, selection_result, next_selections) + def initialize(runtime, field, object, ast_node, ast_nodes, result_name, selection_result, next_selections) @runtime = runtime @field = field @object = object @ast_node = ast_node - @kwarg_arguments = kwarg_arguments - @resolved_arguments = resolved_arguments + @ast_nodes = ast_nodes @result_name = result_name @selection_result = selection_result @next_selections = next_selections @@ -415,11 +413,11 @@ def initialize(runtime, field, object, ast_node, kwarg_arguments, resolved_argum end def inspect_step - "#{self.class.name.split("::").last}##{object_id}(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" + "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" end def step_finished? - @step == 2 + @step == 4 end def depth @@ -444,12 +442,82 @@ def value # Lazy API def run_step if @selection_result.graphql_dead - @step = 2 + @step = 4 return nil end - + @step += 1 case @step - when 0 + when 1 + if !@field.any_arguments? + @resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY + if @field.extras.size == 0 + @kwarg_arguments = EmptyObjects::EMPTY_HASH + @step += 1 # skip step 2 + end + nil + else + @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @owner_object) do |resolved_arguments| + @resolved_arguments = resolved_arguments + end + end + when 2 + if @resolved_arguments.is_a?(GraphQL::ExecutionError) || @resolved_arguments.is_a?(GraphQL::UnauthorizedError) + return_type_non_null = @field.type.non_null? + @runtime.continue_value(@resolved_arguments, @field, return_type_non_null, @ast_node, @result_name, @selection_result) + @step = 4 + return + end + + @kwarg_arguments = if @field.extras.empty? + if @resolved_arguments.empty? + # We can avoid allocating the `{ Symbol => Object }` hash in this case + EmptyObjects::EMPTY_HASH + else + @resolved_arguments.keyword_arguments + end + else + # Bundle up the extras, then make a new arguments instance + # that includes the extras, too. + extra_args = {} + @field.extras.each do |extra| + case extra + when :ast_node + extra_args[:ast_node] = ast_node + when :execution_errors + extra_args[:execution_errors] = ExecutionErrors.new(@runtime.context, @ast_node, @runtime.current_path) + when :path + extra_args[:path] = @runtime.current_path + when :lookahead + if !@field_ast_nodes + @field_ast_nodes = [@ast_node] + end + + extra_args[:lookahead] = Execution::Lookahead.new( + query: @runtime.query, + ast_nodes: @field_ast_nodes, + field: @field, + ) + when :argument_details + # Use this flag to tell Interpreter::Arguments to add itself + # to the keyword args hash _before_ freezing everything. + extra_args[:argument_details] = :__arguments_add_self + when :parent + parent_result = @selection_result.graphql_parent + if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) + parent_result = parent_result.graphql_parent + end + parent_value = parent_result&.graphql_application_value&.object + extra_args[:parent] = parent_value + else + extra_args[extra] = @field.fetch_extra(extra, @runtime.context) + end + end + if !extra_args.empty? + @resolved_arguments = @resolved_arguments.merge_extras(extra_args) + end + @resolved_arguments.keyword_arguments + end + when 3 # if !directives.empty? # This might be executed in a different context; reset this info runtime_state = @runtime.get_current_runtime_state @@ -477,9 +545,7 @@ def run_step end @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) @result = app_result - @step += 1 - @result - when 1 + when 4 runtime_state = @runtime.get_current_runtime_state runtime_state.current_field = @field runtime_state.current_arguments = @resolved_arguments @@ -496,8 +562,6 @@ def run_step else nil end - - @step += 1 nil end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index a121755521..09667edfea 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -182,102 +182,29 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab field_ast_nodes = nil ast_node = field_ast_nodes_or_ast_node end + + # Optimize for the case that field is selected only once + if field_ast_nodes.nil? || field_ast_nodes.size == 1 + next_selections = ast_node.selections + directives = ast_node.directives + else + next_selections = [] + directives = [] + field_ast_nodes.each { |f| + next_selections.concat(f.selections) + directives.concat(f.directives) + } + end + field_name = ast_node.name owner_type = @graphql_result_type field_defn = @runtime.query.types.field(owner_type, field_name) - # Set this before calling `run_with_directives`, so that the directive can have the latest path - runtime_state = @runtime.get_current_runtime_state - runtime_state.current_field = field_defn - runtime_state.current_result = self - runtime_state.current_result_name = result_name - owner_object = @graphql_application_value if field_defn.dynamic_introspection owner_object = field_defn.owner.wrap(owner_object, @runtime.context) end - if !field_defn.any_arguments? - resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY - if field_defn.extras.size == 0 - evaluate_selection_with_resolved_keyword_args( - EmptyObjects::EMPTY_HASH, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state - ) - else - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state) - end - else - @runtime.query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| - runtime_state = @runtime.get_current_runtime_state # This might be in a different fiber - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, runtime_state) - end - end - end - - def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) # rubocop:disable Metrics/ParameterLists - # TODO make this a step - @runtime.after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: self, runtime_state: runtime_state) do |resolved_arguments, runtime_state| - if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) - return_type_non_null = field_defn.type.non_null? - continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, self) - next - end - - kwarg_arguments = if field_defn.extras.empty? - if resolved_arguments.empty? - # We can avoid allocating the `{ Symbol => Object }` hash in this case - EmptyObjects::EMPTY_HASH - else - resolved_arguments.keyword_arguments - end - else - # Bundle up the extras, then make a new arguments instance - # that includes the extras, too. - extra_args = {} - field_defn.extras.each do |extra| - case extra - when :ast_node - extra_args[:ast_node] = ast_node - when :execution_errors - extra_args[:execution_errors] = ExecutionErrors.new(@runtime.context, ast_node, @runtime.current_path) - when :path - extra_args[:path] = @runtime.current_path - when :lookahead - if !field_ast_nodes - field_ast_nodes = [ast_node] - end - - extra_args[:lookahead] = Execution::Lookahead.new( - query: @runtime.query, - ast_nodes: field_ast_nodes, - field: field_defn, - ) - when :argument_details - # Use this flag to tell Interpreter::Arguments to add itself - # to the keyword args hash _before_ freezing everything. - extra_args[:argument_details] = :__arguments_add_self - when :parent - parent_result = @graphql_parent - if parent_result.is_a?(GraphQL::Execution::Interpreter::Runtime::GraphQLResultArray) - parent_result = parent_result.graphql_parent - end - parent_value = parent_result&.graphql_application_value&.object - extra_args[:parent] = parent_value - else - extra_args[extra] = field_defn.fetch_extra(extra, @runtime.context) - end - end - if !extra_args.empty? - resolved_arguments = resolved_arguments.merge_extras(extra_args) - end - resolved_arguments.keyword_arguments - end - - evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) - end - end - - def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, runtime_state) # rubocop:disable Metrics/ParameterLists # Optimize for the case that field is selected only once if field_ast_nodes.nil? || field_ast_nodes.size == 1 next_selections = ast_node.selections @@ -291,17 +218,18 @@ def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_argu } end - resolve_field_step = FieldResolveStep.new(@runtime, field_defn, object, ast_node, kwarg_arguments, resolved_arguments, result_name, self, next_selections) - @runtime.run_queue.append_step(if !directives.empty? + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, owner_object, ast_node, field_ast_nodes, result_name, self, next_selections) + next_step = if !directives.empty? # TODO this will get clobbered by other steps in the queue - runtime_state.current_field = field_defn - runtime_state.current_arguments = resolved_arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = self - DirectivesStep.new(@runtime, object, :resolve, directives, resolve_field_step) + # runtime_state.current_field = field_defn + # runtime_state.current_arguments = resolved_arguments + # runtime_state.current_result_name = result_name + # runtime_state.current_result = self + DirectivesStep.new(@runtime, owner_object, :resolve, directives, resolve_field_step) else resolve_field_step - end) + end + @runtime.run_queue.append_step(next_step) end attr_accessor :ordered_result_keys, :target_result, :was_scoped diff --git a/lib/graphql/schema/build_from_definition.rb b/lib/graphql/schema/build_from_definition.rb index b5b3cc614d..8918818ae8 100644 --- a/lib/graphql/schema/build_from_definition.rb +++ b/lib/graphql/schema/build_from_definition.rb @@ -523,7 +523,7 @@ def build_fields(owner, field_definitions, type_resolver, default_resolve:) def define_field_resolve_method(owner, method_name, field_name) owner.define_method(method_name) { |**args| - field_instance = self.class.get_field(field_name) + field_instance = context.types.field(owner, field_name) context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context) } end From d3927b88652b30c2fe6dd4a9041413830984bd0c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 25 Jun 2025 14:58:15 -0400 Subject: [PATCH 15/20] Add todos --- lib/graphql/execution/interpreter/runtime/graphql_result.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 09667edfea..3b867bd500 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -100,6 +100,7 @@ def run_step elsif @graphql_parent @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) end + # TODO Why cant this go right to the next step? nil when 3 @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| @@ -119,6 +120,7 @@ def run_step @graphql_field) selections_result.target_result = self selections_result.ordered_result_keys = ordered_result_keys + # TODO This hash should start in step 4? else selections_result = self @target_result = nil @@ -133,6 +135,7 @@ def run_step dir_step = DirectivesStep.new(@runtime, selections_result.graphql_application_value, :resolve, directives, selections_result) @runtime.run_queue.append_step(dir_step) elsif @target_result.nil? + # TODO extract these substeps out into methods, call that method directly run_step # Run itself again else @runtime.run_queue.append_step(selections_result) From 123bf35d575c3e3827ff0a56880a1a7cd265e2a1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 26 Jun 2025 15:28:25 -0400 Subject: [PATCH 16/20] Merge ResolveTypeStep into ResultHash --- lib/graphql/execution/interpreter/runtime.rb | 84 ++----------------- .../interpreter/runtime/graphql_result.rb | 56 +++++++++++-- 2 files changed, 55 insertions(+), 85 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 713d616f23..460c63ead1 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -162,72 +162,6 @@ def inspect_step end end - class ResolveTypeStep - def initialize(runtime, response_hash, was_scoped) - @runtime = runtime - @response_hash = response_hash - @was_scoped = was_scoped - @step = 0 - end - - def inspect_step - "#{self.class.name.split("::").last}##{object_id}(#{@response_hash.graphql_result_type}, #{@response_hash.graphql_application_value})" - end - - def depth - @response_hash.depth - end - - def step_finished? - @step == 2 - end - - def value - @result = @runtime.schema.sync_lazy(@result) - end - - def run_step - case @step - when 0 - @step += 1 - current_type = @response_hash.graphql_result_type - value = @response_hash.graphql_application_value - @result = begin - @runtime.resolve_type(current_type, value) - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err - return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) - rescue StandardError => err - begin - @runtime.query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - return @runtime.continue_value(ex_err, @response_hash.graphql_field, @response_hash.graphql_is_non_null_in_parent, @response_hash.ast_node, @response_hash.graphql_result_name, @response_hash.graphql_parent) - end - end - when 1 - @step += 1 - if @result.is_a?(Array) && @result.length == 2 - resolved_type, resolved_value = @result - else - resolved_type = @result - resolved_value = value - end - current_type = @response_hash.graphql_result_type - possible_types = @runtime.query.types.possible_types(current_type) - if !possible_types.include?(resolved_type) - field = @response_hash.graphql_field - parent_type = field.owner_type - err_class = current_type::UnresolvedTypeError - type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - @runtime.schema.type_error(type_error, @runtime.context) - @runtime.set_result(selection_result, result_name, nil, false, is_non_null) - nil - else - @runtime.continue_field(resolved_value, @response_hash.graphql_field, resolved_type, @response_hash.ast_node, @response_hash.graphql_selections, @response_hash.graphql_is_non_null_in_parent, @response_hash.graphql_arguments, @response_hash.graphql_result_name, @response_hash.graphql_parent, @was_scoped, @runtime.get_current_runtime_state) - end - end - end - end - # @return [void] def run_eager root_type = query.root_type @@ -250,10 +184,11 @@ def run_eager object = schema.sync_lazy(object) # TODO test query partial with lazy root object runtime_state = get_current_runtime_state case root_type.kind.name - when "OBJECT" + when "OBJECT", "UNION", "INTERFACE" # TODO: use `nil` for top-level result when `.wrap` returns `nil` @response = GraphQLResultHash.new(self, nil, root_type, object, nil, false, selections, is_eager, ast_node, nil, nil) @response.base_path = base_path + runtime_state.current_result = @response next_step = if !ast_node.directives.empty? DirectivesStep.new(self, object, :resolve, ast_node.directives, @response) @@ -268,7 +203,7 @@ def run_eager result_name = ast_node.alias || ast_node.name field_defn = query.field_definition owner_type = field_defn.owner - selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result = GraphQLResultHash.new(self, nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) selection_result.base_path = base_path selection_result.ordered_result_keys = [result_name] runtime_state.current_result = selection_result @@ -287,7 +222,7 @@ def run_eager result_name = ast_node.alias || ast_node.name field_defn = query.field_definition owner_type = field_defn.owner - selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result = GraphQLResultHash.new(self, nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) selection_result.ordered_result_keys = [result_name] selection_result.base_path = base_path runtime_state = get_current_runtime_state @@ -298,12 +233,6 @@ def run_eager continue_field(continue_value, field_defn, query.root_type, ast_node, nil, false, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists end @response = selection_result[result_name] - when "UNION", "INTERFACE" - - @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false, query.ast_nodes.first, nil, nil) - @response.base_path = base_path - - @run_queue.append_step(ResolveTypeStep.new(self, @response, false)) else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end @@ -746,10 +675,7 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end set_result(selection_result, result_name, r, false, is_non_null) r - when "UNION", "INTERFACE" - response_hash = GraphQLResultHash.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) - @run_queue.append_step ResolveTypeStep.new(self, response_hash, was_scoped) - when "OBJECT" + when "OBJECT", "UNION", "INTERFACE" response_hash = GraphQLResultHash.new(self, result_name, current_type, value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) response_hash.was_scoped = was_scoped @run_queue.append_step response_hash diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 3b867bd500..c69740a6b7 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -60,6 +60,7 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @ordered_result_keys = nil @target_result = nil @was_scoped = nil + @resolve_type_result = nil @step = 0 end @@ -75,17 +76,60 @@ def inspect_step end def step_finished? - @step == 4 + @step == 6 end def value - @graphql_application_value = @runtime.schema.sync_lazy(@graphql_application_value) + if @resolve_type_result + @resolve_type_result = @runtime.schema.sync_lazy(@resolve_type_result) + else + @graphql_application_value = @runtime.schema.sync_lazy(@graphql_application_value) + end end def run_step @step += 1 case @step when 1 + if !@graphql_result_type.kind.abstract? + @step = 2 # skip + return nil + end + current_type = @graphql_result_type + value = @graphql_application_value + @resolve_type_result = begin + @runtime.resolve_type(current_type, value) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + return @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + return @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) + end + end + when 2 + if @resolve_type_result.is_a?(Array) && @resolve_type_result.length == 2 + resolved_type, resolved_value = @resolve_type_result + else + resolved_type = @resolve_type_result + resolved_value = value + end + @resolve_type_result = nil + current_type = @graphql_result_type + possible_types = @runtime.query.types.possible_types(current_type) + if !possible_types.include?(resolved_type) + field = @graphql_field + parent_type = field.owner_type + err_class = current_type::UnresolvedTypeError + type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) + @runtime.schema.type_error(type_error, @runtime.context) + @runtime.set_result(self, @result_name, nil, false, is_non_null) + nil + else + @graphql_result_type = resolved_type + end + when 3 @graphql_application_value = begin value = @graphql_application_value context = @runtime.context @@ -93,16 +137,16 @@ def run_step rescue GraphQL::ExecutionError => err err end - when 2 + when 4 @graphql_application_value = @runtime.continue_value(@graphql_application_value, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) if HALT.equal?(@graphql_application_value) - @step = 4 + @step = 6 elsif @graphql_parent @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) end # TODO Why cant this go right to the next step? nil - when 3 + when 5 @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| @ordered_result_keys ||= ordered_result_keys if is_selection_array @@ -141,7 +185,7 @@ def run_step @runtime.run_queue.append_step(selections_result) end end - when 4 + when 6 @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| # Field resolution may pause the fiber, # so it wouldn't get to the `Resolve` call that happens below. From 898ba4a03bb82e58da5df34034cd1d4c4bd8ac8e Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 26 Jun 2025 15:49:51 -0400 Subject: [PATCH 17/20] Use named states --- .../interpreter/runtime/graphql_result.rb | 140 ++++++++++-------- 1 file changed, 81 insertions(+), 59 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index c69740a6b7..aa7cef4db5 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -61,7 +61,15 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @target_result = nil @was_scoped = nil @resolve_type_result = nil - @step = 0 + if @graphql_result_type.kind.object? + @step = :wrap_application_value + else + @step = :resolve_abstract_type + end + end + + def set_step(new_step) + @step = new_step end def depth @@ -76,7 +84,7 @@ def inspect_step end def step_finished? - @step == 6 + @step == :finished end def value @@ -87,66 +95,77 @@ def value end end - def run_step - @step += 1 - case @step - when 1 - if !@graphql_result_type.kind.abstract? - @step = 2 # skip - return nil - end - current_type = @graphql_result_type - value = @graphql_application_value - @resolve_type_result = begin - @runtime.resolve_type(current_type, value) - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + def resolve_abstract_type + current_type = @graphql_result_type + value = @graphql_application_value + @resolve_type_result = begin + @runtime.resolve_type(current_type, value) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + return @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) + rescue StandardError => err + begin + @runtime.query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err return @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) - rescue StandardError => err - begin - @runtime.query.handle_or_reraise(err) - rescue GraphQL::ExecutionError => ex_err - return @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) - end - end - when 2 - if @resolve_type_result.is_a?(Array) && @resolve_type_result.length == 2 - resolved_type, resolved_value = @resolve_type_result - else - resolved_type = @resolve_type_result - resolved_value = value - end - @resolve_type_result = nil - current_type = @graphql_result_type - possible_types = @runtime.query.types.possible_types(current_type) - if !possible_types.include?(resolved_type) - field = @graphql_field - parent_type = field.owner_type - err_class = current_type::UnresolvedTypeError - type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) - @runtime.schema.type_error(type_error, @runtime.context) - @runtime.set_result(self, @result_name, nil, false, is_non_null) - nil - else - @graphql_result_type = resolved_type end - when 3 - @graphql_application_value = begin - value = @graphql_application_value - context = @runtime.context - @was_scoped ? @graphql_result_type.wrap_scoped(value, context) : @graphql_result_type.wrap(value, context) - rescue GraphQL::ExecutionError => err - err - end - when 4 + end + end + + def handle_resolved_type + if @resolve_type_result.is_a?(Array) && @resolve_type_result.length == 2 + resolved_type, resolved_value = @resolve_type_result + else + resolved_type = @resolve_type_result + resolved_value = value + end + @resolve_type_result = nil + current_type = @graphql_result_type + possible_types = @runtime.query.types.possible_types(current_type) + if !possible_types.include?(resolved_type) + field = @graphql_field + parent_type = field.owner_type + err_class = current_type::UnresolvedTypeError + type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) + @runtime.schema.type_error(type_error, @runtime.context) + @runtime.set_result(self, @result_name, nil, false, is_non_null) + nil + else + @graphql_result_type = resolved_type + end + end + + def wrap_application_value + @graphql_application_value = begin + value = @graphql_application_value + context = @runtime.context + @was_scoped ? @graphql_result_type.wrap_scoped(value, context) : @graphql_result_type.wrap(value, context) + rescue GraphQL::ExecutionError => err + err + end + @step = :handle_wrapped_application_value + @graphql_application_value + end + + def run_step + case @step + when :resolve_abstract_type + @step = :handle_resolved_type + resolve_abstract_type + when :handle_resolved_type + handle_resolved_type + wrap_application_value + when :wrap_application_value + wrap_application_value + when :handle_wrapped_application_value @graphql_application_value = @runtime.continue_value(@graphql_application_value, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) if HALT.equal?(@graphql_application_value) - @step = 6 + @step = :finished + return elsif @graphql_parent @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) end - # TODO Why cant this go right to the next step? - nil - when 5 + @step = :run_selections + when :run_selections @runtime.each_gathered_selections(self) do |gathered_selections, is_selection_array, ordered_result_keys| @ordered_result_keys ||= ordered_result_keys if is_selection_array @@ -164,7 +183,7 @@ def run_step @graphql_field) selections_result.target_result = self selections_result.ordered_result_keys = ordered_result_keys - # TODO This hash should start in step 4? + selections_result.set_step :call_each_field else selections_result = self @target_result = nil @@ -180,12 +199,12 @@ def run_step @runtime.run_queue.append_step(dir_step) elsif @target_result.nil? # TODO extract these substeps out into methods, call that method directly - run_step # Run itself again + @step = :call_each_field else @runtime.run_queue.append_step(selections_result) end end - when 6 + when :call_each_field @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| # Field resolution may pause the fiber, # so it wouldn't get to the `Resolve` call that happens below. @@ -210,10 +229,13 @@ def run_step } end end - # TODO I'm pretty sure finished_jobs/enqueued_jobs actually did nothing + if @target_result self.merge_into(@target_result) end + @step = :finished + else + raise "Invariant: invalid state for #{self.class}: #{@step}" end end From 6f6c79e03469d776b6eb7f633b9f4168381609c9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 26 Jun 2025 16:17:05 -0400 Subject: [PATCH 18/20] Move authorized into runtime state machine --- .../interpreter/runtime/graphql_result.rb | 55 +++++++++++++++++-- lib/graphql/schema/object.rb | 2 + 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index aa7cef4db5..39b7bee7f9 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -61,8 +61,9 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p @target_result = nil @was_scoped = nil @resolve_type_result = nil + @authorized_check_result = nil if @graphql_result_type.kind.object? - @step = :wrap_application_value + @step = :authorize_application_value else @step = :resolve_abstract_type end @@ -90,6 +91,12 @@ def step_finished? def value if @resolve_type_result @resolve_type_result = @runtime.schema.sync_lazy(@resolve_type_result) + elsif @authorized_check_result + @runtime.current_trace.begin_authorized(@graphql_result_type, @graphql_application_value, @runtime.context) + @runtime.current_trace.authorized_lazy(query: @runtime.query, type: @graphql_result_type, object: @graphql_application_value) do + @authorized_check_result = @runtime.schema.sync_lazy(@authorized_check_result) + @runtime.current_trace.end_authorized(@graphql_result_type, @graphql_application_value, @runtime.context, @authorized_check_result) + end else @graphql_application_value = @runtime.schema.sync_lazy(@graphql_application_value) end @@ -146,6 +153,24 @@ def wrap_application_value @graphql_application_value end + def authorize_application_value + @runtime.current_trace.begin_authorized(@graphql_result_type, @graphql_application_value, @runtime.context) + begin + @authorized_check_result = @runtime.current_trace.authorized(query: @runtime.query, type: @graphql_result_type, object: @graphql_application_value) do + begin + @graphql_result_type.authorized?(@graphql_application_value, @runtime.context) + rescue GraphQL::UnauthorizedError => err + @runtime.schema.unauthorized_object(err) + rescue StandardError => err + @runtime.query.handle_or_reraise(err) + end + end + ensure + @step = :handle_authorized_application_value + @runtime.current_trace.end_authorized(@graphql_result_type, @graphql_application_value, @runtime.context, @authorized_check_result) + end + end + def run_step case @step when :resolve_abstract_type @@ -153,9 +178,31 @@ def run_step resolve_abstract_type when :handle_resolved_type handle_resolved_type - wrap_application_value - when :wrap_application_value - wrap_application_value + authorize_application_value + when :authorize_application_value + # TODO skip if scoped + authorize_application_value + when :handle_authorized_application_value + # TODO doesn't actually support `.wrap` + if @authorized_check_result + # TODO don't call `scoped_new here` + @graphql_application_value = @graphql_result_type.scoped_new(@graphql_application_value, @runtime.context) + else + # It failed the authorization check, so go to the schema's authorized object hook + err = GraphQL::UnauthorizedError.new(object: @graphql_application_value, type: @graphql_result_type, context: @runtime.context) + # If a new value was returned, wrap that instead of the original value + begin + new_obj = @runtime.schema.unauthorized_object(err) + if new_obj + # TODO don't do this work-around + @graphql_application_value = @graphql_result_type.scoped_new(new_obj, @runtime.context) + else + @graphql_application_value = nil + end + end + end + @authorized_check_result = nil + @step = :handle_wrapped_application_value when :handle_wrapped_application_value @graphql_application_value = @runtime.continue_value(@graphql_application_value, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) if HALT.equal?(@graphql_application_value) diff --git a/lib/graphql/schema/object.rb b/lib/graphql/schema/object.rb index e1f5a99f09..ad337f21de 100644 --- a/lib/graphql/schema/object.rb +++ b/lib/graphql/schema/object.rb @@ -43,6 +43,8 @@ def wrap_scoped(object, context) scoped_new(object, context) end + # TODO Runtime calls to `.wrap` have been removed, maybe clean this up + # This is called by the runtime to return an object to call methods on. def wrap(object, context) authorized_new(object, context) From 466a80c5b267a0ff79581399b1e40cd8e8d2cfd4 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 27 Jun 2025 11:09:09 -0400 Subject: [PATCH 19/20] Move directive resolution into other steps --- lib/graphql/execution/interpreter/runtime.rb | 118 ++++++++++++------ .../interpreter/runtime/graphql_result.rb | 74 +++-------- spec/graphql/schema/directive_spec.rb | 2 +- 3 files changed, 102 insertions(+), 92 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 460c63ead1..cf7d605b4e 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -137,7 +137,7 @@ def inspect "#<#{self.class.name} response=#{@response.inspect}>" end - class DirectivesStep + class OperationDirectivesStep def initialize(runtime, object, method_to_call, directives, next_step) @runtime = runtime @object = object @@ -191,7 +191,7 @@ def run_eager runtime_state.current_result = @response next_step = if !ast_node.directives.empty? - DirectivesStep.new(self, object, :resolve, ast_node.directives, @response) + OperationDirectivesStep.new(self, object, :resolve, ast_node.directives, @response) else @response end @@ -329,7 +329,7 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s end class FieldResolveStep - def initialize(runtime, field, object, ast_node, ast_nodes, result_name, selection_result, next_selections) + def initialize(runtime, field, object, ast_node, ast_nodes, result_name, selection_result) @runtime = runtime @field = field @object = object @@ -337,16 +337,18 @@ def initialize(runtime, field, object, ast_node, ast_nodes, result_name, selecti @ast_nodes = ast_nodes @result_name = result_name @selection_result = selection_result - @next_selections = next_selections - @step = 0 + @next_selections = nil + @step = :inspect_ast end + attr_reader :selection_result + def inspect_step "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" end def step_finished? - @step == 4 + @step == :finished end def depth @@ -371,29 +373,55 @@ def value # Lazy API def run_step if @selection_result.graphql_dead - @step = 4 + @step = :finished return nil end - @step += 1 case @step - when 1 + when :inspect_ast + # Optimize for the case that field is selected only once + if @ast_nodes.nil? || @ast_nodes.size == 1 + @next_selections = @ast_node.selections + directives = @ast_node.directives + else + @next_selections = [] + directives = [] + @ast_nodes.each { |f| + @next_selections.concat(f.selections) + directives.concat(f.directives) + } + end + + if directives.any? + @step = :finished # some way to detect whether the block below is called or not + @runtime.call_method_on_directives(:resolve, @object, directives) do + @step = :load_arguments + self # TODO what kind of compatibility is possible here? + end + else + # TODO some way to continue without this step + @step = :load_arguments + end + when :load_arguments if !@field.any_arguments? @resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY if @field.extras.size == 0 @kwarg_arguments = EmptyObjects::EMPTY_HASH - @step += 1 # skip step 2 + @step = :call_field_resolver # kwargs are already ready -- they're empty + else + @step = :prepare_kwarg_arguments end nil else + @step = :prepare_kwarg_arguments @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @owner_object) do |resolved_arguments| @resolved_arguments = resolved_arguments end end - when 2 + when :prepare_kwarg_arguments if @resolved_arguments.is_a?(GraphQL::ExecutionError) || @resolved_arguments.is_a?(GraphQL::UnauthorizedError) return_type_non_null = @field.type.non_null? @runtime.continue_value(@resolved_arguments, @field, return_type_non_null, @ast_node, @result_name, @selection_result) - @step = 4 + @step = :finished return end @@ -411,7 +439,7 @@ def run_step @field.extras.each do |extra| case extra when :ast_node - extra_args[:ast_node] = ast_node + extra_args[:ast_node] = @ast_node when :execution_errors extra_args[:execution_errors] = ExecutionErrors.new(@runtime.context, @ast_node, @runtime.current_path) when :path @@ -446,7 +474,9 @@ def run_step end @resolved_arguments.keyword_arguments end - when 3 + @step = :call_field_resolver + nil + when :call_field_resolver # if !directives.empty? # This might be executed in a different context; reset this info runtime_state = @runtime.get_current_runtime_state @@ -473,25 +503,29 @@ def run_step end end @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) + @step = :handle_resolved_value @result = app_result - when 4 + when :handle_resolved_value runtime_state = @runtime.get_current_runtime_state runtime_state.current_field = @field runtime_state.current_arguments = @resolved_arguments runtime_state.current_result_name = @result_name runtime_state.current_result = @selection_result return_type = @field.type - continue_value = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) + @result = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) - if HALT != continue_value + if !HALT.equal?(@result) runtime_state = @runtime.get_current_runtime_state was_scoped = runtime_state.was_authorized_by_scope_items runtime_state.was_authorized_by_scope_items = nil - @runtime.continue_field(continue_value, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) + @runtime.continue_field(@result, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) else nil end + @step = :finished nil + else + raise "Invariant: unexpected #{self.class} step: #{@step.inspect} (#{inspect_step})" end end end @@ -689,31 +723,21 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end end - class ListItemDirectivesStep < DirectivesStep - def run_step - runtime_state = @runtime.get_current_runtime_state - runtime_state.current_result_name = @next_step.index - runtime_state.current_result = @next_step.list_result - super - end - end - class ListItemStep - def initialize(runtime, list_result, index, item_value) + def initialize(runtime, list_result, index, item_value, directives) @runtime = runtime @list_result = list_result @index = index @item_value = item_value - @step_finished = false + @directives = directives @depth = nil + @step = :check_directives end def step_finished? - @step_finished + @step == :finished end - attr_reader :index, :list_result - def inspect_step "#{self.class.name.split("::").last}##{object_id}@#{@index}" end @@ -737,17 +761,37 @@ def depth end def run_step - if @runtime.lazy?(@item_value) - @item_value - else + case @step + when :check_directives + if @directives.any? + @step = :finished + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_result_name = @index + runtime_state.current_result = @list_result + @runtime.call_method_on_directives(:resolve_each, @list_result.graphql_application_value, @directives) do + @step = :check_lazy_item + end + else + @step = :check_lazy_item + end + when :check_lazy_item + @step = :handle_item + if @runtime.lazy?(@item_value) + @item_value + else + nil + end + when :handle_item item_type = @list_result.graphql_result_type.of_type item_type_non_null = item_type.non_null? continue_value = @runtime.continue_value(@item_value, @list_result.graphql_field, item_type_non_null, @list_result.ast_node, @index, @list_result) - if HALT != continue_value + if !HALT.equal?(continue_value) was_scoped = false # TODO!! @runtime.continue_field(continue_value, @list_result.graphql_field, item_type, @list_result.ast_node, @list_result.graphql_selections, false, @list_result.graphql_arguments, @index, @list_result, was_scoped, @runtime.get_current_runtime_state) end - @step_finished = true + @step = :finished + else + raise "Invariant: unexpected step: #{inspect_step}" end end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 39b7bee7f9..74e5ea57d0 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -230,26 +230,31 @@ def run_step @graphql_field) selections_result.target_result = self selections_result.ordered_result_keys = ordered_result_keys - selections_result.set_step :call_each_field + selections_result.set_step :run_selection_directives + @runtime.run_queue.append_step(selections_result) + @step = :finished # Continuing from others now -- could actually reuse this instance for the first one tho else selections_result = self @target_result = nil @graphql_selections = gathered_selections + # TODO extract these substeps out into methods, call that method directly + @step = :run_selection_directives end runtime_state = @runtime.get_current_runtime_state runtime_state.current_result_name = nil runtime_state.current_result = selections_result - # This is a less-frequent case; use a fast check since it's often not there. - if (directives = gathered_selections[:graphql_directives]) - gathered_selections.delete(:graphql_directives) - dir_step = DirectivesStep.new(@runtime, selections_result.graphql_application_value, :resolve, directives, selections_result) - @runtime.run_queue.append_step(dir_step) - elsif @target_result.nil? - # TODO extract these substeps out into methods, call that method directly + nil + end + when :run_selection_directives + if (directives = @graphql_selections[:graphql_directives]) + @graphql_selections.delete(:graphql_directives) + @step = :finished # some way to detect whether the block below is called or not + @runtime.call_method_on_directives(:resolve, @graphql_application_value, directives) do @step = :call_each_field - else - @runtime.run_queue.append_step(selections_result) end + else + # TODO some way to continue without this step + @step = :call_each_field end when :call_each_field @graphql_selections.each do |result_name, field_ast_nodes_or_ast_node| @@ -299,19 +304,6 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab ast_node = field_ast_nodes_or_ast_node end - # Optimize for the case that field is selected only once - if field_ast_nodes.nil? || field_ast_nodes.size == 1 - next_selections = ast_node.selections - directives = ast_node.directives - else - next_selections = [] - directives = [] - field_ast_nodes.each { |f| - next_selections.concat(f.selections) - directives.concat(f.directives) - } - end - field_name = ast_node.name owner_type = @graphql_result_type field_defn = @runtime.query.types.field(owner_type, field_name) @@ -321,31 +313,8 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab owner_object = field_defn.owner.wrap(owner_object, @runtime.context) end - # Optimize for the case that field is selected only once - if field_ast_nodes.nil? || field_ast_nodes.size == 1 - next_selections = ast_node.selections - directives = ast_node.directives - else - next_selections = [] - directives = [] - field_ast_nodes.each { |f| - next_selections.concat(f.selections) - directives.concat(f.directives) - } - end - - resolve_field_step = FieldResolveStep.new(@runtime, field_defn, owner_object, ast_node, field_ast_nodes, result_name, self, next_selections) - next_step = if !directives.empty? - # TODO this will get clobbered by other steps in the queue - # runtime_state.current_field = field_defn - # runtime_state.current_arguments = resolved_arguments - # runtime_state.current_result_name = result_name - # runtime_state.current_result = self - DirectivesStep.new(@runtime, owner_object, :resolve, directives, resolve_field_step) - else - resolve_field_step - end - @runtime.run_queue.append_step(next_step) + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, owner_object, ast_node, field_ast_nodes, result_name, self) + @runtime.run_queue.append_step(resolve_field_step) end attr_accessor :ordered_result_keys, :target_result, :was_scoped @@ -488,13 +457,10 @@ def run_step @runtime, self, this_idx, - inner_value + inner_value, + dirs, ) - @runtime.run_queue.append_step(if make_dir_step - ListItemDirectivesStep.new(@runtime, @graphql_application_value, :resolve_each, dirs, list_item_step) - else - list_item_step - end) + @runtime.run_queue.append_step(list_item_step) end self diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index bac6e4cb19..f423f20963 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -342,7 +342,7 @@ def self.resolve(object, arguments, context) # Previously, `yield` returned a finished value. But it doesn't anymore. runtime_instance = context.namespace(:interpreter_runtime)[:runtime] runtime_instance.run_queue.complete - value.values.compact! + value.selection_result.values.compact! value end end From bdc1b08abf7ee0ac98a260a905eb1f4d86123574 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 27 Jun 2025 16:00:45 -0400 Subject: [PATCH 20/20] Run dataloader if arguments need it --- lib/graphql/execution/interpreter/runtime.rb | 27 ++++++++++++------- .../interpreter/runtime/graphql_result.rb | 15 +++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index cf7d605b4e..3b86cff011 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -329,10 +329,13 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s end class FieldResolveStep - def initialize(runtime, field, object, ast_node, ast_nodes, result_name, selection_result) + def initialize(runtime, field, ast_node, ast_nodes, result_name, selection_result) @runtime = runtime @field = field - @object = object + @object = selection_result.graphql_application_value + if @field.dynamic_introspection + @object = field.owner.wrap(@object, @runtime.context) + end @ast_node = ast_node @ast_nodes = ast_nodes @result_name = result_name @@ -413,11 +416,17 @@ def run_step nil else @step = :prepare_kwarg_arguments - @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @owner_object) do |resolved_arguments| - @resolved_arguments = resolved_arguments + @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| + @result = resolved_arguments end + @result end when :prepare_kwarg_arguments + if @resolved_arguments.nil? && @result.nil? + @runtime.dataloader.run + end + @resolved_arguments ||= @result + @result = nil if @resolved_arguments.is_a?(GraphQL::ExecutionError) || @resolved_arguments.is_a?(GraphQL::UnauthorizedError) return_type_non_null = @field.type.non_null? @runtime.continue_value(@resolved_arguments, @field, return_type_non_null, @ast_node, @result_name, @selection_result) @@ -724,13 +733,11 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end class ListItemStep - def initialize(runtime, list_result, index, item_value, directives) + def initialize(runtime, list_result, index, item_value) @runtime = runtime @list_result = list_result @index = index @item_value = item_value - @directives = directives - @depth = nil @step = :check_directives end @@ -757,18 +764,18 @@ def value # Lazy API end def depth - @depth ||= @list_result.depth + 1 + @list_result.depth + 1 end def run_step case @step when :check_directives - if @directives.any? + if (dirs = @list_result.ast_node.directives).any? @step = :finished runtime_state = @runtime.get_current_runtime_state runtime_state.current_result_name = @index runtime_state.current_result = @list_result - @runtime.call_method_on_directives(:resolve_each, @list_result.graphql_application_value, @directives) do + @runtime.call_method_on_directives(:resolve_each, @list_result.graphql_application_value, dirs) do @step = :check_lazy_item end else diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 74e5ea57d0..034cd6c499 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -293,9 +293,8 @@ def run_step def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disable Metrics/ParameterLists return if @graphql_dead - # As a performance optimization, the hash key will be a `Node` if - # there's only one selection of the field. But if there are multiple - # selections of the field, it will be an Array of nodes + + # Don't create the list of nodes if there's only one node if field_ast_nodes_or_ast_node.is_a?(Array) field_ast_nodes = field_ast_nodes_or_ast_node ast_node = field_ast_nodes.first @@ -308,12 +307,7 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab owner_type = @graphql_result_type field_defn = @runtime.query.types.field(owner_type, field_name) - owner_object = @graphql_application_value - if field_defn.dynamic_introspection - owner_object = field_defn.owner.wrap(owner_object, @runtime.context) - end - - resolve_field_step = FieldResolveStep.new(@runtime, field_defn, owner_object, ast_node, field_ast_nodes, result_name, self) + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, ast_node, field_ast_nodes, result_name, self) @runtime.run_queue.append_step(resolve_field_step) end @@ -445,8 +439,6 @@ def run_step # This is true for objects, unions, and interfaces # use_dataloader_job = !inner_type.unwrap.kind.input? idx = nil - dirs = ast_node.directives - make_dir_step = !dirs.empty? list_value = begin begin @graphql_application_value.each do |inner_value| @@ -458,7 +450,6 @@ def run_step self, this_idx, inner_value, - dirs, ) @runtime.run_queue.append_step(list_item_step) end