From 1fdc9cd52bec9496a9e9af41229d4cce23d487a9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 19 Jun 2025 16:43:08 -0400 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 From 7f24a798c74a61eb02d6a44783d045eec57a29ee Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 3 Jul 2025 09:29:56 -0400 Subject: [PATCH 21/34] Start working on dataloader compat --- lib/graphql/execution/lazy.rb | 5 -- spec/graphql/dataloader_spec.rb | 107 ++++++++++++++++---------------- 2 files changed, 54 insertions(+), 58 deletions(-) diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb index 41849f5f50..d2f72e6e3d 100644 --- a/lib/graphql/execution/lazy.rb +++ b/lib/graphql/execution/lazy.rb @@ -59,11 +59,6 @@ def self.all(lazies) lazies.map { |l| l.is_a?(Lazy) ? l.value : l } } end - - # This can be used for fields which _had no_ lazy results - # @api private - NullResult = Lazy.new(){} - NullResult.value end end end diff --git a/spec/graphql/dataloader_spec.rb b/spec/graphql/dataloader_spec.rb index 6ec6c346a2..c25d474707 100644 --- a/spec/graphql/dataloader_spec.rb +++ b/spec/graphql/dataloader_spec.rb @@ -626,6 +626,7 @@ def self.included(child_class) assert_equal({"setCache" => "Salad", "getCache" => "1"}, res["data"]) end + focus it "batch-loads" do res = schema.execute <<-GRAPHQL { @@ -1226,59 +1227,59 @@ def make_schema_from(schema) include DataloaderAssertions - if RUBY_VERSION >= "3.1.1" - require "async" - describe "AsyncDataloader" do - def make_schema_from(schema) - Class.new(schema) { - use GraphQL::Dataloader::AsyncDataloader - } - end - - include DataloaderAssertions - end - end - - if Fiber.respond_to?(:scheduler) - describe "nonblocking: true" do - def make_schema_from(schema) - Class.new(schema) do - use GraphQL::Dataloader, nonblocking: true - end - end - - before do - Fiber.set_scheduler(::DummyScheduler.new) - end - - after do - Fiber.set_scheduler(nil) - end - - include DataloaderAssertions - end - - if RUBY_ENGINE == "ruby" && !ENV["GITHUB_ACTIONS"] - describe "nonblocking: true with libev" do - require "libev_scheduler" - def make_schema_from(schema) - Class.new(schema) do - use GraphQL::Dataloader, nonblocking: true - end - end - - before do - Fiber.set_scheduler(Libev::Scheduler.new) - end - - after do - Fiber.set_scheduler(nil) - end - - include DataloaderAssertions - end - end - end + # if RUBY_VERSION >= "3.1.1" + # require "async" + # describe "AsyncDataloader" do + # def make_schema_from(schema) + # Class.new(schema) { + # use GraphQL::Dataloader::AsyncDataloader + # } + # end + + # include DataloaderAssertions + # end + # end + + # if Fiber.respond_to?(:scheduler) + # describe "nonblocking: true" do + # def make_schema_from(schema) + # Class.new(schema) do + # use GraphQL::Dataloader, nonblocking: true + # end + # end + + # before do + # Fiber.set_scheduler(::DummyScheduler.new) + # end + + # after do + # Fiber.set_scheduler(nil) + # end + + # include DataloaderAssertions + # end + + # if RUBY_ENGINE == "ruby" && !ENV["GITHUB_ACTIONS"] + # describe "nonblocking: true with libev" do + # require "libev_scheduler" + # def make_schema_from(schema) + # Class.new(schema) do + # use GraphQL::Dataloader, nonblocking: true + # end + # end + + # before do + # Fiber.set_scheduler(Libev::Scheduler.new) + # end + + # after do + # Fiber.set_scheduler(nil) + # end + + # include DataloaderAssertions + # end + # end + # end describe "example from #3314" do module Example From 96e5d1cbfcc32ccca39bd36df57df2fcdfd158e7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 8 Jul 2025 09:51:03 -0400 Subject: [PATCH 22/34] Share a run queue within a multiplex; run steps inside a dataloader job --- lib/graphql/execution/interpreter.rb | 9 +++-- lib/graphql/execution/interpreter/runtime.rb | 33 ++++++++++--------- .../interpreter/runtime/graphql_result.rb | 2 ++ lib/graphql/query/partial.rb | 1 - 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index ba0b94b2aa..31b2b6e237 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -39,6 +39,7 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl multiplex = Execution::Multiplex.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) trace = multiplex.current_trace Fiber[:__graphql_current_multiplex] = multiplex + run_queue = nil trace.execute_multiplex(multiplex: multiplex) do schema = multiplex.schema queries = multiplex.queries @@ -73,7 +74,8 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl # Although queries in a multiplex _share_ an Interpreter instance, # they also have another item of state, which is private to that query # in particular, assign it here: - runtime = Runtime.new(query: query, lazies_at_depth: lazies_at_depth) + runtime = Runtime.new(query: query, lazies_at_depth: lazies_at_depth, run_queue: run_queue) + run_queue ||= runtime.run_queue query.context.namespace(:interpreter_runtime)[:runtime] = runtime query.current_trace.execute_query(query: query) do @@ -88,8 +90,12 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl } end + multiplex.dataloader.append_job { + run_queue&.complete # can be null if errored + } multiplex.dataloader.run + # Then, work through lazy results in a breadth-first way multiplex.dataloader.append_job { query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil @@ -122,7 +128,6 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl end result["data"] = query.context.namespace(:interpreter_runtime)[:runtime].final_result - result end if query.context.namespace?(:__query_result_extensions__) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 3b86cff011..c38582edee 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -55,17 +55,21 @@ def complete(eager: false) @current_flush = [] steps_to_rerun_after_lazy = [] while fl.any? - while (step = fl.shift) - step_finished = 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 - steps_to_rerun_after_lazy << step - step_finished = true # we'll come back around to it + while (next_step = fl.shift) + next_step.tap do |step| + @dataloader.append_job do + step_finished = 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 + steps_to_rerun_after_lazy << step + step_finished = true # we'll come back around to it + end + end end end @@ -81,9 +85,9 @@ def complete(eager: false) fl.concat(@current_flush) @current_flush.clear else + @dataloader.run 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 @@ -106,7 +110,7 @@ def complete(eager: false) attr_accessor :run_queue - def initialize(query:, lazies_at_depth:) + def initialize(query:, lazies_at_depth:, run_queue: nil) @query = query @current_trace = query.current_trace @dataloader = query.multiplex.dataloader @@ -125,7 +129,7 @@ def initialize(query:, lazies_at_depth:) end # { Class => Boolean } @lazy_cache = {}.compare_by_identity - @run_queue = RunQueue.new(runtime: self) + @run_queue = run_queue || RunQueue.new(runtime: self) end def final_result @@ -236,7 +240,6 @@ def run_eager else raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" 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 034cd6c499..a96ff2ae13 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -48,6 +48,8 @@ def build_path(path_array) attr_reader :graphql_parent, :graphql_result_name, :graphql_is_non_null_in_parent, :graphql_application_value, :graphql_result_type, :graphql_selections, :graphql_is_eager, :ast_node, :graphql_arguments, :graphql_field + attr_reader :step + # @return [Hash] Plain-Ruby result data (`@graphql_metadata` contains Result wrapper objects) attr_accessor :graphql_result_data end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 31bd6abff6..c0e8428353 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -123,7 +123,6 @@ def selected_operation_name def set_type_info_from_path selections = [@query.selected_operation] type = @query.root_type - parent_type = nil field_defn = nil @path.each do |name_in_doc| From 46044fae2e247792956a7ba18dcf4931305718f5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 8 Jul 2025 10:43:45 -0400 Subject: [PATCH 23/34] Support appending callables directly to dataloader --- lib/graphql/dataloader.rb | 4 +- lib/graphql/dataloader/null_dataloader.rb | 4 +- lib/graphql/execution/interpreter/runtime.rb | 246 ++---------------- .../interpreter/runtime/field_resolve_step.rb | 244 +++++++++++++++++ .../interpreter/runtime/graphql_result.rb | 1 + .../execution/interpreter/runtime/step.rb | 24 ++ lib/graphql/schema/build_from_definition.rb | 3 + 7 files changed, 292 insertions(+), 234 deletions(-) create mode 100644 lib/graphql/execution/interpreter/runtime/field_resolve_step.rb create mode 100644 lib/graphql/execution/interpreter/runtime/step.rb diff --git a/lib/graphql/dataloader.rb b/lib/graphql/dataloader.rb index 92ddac4f6c..d78b3e354e 100644 --- a/lib/graphql/dataloader.rb +++ b/lib/graphql/dataloader.rb @@ -140,10 +140,10 @@ def yield(source = Fiber[:__graphql_current_dataloader_source]) end # @api private Nothing to see here - def append_job(&job) + def append_job(callable = nil, &job) # Given a block, queue it up to be worked through when `#run` is called. # (If the dataloader is already running, than a Fiber will pick this up later.) - @pending_jobs.push(job) + @pending_jobs.push(callable || job) nil end diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index 7fd222d7fe..3e431c1086 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -18,8 +18,8 @@ def yield(_source) raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." end - def append_job - yield + def append_job(callable = nil) + callable ? callable.call : yield nil end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index c38582edee..9b5ae12dbf 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -1,4 +1,6 @@ # frozen_string_literal: true +require "graphql/execution/interpreter/runtime/step" +require "graphql/execution/interpreter/runtime/field_resolve_step" require "graphql/execution/interpreter/runtime/graphql_result" ##### @@ -47,31 +49,18 @@ def append_step(step) @current_flush << step end + attr_reader :steps_to_rerun_after_lazy + def complete(eager: false) # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] prev_eagerly = @running_eagerly @running_eagerly = eager while (fl = @current_flush) && fl.any? @current_flush = [] - steps_to_rerun_after_lazy = [] + @steps_to_rerun_after_lazy = [] while fl.any? while (next_step = fl.shift) - next_step.tap do |step| - @dataloader.append_job do - step_finished = 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 - steps_to_rerun_after_lazy << step - step_finished = true # we'll come back around to it - end - end - end - end + @dataloader.append_job(next_step) if @running_eagerly && @current_flush.any? # This is for mutations. If a mutation parent field enqueues any child fields, @@ -86,8 +75,8 @@ def complete(eager: false) @current_flush.clear else @dataloader.run - fl.concat(steps_to_rerun_after_lazy) - steps_to_rerun_after_lazy.clear + fl.concat(@steps_to_rerun_after_lazy) + @steps_to_rerun_after_lazy.clear Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) end end @@ -110,6 +99,10 @@ def complete(eager: false) attr_accessor :run_queue + def steps_to_rerun_after_lazy # TODO fix this jank + @run_queue.steps_to_rerun_after_lazy + end + def initialize(query:, lazies_at_depth:, run_queue: nil) @query = query @current_trace = query.current_trace @@ -142,6 +135,8 @@ def inspect end class OperationDirectivesStep + include Runtime::Step + def initialize(runtime, object, method_to_call, directives, next_step) @runtime = runtime @object = object @@ -331,217 +326,6 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run, s selections_to_run || selections_by_name end - class FieldResolveStep - def initialize(runtime, field, ast_node, ast_nodes, result_name, selection_result) - @runtime = runtime - @field = field - @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 - @selection_result = selection_result - @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 == :finished - end - - def depth - @selection_result.depth + 1 - end - - attr_accessor :result - - def value # Lazy API - @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 = :finished - return nil - end - case @step - 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 = :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, @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) - @step = :finished - 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 - @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 - 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 - @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) - @step = :handle_resolved_value - @result = app_result - 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 - @result = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) - - 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(@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 - def set_result(selection_result, result_name, value, is_child_result, is_non_null) if !selection_result.graphql_dead if value.nil? && is_non_null @@ -736,6 +520,8 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non end class ListItemStep + include Runtime::Step + def initialize(runtime, list_result, index, item_value) @runtime = runtime @list_result = list_result diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb new file mode 100644 index 0000000000..05dbc5de1f --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + class Runtime + class FieldResolveStep + include Runtime::Step + + def initialize(runtime, field, ast_node, ast_nodes, result_name, selection_result) + @runtime = runtime + @field = field + @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 + @selection_result = selection_result + @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 == :finished + end + + def depth + @selection_result.depth + 1 + end + + attr_accessor :result + + def value # Lazy API + @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 = :finished + return nil + end + case @step + when :inspect_ast + inspect_ast + when :load_arguments + load_arguments + when :prepare_kwarg_arguments + prepare_kwarg_arguments + when :call_field_resolver + call_field_resolver + when :handle_resolved_value + handle_resolved_value + else + raise "Invariant: unexpected #{self.class} step: #{@step.inspect} (#{inspect_step})" + end + end + + private + + def 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 + end + + def load_arguments + if !@field.any_arguments? + @resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY + if @field.extras.size == 0 + @kwarg_arguments = EmptyObjects::EMPTY_HASH + @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, @object) do |resolved_arguments| + @result = resolved_arguments + end + @result + end + end + + def 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) + @step = :finished + 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 + @step = :call_field_resolver + nil + end + + def call_field_resolver + # 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 + @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) + @step = :handle_resolved_value + @result = app_result + end + + def 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 + @result = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) + + 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(@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 + end + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index a96ff2ae13..81b8a03a78 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -5,6 +5,7 @@ module Execution class Interpreter class Runtime module GraphQLResult + include Runtime::Step 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 diff --git a/lib/graphql/execution/interpreter/runtime/step.rb b/lib/graphql/execution/interpreter/runtime/step.rb new file mode 100644 index 0000000000..cfdfca6674 --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/step.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + class Runtime + module Step + def call + step_finished = false + while !step_finished + step_result = run_step + step_finished = step_finished? + if !step_finished && @runtime.lazy?(step_result) + @runtime.lazies_at_depth[depth] << self + @runtime.steps_to_rerun_after_lazy << self + step_finished = true # we'll come back around to it + end + end + end + end + end + end + end +end diff --git a/lib/graphql/schema/build_from_definition.rb b/lib/graphql/schema/build_from_definition.rb index 8918818ae8..5a811334f7 100644 --- a/lib/graphql/schema/build_from_definition.rb +++ b/lib/graphql/schema/build_from_definition.rb @@ -402,6 +402,9 @@ def build_object_type(object_type_definition, type_resolver, base_type) builder = self Class.new(base_type) do + def self.name + "GraphQL::Schema::BuildFromDefinition::#{graphql_name}" + end graphql_name(object_type_definition.name) description(object_type_definition.description) ast_node(object_type_definition) From e3cf018e37441190bfd53895e10dafcf3f32335f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 8 Jul 2025 11:41:05 -0400 Subject: [PATCH 24/34] Improve eager continuation in FieldResolveStep, improve dataloader batching --- .../interpreter/runtime/field_resolve_step.rb | 26 +++++++++---------- spec/graphql/dataloader_spec.rb | 1 - 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 05dbc5de1f..76d5122df6 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -96,8 +96,7 @@ def inspect_ast self # TODO what kind of compatibility is possible here? end else - # TODO some way to continue without this step - @step = :load_arguments + load_arguments end end @@ -106,26 +105,28 @@ def load_arguments @resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY if @field.extras.size == 0 @kwarg_arguments = EmptyObjects::EMPTY_HASH - @step = :call_field_resolver # kwargs are already ready -- they're empty + call_field_resolver else - @step = :prepare_kwarg_arguments + prepare_kwarg_arguments end - nil else @step = :prepare_kwarg_arguments - @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| - @result = resolved_arguments + @result = nil + dataload_result = @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| + @result = @resolved_arguments = resolved_arguments + prepare_kwarg_arguments end - @result + # @result may have been assign by nested calls in the block, thru `prepare_kwarg_arguments`. + # Don't clobber it in that case. + @result ||= dataload_result end end def prepare_kwarg_arguments - if @resolved_arguments.nil? && @result.nil? + if @resolved_arguments.nil? @runtime.dataloader.run end - @resolved_arguments ||= @result - @result = nil + @result = nil # TODO is this still necessary? 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) @@ -182,8 +183,7 @@ def prepare_kwarg_arguments end @resolved_arguments.keyword_arguments end - @step = :call_field_resolver - nil + call_field_resolver end def call_field_resolver diff --git a/spec/graphql/dataloader_spec.rb b/spec/graphql/dataloader_spec.rb index c25d474707..ce189aa4e2 100644 --- a/spec/graphql/dataloader_spec.rb +++ b/spec/graphql/dataloader_spec.rb @@ -626,7 +626,6 @@ def self.included(child_class) assert_equal({"setCache" => "Salad", "getCache" => "1"}, res["data"]) end - focus it "batch-loads" do res = schema.execute <<-GRAPHQL { From b1a8c744575cdb0c85c0e89af4b5f67c3cf6b96b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 8 Jul 2025 11:54:07 -0400 Subject: [PATCH 25/34] Rework to support lazy arguments --- .../interpreter/runtime/field_resolve_step.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 76d5122df6..6dd4cc7e07 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -112,20 +112,24 @@ def load_arguments else @step = :prepare_kwarg_arguments @result = nil - dataload_result = @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| - @result = @resolved_arguments = resolved_arguments - prepare_kwarg_arguments + dataloader_paused = false + @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| + @result = resolved_arguments + if dataloader_paused + prepare_kwarg_arguments + end end - # @result may have been assign by nested calls in the block, thru `prepare_kwarg_arguments`. - # Don't clobber it in that case. - @result ||= dataload_result + dataloader_paused = true + @result end end def prepare_kwarg_arguments - if @resolved_arguments.nil? + # @resolved_arguments may have been eagerly set if there aren't actually any args + if @resolved_arguments.nil? && @result.nil? @runtime.dataloader.run end + @resolved_arguments ||= @result @result = nil # TODO is this still necessary? if @resolved_arguments.is_a?(GraphQL::ExecutionError) || @resolved_arguments.is_a?(GraphQL::UnauthorizedError) return_type_non_null = @field.type.non_null? From 5be503236f034abd9b74ea9fabe76f6810e21da7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 06:42:34 -0400 Subject: [PATCH 26/34] Improve current runtime state --- lib/graphql/execution/interpreter/runtime.rb | 67 +++++-------------- .../interpreter/runtime/field_resolve_step.rb | 16 +++-- .../interpreter/runtime/graphql_result.rb | 8 +++ .../interpreter/runtime/run_queue.rb | 58 ++++++++++++++++ .../execution/interpreter/runtime/step.rb | 4 ++ 5 files changed, 98 insertions(+), 55 deletions(-) create mode 100644 lib/graphql/execution/interpreter/runtime/run_queue.rb diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 9b5ae12dbf..febead864f 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -2,6 +2,7 @@ require "graphql/execution/interpreter/runtime/step" require "graphql/execution/interpreter/runtime/field_resolve_step" require "graphql/execution/interpreter/runtime/graphql_result" +require "graphql/execution/interpreter/runtime/run_queue" ##### # Next thoughts @@ -36,56 +37,6 @@ def current_object :current_arguments, :current_field, :was_authorized_by_scope_items end - class RunQueue - def initialize(runtime:) - @runtime = runtime - @current_flush = [] - @dataloader = runtime.dataloader - @lazies_at_depth = runtime.lazies_at_depth - @running_eagerly = false - end - - def append_step(step) - @current_flush << step - end - - attr_reader :steps_to_rerun_after_lazy - - def complete(eager: false) - # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] - prev_eagerly = @running_eagerly - @running_eagerly = eager - while (fl = @current_flush) && fl.any? - @current_flush = [] - @steps_to_rerun_after_lazy = [] - while fl.any? - while (next_step = fl.shift) - @dataloader.append_job(next_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 - - if @current_flush.any? - fl.concat(@current_flush) - @current_flush.clear - else - @dataloader.run - fl.concat(@steps_to_rerun_after_lazy) - @steps_to_rerun_after_lazy.clear - Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) - end - end - end - ensure - @running_eagerly = prev_eagerly - end - end - # @return [GraphQL::Query] attr_reader :query @@ -156,6 +107,14 @@ def step_finished? true end + def current_result + @next_step.current_result + end + + def current_result_name + @next_step.current_result_name + end + def inspect_step "#{self.class.name.split("::").last}##{object_id}(#{@directives ? @directives.map(&:name).join(", ") : nil}) => #{@next_step.inspect_step}" end @@ -530,6 +489,14 @@ def initialize(runtime, list_result, index, item_value) @step = :check_directives end + def current_result + @list_result + end + + def current_result_name + @index + end + def step_finished? @step == :finished end diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 6dd4cc7e07..6447a561fc 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -24,6 +24,14 @@ def initialize(runtime, field, ast_node, ast_nodes, result_name, selection_resul attr_reader :selection_result + def current_result + @selection_result + end + + def current_result_name + @result_name + end + def inspect_step "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" end @@ -40,6 +48,9 @@ def depth def value # Lazy API @result = begin + rs = @runtime.get_current_runtime_state + rs.current_result = current_result + rs.current_result_name = current_result_name @runtime.schema.sync_lazy(@result) rescue GraphQL::ExecutionError => err err @@ -222,11 +233,6 @@ def call_field_resolver end def 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 @result = @runtime.continue_value(@result, @field, return_type.non_null?, @ast_node, @result_name, @selection_result) diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 81b8a03a78..7277eee881 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -30,6 +30,14 @@ def initialize(runtime_instance, result_name, result_type, application_value, pa # TODO test full path in Partial attr_writer :base_path + def current_result + self + end + + def current_result_name + nil + end + def path @path ||= build_path([]) end diff --git a/lib/graphql/execution/interpreter/runtime/run_queue.rb b/lib/graphql/execution/interpreter/runtime/run_queue.rb new file mode 100644 index 0000000000..5d74a2e8d8 --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/run_queue.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class Interpreter + class Runtime + class RunQueue + def initialize(runtime:) + @runtime = runtime + @current_flush = [] + @dataloader = runtime.dataloader + @lazies_at_depth = runtime.lazies_at_depth + @running_eagerly = false + end + + def append_step(step) + @current_flush << step + end + + attr_reader :steps_to_rerun_after_lazy + + def complete(eager: false) + # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] + prev_eagerly = @running_eagerly + @running_eagerly = eager + while (fl = @current_flush) && fl.any? + @current_flush = [] + @steps_to_rerun_after_lazy = [] + while fl.any? + while (next_step = fl.shift) + @dataloader.append_job(next_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 + + if @current_flush.any? + fl.concat(@current_flush) + @current_flush.clear + else + @dataloader.run + fl.concat(@steps_to_rerun_after_lazy) + @steps_to_rerun_after_lazy.clear + Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + end + end + end + ensure + @running_eagerly = prev_eagerly + end + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime/step.rb b/lib/graphql/execution/interpreter/runtime/step.rb index cfdfca6674..eb4b3f47f1 100644 --- a/lib/graphql/execution/interpreter/runtime/step.rb +++ b/lib/graphql/execution/interpreter/runtime/step.rb @@ -8,6 +8,10 @@ module Step def call step_finished = false while !step_finished + # TODO use a `current_step`-type thing for this + rs = @runtime.get_current_runtime_state + rs.current_result = self.current_result + rs.current_result_name = self.current_result_name step_result = run_step step_finished = step_finished? if !step_finished && @runtime.lazy?(step_result) From f29cac42becd5c67f77e05130142abd3fda2631c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 06:48:18 -0400 Subject: [PATCH 27/34] Catch unauthorized errors from field resolution --- lib/graphql/execution/interpreter/runtime/field_resolve_step.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 6447a561fc..56374f1d7a 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -54,6 +54,8 @@ def value # Lazy API @runtime.schema.sync_lazy(@result) rescue GraphQL::ExecutionError => err err + rescue UnauthorizedError => err + err rescue StandardError => err begin @runtime.query.handle_or_reraise(err) From c4ebc03442fe5849e721dfcbba6d74c8c594e1e0 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 07:18:09 -0400 Subject: [PATCH 28/34] Support list scoping --- benchmark/batch_loading.rb | 4 +--- lib/graphql/execution/interpreter/runtime.rb | 8 +++++-- .../interpreter/runtime/field_resolve_step.rb | 5 ++-- .../interpreter/runtime/graphql_result.rb | 24 +++++++------------ .../execution/interpreter/runtime/step.rb | 3 +++ lib/graphql/pagination/connection.rb | 2 +- lib/graphql/schema/field/scope_extension.rb | 3 ++- .../types/relay/connection_behaviors.rb | 4 ++-- lib/graphql/types/relay/edge_behaviors.rb | 2 +- spec/graphql/schema/member/scoped_spec.rb | 2 +- 10 files changed, 29 insertions(+), 28 deletions(-) diff --git a/benchmark/batch_loading.rb b/benchmark/batch_loading.rb index b2d3f2d22c..eda04095aa 100644 --- a/benchmark/batch_loading.rb +++ b/benchmark/batch_loading.rb @@ -63,9 +63,7 @@ def initialize(options = {column: :id}) def fetch(keys) keys.map { |key| - d = GraphQLBatchSchema::DATA.find { |d| d[@column] == key } - # p [key, @column, d] - d + GraphQLBatchSchema::DATA.find { |d| d[@column] == key } } end end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index febead864f..e84d27f43c 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -26,6 +26,7 @@ def initialize @current_arguments = nil @current_result_name = nil @current_result = nil + @current_step = nil @was_authorized_by_scope_items = nil end @@ -34,7 +35,7 @@ def current_object end attr_accessor :current_result, :current_result_name, - :current_arguments, :current_field, :was_authorized_by_scope_items + :current_arguments, :current_field, :was_authorized_by_scope_items, :current_step end # @return [GraphQL::Query] @@ -166,6 +167,7 @@ def run_eager selection_result.ordered_result_keys = [result_name] runtime_state.current_result = selection_result runtime_state.current_result_name = result_name + runtime_state.current_step = selection_result 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, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists @@ -470,6 +472,7 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non @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) + response_list.was_scoped = was_scoped set_result(selection_result, result_name, response_list, true, is_non_null) @run_queue.append_step(response_list) response_list # TODO smell this is used because its returned by `yield` inside a directive @@ -549,7 +552,7 @@ def run_step 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.equal?(continue_value) - was_scoped = false # TODO!! + was_scoped = @list_result.was_scoped @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 @@ -644,6 +647,7 @@ def after_lazy(lazy_obj, field:, owner_object:, arguments:, ast_node:, result:, runtime_state.current_arguments = arguments runtime_state.current_result_name = result_name runtime_state.current_result = orig_result + runtime_state.current_step = orig_result runtime_state.was_authorized_by_scope_items = was_authorized_by_scope_items # Wrap the execution of _this_ method with tracing, # but don't wrap the continuation below diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 56374f1d7a..40d8e560a7 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -51,6 +51,7 @@ def value # Lazy API rs = @runtime.get_current_runtime_state rs.current_result = current_result rs.current_result_name = current_result_name + rs.current_step = self @runtime.schema.sync_lazy(@result) rescue GraphQL::ExecutionError => err err @@ -211,6 +212,7 @@ def call_field_resolver runtime_state.current_arguments = @resolved_arguments runtime_state.current_result_name = @result_name runtime_state.current_result = @selection_result + runtime_state.current_step = self # end # Actually call the field resolver and capture the result @@ -240,8 +242,7 @@ def handle_resolved_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 + was_scoped = @was_scoped @runtime.continue_field(@result, @field, return_type, @ast_node, @next_selections, false, @resolved_arguments, @result_name, @selection_result, was_scoped, runtime_state) else nil diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 7277eee881..3fa87a6422 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -53,7 +53,7 @@ def build_path(path_array) end end - attr_accessor :graphql_dead + attr_accessor :graphql_dead, :was_scoped attr_reader :graphql_parent, :graphql_result_name, :graphql_is_non_null_in_parent, :graphql_application_value, :graphql_result_type, :graphql_selections, :graphql_is_eager, :ast_node, :graphql_arguments, :graphql_field @@ -152,19 +152,12 @@ def handle_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 authorize_application_value + if @was_scoped + @authorized_check_result = true + @step = :handle_authorized_application_value + return # TODO continue? + end @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 @@ -194,7 +187,7 @@ def run_step # TODO skip if scoped authorize_application_value when :handle_authorized_application_value - # TODO doesn't actually support `.wrap` + # TODO doesn't actually support `.wrap` - that may be impossible if merged into Schema::Object if @authorized_check_result # TODO don't call `scoped_new here` @graphql_application_value = @graphql_result_type.scoped_new(@graphql_application_value, @runtime.context) @@ -254,6 +247,7 @@ def run_step runtime_state = @runtime.get_current_runtime_state runtime_state.current_result_name = nil runtime_state.current_result = selections_result + runtime_state.current_step = self nil end when :run_selection_directives @@ -322,7 +316,7 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab @runtime.run_queue.append_step(resolve_field_step) end - attr_accessor :ordered_result_keys, :target_result, :was_scoped + attr_accessor :ordered_result_keys, :target_result include GraphQLResult diff --git a/lib/graphql/execution/interpreter/runtime/step.rb b/lib/graphql/execution/interpreter/runtime/step.rb index eb4b3f47f1..1feee5a236 100644 --- a/lib/graphql/execution/interpreter/runtime/step.rb +++ b/lib/graphql/execution/interpreter/runtime/step.rb @@ -5,6 +5,8 @@ module Execution class Interpreter class Runtime module Step + attr_accessor :was_scoped + def call step_finished = false while !step_finished @@ -12,6 +14,7 @@ def call rs = @runtime.get_current_runtime_state rs.current_result = self.current_result rs.current_result_name = self.current_result_name + rs.current_step = self step_result = run_step step_finished = step_finished? if !step_finished && @runtime.lazy?(step_result) diff --git a/lib/graphql/pagination/connection.rb b/lib/graphql/pagination/connection.rb index e22c625619..e9f1d09ba1 100644 --- a/lib/graphql/pagination/connection.rb +++ b/lib/graphql/pagination/connection.rb @@ -225,7 +225,7 @@ def detect_was_authorized_by_scope_items if @context && (current_runtime_state = Fiber[:__graphql_runtime_info]) && (query_runtime_state = current_runtime_state[@context.query]) - query_runtime_state.was_authorized_by_scope_items + query_runtime_state.current_step.was_scoped else nil end diff --git a/lib/graphql/schema/field/scope_extension.rb b/lib/graphql/schema/field/scope_extension.rb index f031164f3d..ac86f053e4 100644 --- a/lib/graphql/schema/field/scope_extension.rb +++ b/lib/graphql/schema/field/scope_extension.rb @@ -14,7 +14,8 @@ def after_resolve(object:, arguments:, context:, value:, memo:) if !scoped_items.equal?(value) && !ret_type.reauthorize_scoped_objects if (current_runtime_state = Fiber[:__graphql_runtime_info]) && (query_runtime_state = current_runtime_state[context.query]) - query_runtime_state.was_authorized_by_scope_items = true + # query_runtime_state.was_authorized_by_scope_items = true + query_runtime_state.current_step.was_scoped = true end end scoped_items diff --git a/lib/graphql/types/relay/connection_behaviors.rb b/lib/graphql/types/relay/connection_behaviors.rb index b00ebcde86..65f0db6bc8 100644 --- a/lib/graphql/types/relay/connection_behaviors.rb +++ b/lib/graphql/types/relay/connection_behaviors.rb @@ -198,7 +198,7 @@ def edges # already happened at the connection level. current_runtime_state = Fiber[:__graphql_runtime_info] query_runtime_state = current_runtime_state[context.query] - query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? + query_runtime_state.current_step.was_scoped = @object.was_authorized_by_scope_items? @object.edges end @@ -207,7 +207,7 @@ def nodes # already happened at the connection level. current_runtime_state = Fiber[:__graphql_runtime_info] query_runtime_state = current_runtime_state[context.query] - query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? + query_runtime_state.current_step.was_scoped = @object.was_authorized_by_scope_items? @object.nodes end end diff --git a/lib/graphql/types/relay/edge_behaviors.rb b/lib/graphql/types/relay/edge_behaviors.rb index 22261757b2..7fe9d0278c 100644 --- a/lib/graphql/types/relay/edge_behaviors.rb +++ b/lib/graphql/types/relay/edge_behaviors.rb @@ -16,7 +16,7 @@ def self.included(child_class) def node current_runtime_state = Fiber[:__graphql_runtime_info] query_runtime_state = current_runtime_state[context.query] - query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? + query_runtime_state.current_step.was_scoped = @object.was_authorized_by_scope_items? @object.node end diff --git a/spec/graphql/schema/member/scoped_spec.rb b/spec/graphql/schema/member/scoped_spec.rb index 8b5fc76074..f0b1bffe80 100644 --- a/spec/graphql/schema/member/scoped_spec.rb +++ b/spec/graphql/schema/member/scoped_spec.rb @@ -349,8 +349,8 @@ def books log = [] SkipAuthSchema.execute("{ book { title } books { title } }", context: { auth_log: log }) expected_log = [ - [:authorized?, "Nonsense Omnibus"], [:scope_items, ["Jayber Crow", "Hannah Coulter"]], + [:authorized?, "Nonsense Omnibus"], [:authorized?, "Jayber Crow"], [:authorized?, "Hannah Coulter"], ] From a46bcfa15186abe1cc370d36ab3632793b9ff428 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 13:47:43 -0400 Subject: [PATCH 29/34] Start merging RunQueue back into Dataloader --- lib/graphql/dataloader.rb | 33 +++++++++ lib/graphql/dataloader/null_dataloader.rb | 68 +++++++++++++++++-- lib/graphql/execution/interpreter.rb | 4 +- lib/graphql/execution/interpreter/runtime.rb | 2 +- .../interpreter/runtime/field_resolve_step.rb | 3 +- .../interpreter/runtime/graphql_result.rb | 8 +-- .../interpreter/runtime/run_queue.rb | 64 ++++++++--------- 7 files changed, 134 insertions(+), 48 deletions(-) diff --git a/lib/graphql/dataloader.rb b/lib/graphql/dataloader.rb index d78b3e354e..4378b46fb3 100644 --- a/lib/graphql/dataloader.rb +++ b/lib/graphql/dataloader.rb @@ -64,8 +64,14 @@ def initialize(nonblocking: self.class.default_nonblocking, fiber_limit: self.cl @nonblocking = nonblocking end @fiber_limit = fiber_limit + @steps_to_rerun_after_lazy = [] + @lazies_at_depth = nil end + attr_accessor :lazies_at_depth + + attr_reader :steps_to_rerun_after_lazy + # @return [Integer, nil] attr_reader :fiber_limit @@ -189,6 +195,8 @@ def run_isolated end def run + # TODO unify the initialization lazies_at_depth + @lazies_at_depth ||= Hash.new { |h, k| h[k] = [] } trace = Fiber[:__graphql_current_multiplex]&.current_trace jobs_fiber_limit, total_fiber_limit = calculate_fiber_limit job_fibers = [] @@ -222,6 +230,31 @@ def run end join_queues(source_fibers, next_source_fibers) end + + if @lazies_at_depth.any? + smallest_depth = nil + @lazies_at_depth.each_key do |depth_key| + smallest_depth ||= depth_key + if depth_key < smallest_depth + smallest_depth = depth_key + end + end + + if smallest_depth + lazies = @lazies_at_depth.delete(smallest_depth) + if !lazies.empty? + append_job { + lazies.each(&:value) # resolve these Lazy instances + } + job_fibers << spawn_job_fiber(trace) + end + end + elsif @steps_to_rerun_after_lazy.any? + @pending_jobs.concat(@steps_to_rerun_after_lazy) + f = spawn_job_fiber(trace) + job_fibers << f + @steps_to_rerun_after_lazy.clear + end end trace&.end_dataloader(self) diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index 3e431c1086..e033a3528a 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -10,16 +10,74 @@ class NullDataloader < Dataloader # These are all no-ops because code was # executed synchronously. - def initialize(*); end - def run; end - def run_isolated; yield; end + def initialize(*) + # TODO unify the initialization lazies_at_depth + @lazies_at_depth ||= Hash.new { |h, k| h[k] = [] } + @steps_to_rerun_after_lazy = [] + @queue = [] + end + + def run + puts "#{self.class}#run ~~~ @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" + while @queue.any? + puts "#{self.class}#run 111 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" + while (step = @queue.shift) + step.call + end + + puts "#{self.class}#run 222 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" + + while @lazies_at_depth.any? + smallest_depth = nil + @lazies_at_depth.each_key do |depth_key| + smallest_depth ||= depth_key + if depth_key < smallest_depth + smallest_depth = depth_key + end + end + + if smallest_depth + lazies = @lazies_at_depth.delete(smallest_depth) + lazies.each(&:value) # resolve these Lazy instances + end + end + + puts "#{self.class}#run 333 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" + + if @steps_to_rerun_after_lazy.any? + @steps_to_rerun_after_lazy.each(&:call) + @steps_to_rerun_after_lazy.clear + end + end + end + + def run_isolated + prev_queue = @queue + prev_stral = @steps_to_rerun_after_lazy + prev_lad = @lazies_at_depth + @steps_to_rerun_after_lazy = [] + @queue = [] + @lazies_at_depth = @lazies_at_depth.dup.clear + res = nil + append_job { + res = yield + } + run + res + ensure + @queue = prev_queue + @steps_to_rerun_after_lazy = prev_stral + @lazies_at_depth = prev_lad + end + def clear_cache; end + def yield(_source) raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." end - def append_job(callable = nil) - callable ? callable.call : yield + def append_job(callable = nil, &block) + @queue << (callable || block) nil end diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 31b2b6e237..660189136b 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -44,6 +44,7 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl schema = multiplex.schema queries = multiplex.queries lazies_at_depth = Hash.new { |h, k| h[k] = [] } + multiplex.dataloader.lazies_at_depth = lazies_at_depth multiplex_analyzers = schema.multiplex_analyzers if multiplex.max_complexity multiplex_analyzers += [GraphQL::Analysis::MaxQueryComplexity] @@ -90,9 +91,6 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl } end - multiplex.dataloader.append_job { - run_queue&.complete # can be null if errored - } multiplex.dataloader.run diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index e84d27f43c..a73405af8e 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -52,7 +52,7 @@ def current_object attr_accessor :run_queue def steps_to_rerun_after_lazy # TODO fix this jank - @run_queue.steps_to_rerun_after_lazy + @dataloader.steps_to_rerun_after_lazy end def initialize(query:, lazies_at_depth:, run_queue: nil) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 40d8e560a7..e9b22c16a2 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -12,6 +12,7 @@ def initialize(runtime, field, ast_node, ast_nodes, result_name, selection_resul @field = field @object = selection_result.graphql_application_value if @field.dynamic_introspection + # TODO `.wrap` isn't used elsewhere @object = field.owner.wrap(@object, @runtime.context) end @ast_node = ast_node @@ -33,7 +34,7 @@ def current_result_name end def inspect_step - "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name})" + "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name}, #{@result.class})" end def step_finished? diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index 3fa87a6422..268148a544 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -92,7 +92,7 @@ def depth end def inspect_step - "#{self.class.name.split("::").last}##{object_id}(#{@graphql_result_type.to_type_signature} @ #{path.join(".")}})" + "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")}, #{(@graphql_application_value&.object.class rescue @graphql_application_value.class)}})" end def step_finished? @@ -267,8 +267,6 @@ def run_step # 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( @@ -276,8 +274,6 @@ def run_step ) @runtime.dataloader.clear_cache } - @runtime.run_queue.complete(eager: true) - @runtime.run_queue = prev_queue else @runtime.dataloader.append_job { evaluate_selection( @@ -427,7 +423,7 @@ def initialize(_runtime_inst, _result_name, _result_type, _application_value, _p end def inspect_step - "#{self.class.name.split("::").last}##{object_id}(#{@graphql_result_type.to_type_signature} @ #{path.join(".")})" + "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")})" end def depth diff --git a/lib/graphql/execution/interpreter/runtime/run_queue.rb b/lib/graphql/execution/interpreter/runtime/run_queue.rb index 5d74a2e8d8..f61dfec8f1 100644 --- a/lib/graphql/execution/interpreter/runtime/run_queue.rb +++ b/lib/graphql/execution/interpreter/runtime/run_queue.rb @@ -13,43 +13,43 @@ def initialize(runtime:) end def append_step(step) - @current_flush << step + @dataloader.append_job(step) + # @current_flush << step end - attr_reader :steps_to_rerun_after_lazy - def complete(eager: false) - # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] - prev_eagerly = @running_eagerly - @running_eagerly = eager - while (fl = @current_flush) && fl.any? - @current_flush = [] - @steps_to_rerun_after_lazy = [] - while fl.any? - while (next_step = fl.shift) - @dataloader.append_job(next_step) + @dataloader.run + # # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] + # prev_eagerly = @running_eagerly + # @running_eagerly = eager + # while (fl = @current_flush) && fl.any? + # @current_flush = [] + # @steps_to_rerun_after_lazy = [] + # while fl.any? + # while (next_step = fl.shift) + # @dataloader.append_job(next_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 + # 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 - if @current_flush.any? - fl.concat(@current_flush) - @current_flush.clear - else - @dataloader.run - fl.concat(@steps_to_rerun_after_lazy) - @steps_to_rerun_after_lazy.clear - Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) - end - end - end - ensure - @running_eagerly = prev_eagerly + # if @current_flush.any? + # fl.concat(@current_flush) + # @current_flush.clear + # else + # @dataloader.run + # fl.concat(@steps_to_rerun_after_lazy) + # @steps_to_rerun_after_lazy.clear + # Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) + # end + # end + # end + # ensure + # @running_eagerly = prev_eagerly end end end From 1f5117e485bceba54c7556c6326b5d10d24e1e57 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 13:55:15 -0400 Subject: [PATCH 30/34] Remove RunQueue --- lib/graphql/dataloader/null_dataloader.rb | 6 -- lib/graphql/execution/interpreter.rb | 3 +- lib/graphql/execution/interpreter/runtime.rb | 16 ++--- .../interpreter/runtime/graphql_result.rb | 6 +- .../interpreter/runtime/run_queue.rb | 58 ------------------- 5 files changed, 10 insertions(+), 79 deletions(-) delete mode 100644 lib/graphql/execution/interpreter/runtime/run_queue.rb diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index e033a3528a..eec11a0233 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -18,15 +18,11 @@ def initialize(*) end def run - puts "#{self.class}#run ~~~ @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" while @queue.any? - puts "#{self.class}#run 111 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" while (step = @queue.shift) step.call end - puts "#{self.class}#run 222 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" - while @lazies_at_depth.any? smallest_depth = nil @lazies_at_depth.each_key do |depth_key| @@ -42,8 +38,6 @@ def run end end - puts "#{self.class}#run 333 @q:#{@queue.size} @lad:#{@lazies_at_depth.size} / @stral:#{@steps_to_rerun_after_lazy.size}" - if @steps_to_rerun_after_lazy.any? @steps_to_rerun_after_lazy.each(&:call) @steps_to_rerun_after_lazy.clear diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 660189136b..61df126f16 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -75,8 +75,7 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl # Although queries in a multiplex _share_ an Interpreter instance, # they also have another item of state, which is private to that query # in particular, assign it here: - runtime = Runtime.new(query: query, lazies_at_depth: lazies_at_depth, run_queue: run_queue) - run_queue ||= runtime.run_queue + runtime = Runtime.new(query: query, lazies_at_depth: lazies_at_depth) query.context.namespace(:interpreter_runtime)[:runtime] = runtime query.current_trace.execute_query(query: query) do diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index a73405af8e..a0f35dc026 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -2,7 +2,6 @@ require "graphql/execution/interpreter/runtime/step" require "graphql/execution/interpreter/runtime/field_resolve_step" require "graphql/execution/interpreter/runtime/graphql_result" -require "graphql/execution/interpreter/runtime/run_queue" ##### # Next thoughts @@ -49,13 +48,11 @@ def current_object attr_reader :dataloader, :current_trace, :lazies_at_depth - attr_accessor :run_queue - def steps_to_rerun_after_lazy # TODO fix this jank @dataloader.steps_to_rerun_after_lazy end - def initialize(query:, lazies_at_depth:, run_queue: nil) + def initialize(query:, lazies_at_depth:) @query = query @current_trace = query.current_trace @dataloader = query.multiplex.dataloader @@ -74,7 +71,6 @@ def initialize(query:, lazies_at_depth:, run_queue: nil) end # { Class => Boolean } @lazy_cache = {}.compare_by_identity - @run_queue = run_queue || RunQueue.new(runtime: self) end def final_result @@ -99,7 +95,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.append_step(@next_step) + @runtime.dataloader.append_job(@next_step) @next_step end end @@ -154,7 +150,7 @@ def run_eager else @response end - @run_queue.append_step(next_step) + @dataloader.append_job(next_step) when "LIST" inner_type = root_type.unwrap case inner_type.kind.name @@ -176,7 +172,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.append_step(@response) + @dataloader.append_job(@response) end when "SCALAR", "ENUM" result_name = ast_node.alias || ast_node.name @@ -469,12 +465,12 @@ def continue_field(value, field, current_type, ast_node, next_selections, is_non 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 + @dataloader.append_job 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) response_list.was_scoped = was_scoped set_result(selection_result, result_name, response_list, true, is_non_null) - @run_queue.append_step(response_list) + @dataloader.append_job(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 268148a544..ab14c8cb14 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -235,7 +235,7 @@ def run_step selections_result.target_result = self selections_result.ordered_result_keys = ordered_result_keys selections_result.set_step :run_selection_directives - @runtime.run_queue.append_step(selections_result) + @runtime.dataloader.append_job(selections_result) @step = :finished # Continuing from others now -- could actually reuse this instance for the first one tho else selections_result = self @@ -309,7 +309,7 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disab field_defn = @runtime.query.types.field(owner_type, field_name) resolve_field_step = FieldResolveStep.new(@runtime, field_defn, ast_node, field_ast_nodes, result_name, self) - @runtime.run_queue.append_step(resolve_field_step) + @runtime.dataloader.append_job(resolve_field_step) end attr_accessor :ordered_result_keys, :target_result @@ -452,7 +452,7 @@ def run_step this_idx, inner_value, ) - @runtime.run_queue.append_step(list_item_step) + @runtime.dataloader.append_job(list_item_step) end self diff --git a/lib/graphql/execution/interpreter/runtime/run_queue.rb b/lib/graphql/execution/interpreter/runtime/run_queue.rb deleted file mode 100644 index f61dfec8f1..0000000000 --- a/lib/graphql/execution/interpreter/runtime/run_queue.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Execution - class Interpreter - class Runtime - class RunQueue - def initialize(runtime:) - @runtime = runtime - @current_flush = [] - @dataloader = runtime.dataloader - @lazies_at_depth = runtime.lazies_at_depth - @running_eagerly = false - end - - def append_step(step) - @dataloader.append_job(step) - # @current_flush << step - end - - def complete(eager: false) - @dataloader.run - # # p [self.class, __method__, eager, caller(1,1).first, @current_flush.size] - # prev_eagerly = @running_eagerly - # @running_eagerly = eager - # while (fl = @current_flush) && fl.any? - # @current_flush = [] - # @steps_to_rerun_after_lazy = [] - # while fl.any? - # while (next_step = fl.shift) - # @dataloader.append_job(next_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 - - # if @current_flush.any? - # fl.concat(@current_flush) - # @current_flush.clear - # else - # @dataloader.run - # fl.concat(@steps_to_rerun_after_lazy) - # @steps_to_rerun_after_lazy.clear - # Interpreter::Resolve.resolve_each_depth(@lazies_at_depth, @dataloader) - # end - # end - # end - # ensure - # @running_eagerly = prev_eagerly - end - end - end - end - end -end From 77e748534350cf0512bb8cd414e558c746b44001 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 10 Jul 2025 14:00:11 -0400 Subject: [PATCH 31/34] Remove Interpreter::Resolve which is now needless --- lib/graphql/execution/interpreter.rb | 12 --- lib/graphql/execution/interpreter/resolve.rb | 100 ------------------- spec/graphql/schema/directive_spec.rb | 3 - 3 files changed, 115 deletions(-) delete mode 100644 lib/graphql/execution/interpreter/resolve.rb diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 61df126f16..fae23c8027 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -5,7 +5,6 @@ require "graphql/execution/interpreter/arguments_cache" require "graphql/execution/interpreter/execution_errors" require "graphql/execution/interpreter/runtime" -require "graphql/execution/interpreter/resolve" require "graphql/execution/interpreter/handles_raw_value" module GraphQL @@ -39,7 +38,6 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl multiplex = Execution::Multiplex.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) trace = multiplex.current_trace Fiber[:__graphql_current_multiplex] = multiplex - run_queue = nil trace.execute_multiplex(multiplex: multiplex) do schema = multiplex.schema queries = multiplex.queries @@ -92,16 +90,6 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl multiplex.dataloader.run - - # Then, work through lazy results in a breadth-first way - multiplex.dataloader.append_job { - query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil - multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: query) do - Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) - end - } - multiplex.dataloader.run - # Then, find all errors and assign the result to the query object results.each_with_index do |data_result, idx| query = queries[idx] diff --git a/lib/graphql/execution/interpreter/resolve.rb b/lib/graphql/execution/interpreter/resolve.rb deleted file mode 100644 index 99bb674835..0000000000 --- a/lib/graphql/execution/interpreter/resolve.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Execution - class Interpreter - module Resolve - # Continue field results in `results` until there's nothing else to continue. - # @return [void] - def self.resolve_all(results, dataloader) - dataloader.append_job { resolve(results, dataloader) } - nil - end - - def self.resolve_each_depth(lazies_at_depth, dataloader) - smallest_depth = nil - lazies_at_depth.each_key do |depth_key| - smallest_depth ||= depth_key - if depth_key < smallest_depth - smallest_depth = depth_key - end - end - - if smallest_depth - lazies = lazies_at_depth.delete(smallest_depth) - if !lazies.empty? - dataloader.append_job { - lazies.each(&:value) # resolve these Lazy instances - } - # Run lazies _and_ dataloader, see if more are enqueued - dataloader.run - resolve_each_depth(lazies_at_depth, dataloader) - end - end - nil - end - - # After getting `results` back from an interpreter evaluation, - # continue it until you get a response-ready Ruby value. - # - # `results` is one level of _depth_ of a query or multiplex. - # - # Resolve all lazy values in that depth before moving on - # to the next level. - # - # It's assumed that the lazies will - # return {Lazy} instances if there's more work to be done, - # or return {Hash}/{Array} if the query should be continued. - # - # @return [void] - def self.resolve(results, dataloader) - # There might be pending jobs here that _will_ write lazies - # into the result hash. We should run them out, so we - # can be sure that all lazies will be present in the result hashes. - # A better implementation would somehow interleave (or unify) - # these approaches. - dataloader.run - next_results = [] - while !results.empty? - result_value = results.shift - if result_value.is_a?(Runtime::GraphQLResultHash) || result_value.is_a?(Hash) - results.concat(result_value.values) - next - elsif result_value.is_a?(Runtime::GraphQLResultArray) - results.concat(result_value.values) - next - elsif result_value.is_a?(Array) - results.concat(result_value) - next - elsif result_value.is_a?(Lazy) - loaded_value = result_value.value - if loaded_value.is_a?(Lazy) - # Since this field returned another lazy, - # add it to the same queue - results << loaded_value - elsif loaded_value.is_a?(Runtime::GraphQLResultHash) || loaded_value.is_a?(Runtime::GraphQLResultArray) || - loaded_value.is_a?(Hash) || loaded_value.is_a?(Array) - # Add these values in wholesale -- - # they might be modified by later work in the dataloader. - next_results << loaded_value - end - end - end - - if !next_results.empty? - # Any pending data loader jobs may populate the - # resutl arrays or result hashes accumulated in - # `next_results``. Run those **to completion** - # before continuing to resolve `next_results`. - # (Just `.append_job` doesn't work if any pending - # jobs require multiple passes.) - dataloader.run - dataloader.append_job { resolve(next_results, dataloader) } - end - - nil - end - end - end - end -end diff --git a/spec/graphql/schema/directive_spec.rb b/spec/graphql/schema/directive_spec.rb index f423f20963..db8faa5e63 100644 --- a/spec/graphql/schema/directive_spec.rb +++ b/spec/graphql/schema/directive_spec.rb @@ -117,7 +117,6 @@ def self.resolve(obj, args, ctx) result = nil ctx.dataloader.run_isolated do result = yield - GraphQL::Execution::Interpreter::Resolve.resolve_all([result], ctx.dataloader) end ctx[:count_fields] ||= Hash.new { |h, k| h[k] = [] } @@ -340,8 +339,6 @@ def self.resolve_each(object, args, context) 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.selection_result.values.compact! value end From 7812e220ba54278ce0b4a657f3903f7a259d4f22 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 11 Jul 2025 06:46:23 -0400 Subject: [PATCH 32/34] Return NullDataloader which can be frozen --- lib/graphql/dataloader.rb | 1 + lib/graphql/dataloader/flat_dataloader.rb | 76 +++++++++++++++++++++++ lib/graphql/dataloader/null_dataloader.rb | 62 ++---------------- lib/graphql/schema.rb | 2 +- 4 files changed, 83 insertions(+), 58 deletions(-) create mode 100644 lib/graphql/dataloader/flat_dataloader.rb diff --git a/lib/graphql/dataloader.rb b/lib/graphql/dataloader.rb index 4378b46fb3..8da6c39465 100644 --- a/lib/graphql/dataloader.rb +++ b/lib/graphql/dataloader.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "graphql/dataloader/flat_dataloader" require "graphql/dataloader/null_dataloader" require "graphql/dataloader/request" require "graphql/dataloader/request_all" diff --git a/lib/graphql/dataloader/flat_dataloader.rb b/lib/graphql/dataloader/flat_dataloader.rb new file mode 100644 index 0000000000..9977ce1795 --- /dev/null +++ b/lib/graphql/dataloader/flat_dataloader.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module GraphQL + class Dataloader + class FlatDataloader < Dataloader + def initialize(*) + # TODO unify the initialization lazies_at_depth + @lazies_at_depth ||= Hash.new { |h, k| h[k] = [] } + @steps_to_rerun_after_lazy = [] + @queue = [] + end + + def run + while @queue.any? + while (step = @queue.shift) + step.call + end + + while @lazies_at_depth&.any? + smallest_depth = nil + @lazies_at_depth.each_key do |depth_key| + smallest_depth ||= depth_key + if depth_key < smallest_depth + smallest_depth = depth_key + end + end + + if smallest_depth + lazies = @lazies_at_depth.delete(smallest_depth) + lazies.each(&:value) # resolve these Lazy instances + end + end + + if @steps_to_rerun_after_lazy.any? + @steps_to_rerun_after_lazy.each(&:call) + @steps_to_rerun_after_lazy.clear + end + end + end + + def run_isolated + prev_queue = @queue + prev_stral = @steps_to_rerun_after_lazy + prev_lad = @lazies_at_depth + @steps_to_rerun_after_lazy = [] + @queue = [] + @lazies_at_depth = @lazies_at_depth.dup&.clear + res = nil + append_job { + res = yield + } + run + res + ensure + @queue = prev_queue + @steps_to_rerun_after_lazy = prev_stral + @lazies_at_depth = prev_lad + end + + def clear_cache; end + + def yield(_source) + raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." + end + + def append_job(callable = nil, &block) + @queue << (callable || block) + nil + end + + def with(*) + raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." + end + end + end +end diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index eec11a0233..3e431c1086 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -10,68 +10,16 @@ class NullDataloader < Dataloader # These are all no-ops because code was # executed synchronously. - def initialize(*) - # TODO unify the initialization lazies_at_depth - @lazies_at_depth ||= Hash.new { |h, k| h[k] = [] } - @steps_to_rerun_after_lazy = [] - @queue = [] - end - - def run - while @queue.any? - while (step = @queue.shift) - step.call - end - - while @lazies_at_depth.any? - smallest_depth = nil - @lazies_at_depth.each_key do |depth_key| - smallest_depth ||= depth_key - if depth_key < smallest_depth - smallest_depth = depth_key - end - end - - if smallest_depth - lazies = @lazies_at_depth.delete(smallest_depth) - lazies.each(&:value) # resolve these Lazy instances - end - end - - if @steps_to_rerun_after_lazy.any? - @steps_to_rerun_after_lazy.each(&:call) - @steps_to_rerun_after_lazy.clear - end - end - end - - def run_isolated - prev_queue = @queue - prev_stral = @steps_to_rerun_after_lazy - prev_lad = @lazies_at_depth - @steps_to_rerun_after_lazy = [] - @queue = [] - @lazies_at_depth = @lazies_at_depth.dup.clear - res = nil - append_job { - res = yield - } - run - res - ensure - @queue = prev_queue - @steps_to_rerun_after_lazy = prev_stral - @lazies_at_depth = prev_lad - end - + def initialize(*); end + def run; end + def run_isolated; yield; end def clear_cache; end - def yield(_source) raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." end - def append_job(callable = nil, &block) - @queue << (callable || block) + def append_job(callable = nil) + callable ? callable.call : yield nil end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index be3d20b729..1f0094f828 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -671,7 +671,7 @@ def union_memberships(type = nil) # @api private # @see GraphQL::Dataloader def dataloader_class - @dataloader_class || GraphQL::Dataloader::NullDataloader + @dataloader_class || GraphQL::Dataloader::FlatDataloader end attr_writer :dataloader_class From b386fd6aca163d87e3e4985458061c82c672a9db Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 11 Jul 2025 07:01:53 -0400 Subject: [PATCH 33/34] Rescue some errors; hack to fix double-execute --- .../interpreter/runtime/field_resolve_step.rb | 12 +++++++++--- .../execution/interpreter/runtime/graphql_result.rb | 11 ++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index e9b22c16a2..9e7503faf0 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -127,19 +127,25 @@ def load_arguments else @step = :prepare_kwarg_arguments @result = nil - dataloader_paused = false + @should_continue_args = false @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| @result = resolved_arguments - if dataloader_paused + if @should_continue_args prepare_kwarg_arguments end end - dataloader_paused = true + @should_continue_args = true @result end end def prepare_kwarg_arguments + # TODO the problem is that if Dataloader pauses in the block above, + # the step is somehow resumed here. + # Then the call in the block above also runs later, resulting in double-execution. + # I think the big fix is to move the dataloader-y stuff from argument resolution + # and inline it here. + @should_continue_args = false # @resolved_arguments may have been eagerly set if there aren't actually any args if @resolved_arguments.nil? && @result.nil? @runtime.dataloader.run diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index ab14c8cb14..a58380fa8a 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -164,7 +164,14 @@ def authorize_application_value begin @graphql_result_type.authorized?(@graphql_application_value, @runtime.context) rescue GraphQL::UnauthorizedError => err - @runtime.schema.unauthorized_object(err) + begin + @runtime.schema.unauthorized_object(err) + rescue GraphQL::ExecutionError => err + # TODO this is getting handled like a normal "false" below + # but should skip the re-wrapping with UnauthorizedError + @graphql_application_value = err + false + end rescue StandardError => err @runtime.query.handle_or_reraise(err) end @@ -203,6 +210,8 @@ def run_step else @graphql_application_value = nil end + rescue GraphQL::ExecutionError => err + @graphql_application_value = err end end @authorized_check_result = nil From 7d1b3651cc51daadd2eb5b9854ea89a4d8454974 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 24 Jul 2025 21:08:48 -0400 Subject: [PATCH 34/34] Keep working --- lib/graphql/execution/interpreter/runtime.rb | 57 ++- .../interpreter/runtime/field_resolve_step.rb | 47 +- .../interpreter/runtime/graphql_result.rb | 475 ------------------ .../runtime/graphql_result_array.rb | 119 +++++ .../runtime/graphql_result_hash.rb | 391 ++++++++++++++ .../execution/interpreter/runtime/step.rb | 32 +- spec/graphql/execution/interpreter_spec.rb | 1 + 7 files changed, 577 insertions(+), 545 deletions(-) create mode 100644 lib/graphql/execution/interpreter/runtime/graphql_result_array.rb create mode 100644 lib/graphql/execution/interpreter/runtime/graphql_result_hash.rb diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index a0f35dc026..e1791c65c7 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -2,6 +2,8 @@ require "graphql/execution/interpreter/runtime/step" require "graphql/execution/interpreter/runtime/field_resolve_step" require "graphql/execution/interpreter/runtime/graphql_result" +require "graphql/execution/interpreter/runtime/graphql_result_array" +require "graphql/execution/interpreter/runtime/graphql_result_hash" ##### # Next thoughts @@ -100,10 +102,6 @@ def run_step end end - def step_finished? - true - end - def current_result @next_step.current_result end @@ -496,10 +494,6 @@ def current_result_name @index end - def step_finished? - @step == :finished - end - def inspect_step "#{self.class.name.split("::").last}##{object_id}@#{@index}" end @@ -526,36 +520,41 @@ def run_step case @step when :check_directives 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_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, dirs) do - @step = :check_lazy_item + check_if_item_lazy end else - @step = :check_lazy_item - end - when :check_lazy_item - @step = :handle_item - if @runtime.lazy?(@item_value) - @item_value - else - nil + check_if_item_lazy 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.equal?(continue_value) - was_scoped = @list_result.was_scoped - @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 + handle_item else raise "Invariant: unexpected step: #{inspect_step}" end end + + private + + def check_if_item_lazy + if reenqueue_if_lazy?(@item_value) + @step = :handle_item + else + handle_item + end + end + + def 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.equal?(continue_value) + was_scoped = @list_result.was_scoped + @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 + end end def call_method_on_directives(method_name, object, directives, &block) diff --git a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb index 9e7503faf0..b1ce86b19e 100644 --- a/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb +++ b/lib/graphql/execution/interpreter/runtime/field_resolve_step.rb @@ -37,10 +37,6 @@ def inspect_step "#{self.class.name.split("::").last}##{object_id}/#@step(#{@field.path} @ #{@selection_result.path.join(".")}.#{@result_name}, #{@result.class})" end - def step_finished? - @step == :finished - end - def depth @selection_result.depth + 1 end @@ -53,6 +49,7 @@ def value # Lazy API rs.current_result = current_result rs.current_result_name = current_result_name rs.current_step = self + puts "sync_lazy #{@result} #{inspect_step}" @runtime.schema.sync_lazy(@result) rescue GraphQL::ExecutionError => err err @@ -68,9 +65,9 @@ def value # Lazy API end def run_step + puts "run_step #{inspect_step}" if @selection_result.graphql_dead - @step = :finished - return nil + return end case @step when :inspect_ast @@ -105,10 +102,8 @@ def inspect_ast 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? + load_arguments end else load_arguments @@ -127,15 +122,14 @@ def load_arguments else @step = :prepare_kwarg_arguments @result = nil - @should_continue_args = false - @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| + dataload_for_result = @runtime.query.arguments_cache.dataload_for(@ast_node, @field, @object) do |resolved_arguments| @result = resolved_arguments - if @should_continue_args - prepare_kwarg_arguments - end end - @should_continue_args = true - @result + if (@result && reenqueue_if_lazy?(@result)) || (reenqueue_if_lazy?(dataload_for_result)) + return + else + @runtime.steps_to_rerun_after_lazy << self + end end end @@ -145,17 +139,15 @@ def prepare_kwarg_arguments # Then the call in the block above also runs later, resulting in double-execution. # I think the big fix is to move the dataloader-y stuff from argument resolution # and inline it here. - @should_continue_args = false # @resolved_arguments may have been eagerly set if there aren't actually any args - if @resolved_arguments.nil? && @result.nil? - @runtime.dataloader.run - end + # if @resolved_arguments.nil? && @result.nil? + # @runtime.dataloader.run + # end @resolved_arguments ||= @result @result = nil # TODO is this still necessary? 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 = :finished return end @@ -239,8 +231,15 @@ def call_field_resolver end end @runtime.current_trace.end_execute_field(@field, @object, @kwarg_arguments, query, app_result) - @step = :handle_resolved_value @result = app_result + reenc = reenqueue_if_lazy?(@result) + p [:app_result, @result, reenc] + if reenc + @step = :handle_resolved_value + return + else + handle_resolved_value + end end def handle_resolved_value @@ -251,11 +250,7 @@ def handle_resolved_value runtime_state = @runtime.get_current_runtime_state was_scoped = @was_scoped @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 end end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index a58380fa8a..f5c4cb2f79 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -62,481 +62,6 @@ def build_path(path_array) # @return [Hash] Plain-Ruby result data (`@graphql_metadata` contains Result wrapper objects) attr_accessor :graphql_result_data 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 - @resolve_type_result = nil - @authorized_check_result = nil - if @graphql_result_type.kind.object? - @step = :authorize_application_value - else - @step = :resolve_abstract_type - end - end - - def set_step(new_step) - @step = new_step - end - - def depth - @graphql_depth ||= begin - parent_depth = @graphql_parent ? @graphql_parent.depth : 0 - parent_depth + 1 - end - end - - def inspect_step - "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")}, #{(@graphql_application_value&.object.class rescue @graphql_application_value.class)}})" - end - - def step_finished? - @step == :finished - end - - 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 - end - - 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) - end - 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 authorize_application_value - if @was_scoped - @authorized_check_result = true - @step = :handle_authorized_application_value - return # TODO continue? - end - @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 - begin - @runtime.schema.unauthorized_object(err) - rescue GraphQL::ExecutionError => err - # TODO this is getting handled like a normal "false" below - # but should skip the re-wrapping with UnauthorizedError - @graphql_application_value = err - false - end - 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 - @step = :handle_resolved_type - resolve_abstract_type - when :handle_resolved_type - handle_resolved_type - 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` - that may be impossible if merged into Schema::Object - 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 - rescue GraphQL::ExecutionError => err - @graphql_application_value = err - 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) - @step = :finished - return - elsif @graphql_parent - @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) - end - @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 - 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 - selections_result.set_step :run_selection_directives - @runtime.dataloader.append_job(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 - runtime_state.current_step = self - 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 - 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| - # 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 { - evaluate_selection( - result_name, field_ast_nodes_or_ast_node - ) - @runtime.dataloader.clear_cache - } - else - @runtime.dataloader.append_job { - evaluate_selection( - result_name, field_ast_nodes_or_ast_node - ) - } - end - end - - if @target_result - self.merge_into(@target_result) - end - @step = :finished - else - raise "Invariant: invalid state for #{self.class}: #{@step}" - end - end - - def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disable Metrics/ParameterLists - return if @graphql_dead - - # 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 - 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) - - resolve_field_step = FieldResolveStep.new(@runtime, field_defn, ast_node, field_ast_nodes, result_name, self) - @runtime.dataloader.append_job(resolve_field_step) - end - - attr_accessor :ordered_result_keys, :target_result - - include GraphQLResult - - attr_accessor :graphql_merged_into - - def set_leaf(key, value) - # This is a hack. - # Basically, this object is merged into the root-level result at some point. - # But the problem is, some lazies are created whose closures retain reference to _this_ - # object. When those lazies are resolved, they cause an update to this object. - # - # In order to return a proper top-level result, we have to update that top-level result object. - # In order to return a proper partial result (eg, for a directive), we have to update this object, too. - # Yowza. - if (t = @graphql_merged_into) - t.set_leaf(key, value) - end - - before_size = @graphql_result_data.size - @graphql_result_data[key] = value - after_size = @graphql_result_data.size - if after_size > before_size && @ordered_result_keys[before_size] != key - fix_result_order - end - - # keep this up-to-date if it's been initialized - @graphql_metadata && @graphql_metadata[key] = value - - value - end - - def set_child_result(key, value) - if (t = @graphql_merged_into) - t.set_child_result(key, value) - end - before_size = @graphql_result_data.size - @graphql_result_data[key] = value.graphql_result_data - after_size = @graphql_result_data.size - if after_size > before_size && @ordered_result_keys[before_size] != key - fix_result_order - end - - # If we encounter some part of this response that requires metadata tracking, - # then create the metadata hash if necessary. It will be kept up-to-date after this. - (@graphql_metadata ||= @graphql_result_data.dup)[key] = value - value - end - - def delete(key) - @graphql_metadata && @graphql_metadata.delete(key) - @graphql_result_data.delete(key) - end - - def each - (@graphql_metadata || @graphql_result_data).each { |k, v| yield(k, v) } - end - - def values - (@graphql_metadata || @graphql_result_data).values - end - - def key?(k) - @graphql_result_data.key?(k) - end - - def [](k) - (@graphql_metadata || @graphql_result_data)[k] - end - - def merge_into(into_result) - self.each do |key, value| - case value - when GraphQLResultHash - next_into = into_result[key] - if next_into - value.merge_into(next_into) - else - into_result.set_child_result(key, value) - end - when GraphQLResultArray - # There's no special handling of arrays because currently, there's no way to split the execution - # of a list over several concurrent flows. - into_result.set_child_result(key, value) - else - # We have to assume that, since this passed the `fields_will_merge` selection, - # that the old and new values are the same. - into_result.set_leaf(key, value) - end - end - @graphql_merged_into = into_result - end - - def fix_result_order - @ordered_result_keys.each do |k| - if @graphql_result_data.key?(k) - @graphql_result_data[k] = @graphql_result_data.delete(k) - end - end - end - end - - class GraphQLResultArray - include GraphQLResult - - 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 inspect_step - "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")})" - 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 - # This is true for objects, unions, and interfaces - # use_dataloader_job = !inner_type.unwrap.kind.input? - idx = nil - list_value = begin - begin - @graphql_application_value.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - list_item_step = ListItemStep.new( - @runtime, - self, - this_idx, - inner_value, - ) - @runtime.dataloader.append_job(list_item_step) - 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? ? @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 - - 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, - # this object has to coordinate incoming writes to account for any already-skipped indices. - @skip_indices ||= [] - @skip_indices << index - offset_by = @skip_indices.count { |skipped_idx| skipped_idx < index} - delete_at_index = index - offset_by - @graphql_metadata && @graphql_metadata.delete_at(delete_at_index) - @graphql_result_data.delete_at(delete_at_index) - end - - def set_leaf(idx, value) - if @skip_indices - offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } - idx -= offset_by - end - @graphql_result_data[idx] = value - @graphql_metadata && @graphql_metadata[idx] = value - value - end - - def set_child_result(idx, value) - if @skip_indices - offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } - idx -= offset_by - end - @graphql_result_data[idx] = value.graphql_result_data - # If we encounter some part of this response that requires metadata tracking, - # then create the metadata hash if necessary. It will be kept up-to-date after this. - (@graphql_metadata ||= @graphql_result_data.dup)[idx] = value - value - end - - def values - (@graphql_metadata || @graphql_result_data) - end - - def [](idx) - (@graphql_metadata || @graphql_result_data)[idx] - end - end end end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result_array.rb b/lib/graphql/execution/interpreter/runtime/graphql_result_array.rb new file mode 100644 index 0000000000..8f3237e33c --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/graphql_result_array.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class Interpreter + class Runtime + class GraphQLResultArray + include GraphQLResult + + 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 inspect_step + "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")})" + end + + def depth + @graphql_depth ||= @graphql_parent.depth + 1 + end + + def run_step + 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? + idx = nil + list_value = begin + begin + @graphql_application_value.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + list_item_step = ListItemStep.new( + @runtime, + self, + this_idx, + inner_value, + ) + @runtime.dataloader.append_job(list_item_step) + 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? ? @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 + + 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, + # this object has to coordinate incoming writes to account for any already-skipped indices. + @skip_indices ||= [] + @skip_indices << index + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < index} + delete_at_index = index - offset_by + @graphql_metadata && @graphql_metadata.delete_at(delete_at_index) + @graphql_result_data.delete_at(delete_at_index) + end + + def set_leaf(idx, value) + if @skip_indices + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } + idx -= offset_by + end + @graphql_result_data[idx] = value + @graphql_metadata && @graphql_metadata[idx] = value + value + end + + def set_child_result(idx, value) + if @skip_indices + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } + idx -= offset_by + end + @graphql_result_data[idx] = value.graphql_result_data + # If we encounter some part of this response that requires metadata tracking, + # then create the metadata hash if necessary. It will be kept up-to-date after this. + (@graphql_metadata ||= @graphql_result_data.dup)[idx] = value + value + end + + def values + (@graphql_metadata || @graphql_result_data) + end + + def [](idx) + (@graphql_metadata || @graphql_result_data)[idx] + end + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result_hash.rb b/lib/graphql/execution/interpreter/runtime/graphql_result_hash.rb new file mode 100644 index 0000000000..81a53c2bc7 --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/graphql_result_hash.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class Interpreter + class Runtime + 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 + @resolve_type_result = nil + @authorized_check_result = nil + if @graphql_result_type.kind.object? + @step = :authorize_application_value + else + @step = :resolve_abstract_type + end + end + + def set_step(new_step) + @step = new_step + end + + def depth + @graphql_depth ||= begin + parent_depth = @graphql_parent ? @graphql_parent.depth : 0 + parent_depth + 1 + end + end + + def inspect_step + "#{self.class.name.split("::").last}##{object_id}:#@step(#{@graphql_result_type.to_type_signature} @ #{path.join(".")}, #{(@graphql_application_value&.object.class rescue @graphql_application_value.class)}})" + end + + def step_finished? + @step == :finished + end + + 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 + end + + 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 + @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 + @runtime.continue_value(ex_err, @graphql_field, @graphql_is_non_null_in_parent, @ast_node, @graphql_result_name, @graphql_parent) + end + end + + if reenqueue_if_lazy?(@resolve_type_result) + @step = :handle_resolved_type + else + handle_resolved_type + 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 + authorize_application_value + end + + def authorize_application_value + if @was_scoped + @authorized_check_result = true + return handle_authorized_application_value + end + @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 + begin + @runtime.schema.unauthorized_object(err) + rescue GraphQL::ExecutionError => err + # TODO this is getting handled like a normal "false" below + # but should skip the re-wrapping with UnauthorizedError + @graphql_application_value = err + false + end + rescue StandardError => err + @runtime.query.handle_or_reraise(err) + end + end + if reenqueue_if_lazy?(@authorized_check_result) + @step = :handle_authorized_application_value + else + handle_authorized_application_value + end + ensure + @runtime.current_trace.end_authorized(@graphql_result_type, @graphql_application_value, @runtime.context, @authorized_check_result) + end + end + + def handle_authorized_application_value + # TODO doesn't actually support `.wrap` - that may be impossible if merged into Schema::Object + 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 + rescue GraphQL::ExecutionError => err + @graphql_application_value = err + end + end + @authorized_check_result = nil + if reenqueue_if_lazy?(@graphql_application_value) + @step = :handle_wrapped_application_value + else + handle_wrapped_application_value + end + end + + def 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 = :finished + return + elsif @graphql_parent + @runtime.set_result(@graphql_parent, @graphql_result_name, self, true, @graphql_is_non_null_in_parent) + end + @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 + selections_result.set_step :run_selection_directives + @runtime.dataloader.append_job(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 + run_selection_directives + end + runtime_state = @runtime.get_current_runtime_state + runtime_state.current_result_name = nil + runtime_state.current_result = selections_result + runtime_state.current_step = self + nil + end + end + + def run_selection_directives + if (directives = @graphql_selections[:graphql_directives]) + @graphql_selections.delete(:graphql_directives) + @runtime.call_method_on_directives(:resolve, @graphql_application_value, directives) do + call_each_field + end + else + call_each_field + end + end + + def 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. + # So instead trigger a run from this outer context. + if @graphql_is_eager + @runtime.dataloader.clear_cache + @runtime.dataloader.run_isolated { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node + ) + @runtime.dataloader.clear_cache + } + else + @runtime.dataloader.append_job { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node + ) + } + end + end + + if @target_result + self.merge_into(@target_result) + end + @step = :finished + end + + def run_step + case @step + when :resolve_abstract_type + resolve_abstract_type + when :handle_resolved_type + handle_resolved_type + when :authorize_application_value + # TODO skip if scoped + authorize_application_value + when :handle_authorized_application_value + handle_authorized_application_value + when :handle_wrapped_application_value + handle_wrapped_application_value + when :run_selection_directives + run_selection_directives + when :call_each_field + call_each_field + else + raise "Invariant: invalid state for #{self.class}: #{@step}" + end + end + + def evaluate_selection(result_name, field_ast_nodes_or_ast_node) # rubocop:disable Metrics/ParameterLists + return if @graphql_dead + + # 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 + 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) + + resolve_field_step = FieldResolveStep.new(@runtime, field_defn, ast_node, field_ast_nodes, result_name, self) + @runtime.dataloader.append_job(resolve_field_step) + end + + attr_accessor :ordered_result_keys, :target_result + + include GraphQLResult + + attr_accessor :graphql_merged_into + + def set_leaf(key, value) + # This is a hack. + # Basically, this object is merged into the root-level result at some point. + # But the problem is, some lazies are created whose closures retain reference to _this_ + # object. When those lazies are resolved, they cause an update to this object. + # + # In order to return a proper top-level result, we have to update that top-level result object. + # In order to return a proper partial result (eg, for a directive), we have to update this object, too. + # Yowza. + if (t = @graphql_merged_into) + t.set_leaf(key, value) + end + + before_size = @graphql_result_data.size + @graphql_result_data[key] = value + after_size = @graphql_result_data.size + if after_size > before_size && @ordered_result_keys[before_size] != key + fix_result_order + end + + # keep this up-to-date if it's been initialized + @graphql_metadata && @graphql_metadata[key] = value + + value + end + + def set_child_result(key, value) + if (t = @graphql_merged_into) + t.set_child_result(key, value) + end + before_size = @graphql_result_data.size + @graphql_result_data[key] = value.graphql_result_data + after_size = @graphql_result_data.size + if after_size > before_size && @ordered_result_keys[before_size] != key + fix_result_order + end + + # If we encounter some part of this response that requires metadata tracking, + # then create the metadata hash if necessary. It will be kept up-to-date after this. + (@graphql_metadata ||= @graphql_result_data.dup)[key] = value + value + end + + def delete(key) + @graphql_metadata && @graphql_metadata.delete(key) + @graphql_result_data.delete(key) + end + + def each + (@graphql_metadata || @graphql_result_data).each { |k, v| yield(k, v) } + end + + def values + (@graphql_metadata || @graphql_result_data).values + end + + def key?(k) + @graphql_result_data.key?(k) + end + + def [](k) + (@graphql_metadata || @graphql_result_data)[k] + end + + def merge_into(into_result) + self.each do |key, value| + case value + when GraphQLResultHash + next_into = into_result[key] + if next_into + value.merge_into(next_into) + else + into_result.set_child_result(key, value) + end + when GraphQLResultArray + # There's no special handling of arrays because currently, there's no way to split the execution + # of a list over several concurrent flows. + into_result.set_child_result(key, value) + else + # We have to assume that, since this passed the `fields_will_merge` selection, + # that the old and new values are the same. + into_result.set_leaf(key, value) + end + end + @graphql_merged_into = into_result + end + + def fix_result_order + @ordered_result_keys.each do |k| + if @graphql_result_data.key?(k) + @graphql_result_data[k] = @graphql_result_data.delete(k) + end + end + end + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime/step.rb b/lib/graphql/execution/interpreter/runtime/step.rb index 1feee5a236..6aa093ec36 100644 --- a/lib/graphql/execution/interpreter/runtime/step.rb +++ b/lib/graphql/execution/interpreter/runtime/step.rb @@ -7,23 +7,25 @@ class Runtime module Step attr_accessor :was_scoped - def call - step_finished = false - while !step_finished - # TODO use a `current_step`-type thing for this - rs = @runtime.get_current_runtime_state - rs.current_result = self.current_result - rs.current_result_name = self.current_result_name - rs.current_step = self - step_result = run_step - step_finished = step_finished? - if !step_finished && @runtime.lazy?(step_result) - @runtime.lazies_at_depth[depth] << self - @runtime.steps_to_rerun_after_lazy << self - step_finished = true # we'll come back around to it - end + # @return [Boolean] True if `value` was lazy and this step was re-enqueued + def reenqueue_if_lazy?(value) + if @runtime.lazy?(value) + @runtime.lazies_at_depth[depth] << self + @runtime.steps_to_rerun_after_lazy << self + true + else + false end end + + def call + # TODO use a `current_step`-type thing for this + rs = @runtime.get_current_runtime_state + rs.current_result = self.current_result + rs.current_result_name = self.current_result_name + rs.current_step = self + run_step + end end end end diff --git a/spec/graphql/execution/interpreter_spec.rb b/spec/graphql/execution/interpreter_spec.rb index 798cb7dd74..671232c3eb 100644 --- a/spec/graphql/execution/interpreter_spec.rb +++ b/spec/graphql/execution/interpreter_spec.rb @@ -313,6 +313,7 @@ def execute_multiplex(multiplex:) end end + focus it "runs a query" do query_string = <<-GRAPHQL query($expansion: String!, $id1: ID!, $id2: ID!){