From cf3a3f8b53d75b4a04f6db1f21517afaf38c3eeb Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 11:47:46 -0500 Subject: [PATCH 1/7] Add spec for better loads: dataloading, prototype new loads code --- lib/graphql.rb | 9 +++- .../execution/interpreter/argument_value.rb | 28 +++++++++++ .../execution/interpreter/arguments.rb | 47 ++++++++++++------- lib/graphql/execution/interpreter/runtime.rb | 27 +++++++++-- lib/graphql/schema/argument.rb | 16 +++---- lib/graphql/schema/member/has_arguments.rb | 40 ++++++++++++++++ spec/graphql/dataloader_spec.rb | 11 +++++ 7 files changed, 148 insertions(+), 30 deletions(-) diff --git a/lib/graphql.rb b/lib/graphql.rb index ec9bf6c370..b91efe2021 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -72,7 +72,14 @@ def self.scan_with_ruby(graphql_string) GraphQL::Language::Lexer.tokenize(graphql_string) end - NOT_CONFIGURED = Object.new.freeze + NOT_CONFIGURED = Object.new + def NOT_CONFIGURED.to_s + "" + end + def NOT_CONFIGURED.inspect + "" + end + NOT_CONFIGURED.freeze private_constant :NOT_CONFIGURED module EmptyObjects EMPTY_HASH = {}.freeze diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index ca7845b05b..683247e76f 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -7,12 +7,40 @@ class Interpreter # @see Interpreter::Arguments#argument_values for a hash of these objects. class ArgumentValue def initialize(definition:, value:, original_value:, default_used:) + @arguments = nil @definition = definition @value = value @original_value = original_value @default_used = default_used end + attr_writer :arguments + + # @private implements Dataloader API + def call + if NOT_CONFIGURED.equal?(@value) + context = @arguments.context + value = definition.type.coerce_input(@original_value, context) + value = definition.prepare_value(@arguments.parent_object, value, context: context) + if definition.loads && !definition.from_resolver? + value = definition.load_and_authorize_value(definition.owner, value, context) + while value.is_a?(Array) && value.any? { |v| NOT_CONFIGURED.equal?(v) } + @arguments.context.dataloader.yield # TODO hack to wait for other work to finish + end + end + @value = value + end + rescue StandardError => err + @value = err + context = @arguments.context + context.schema.handle_or_reraise(context, err) + end + + # @private is this value finished being dataloaded? + def finished? + !NOT_CONFIGURED.equal?(@value) + end + # @return [Object] The Ruby-ready value for this Argument attr_reader :value diff --git a/lib/graphql/execution/interpreter/arguments.rb b/lib/graphql/execution/interpreter/arguments.rb index 4da23f25d4..399aa6afeb 100644 --- a/lib/graphql/execution/interpreter/arguments.rb +++ b/lib/graphql/execution/interpreter/arguments.rb @@ -13,18 +13,20 @@ class Arguments extend Forwardable include GraphQL::Dig - # The Ruby-style arguments hash, ready for a resolver. - # This hash is the one used at runtime. - # - # @return [Hash] - attr_reader :keyword_arguments + # @return [GraphQL::Query::Context] + attr_reader :context + + # @return [Object, nil] + attr_reader :parent_object # @param argument_values [nil, Hash{Symbol => ArgumentValue}] # @param keyword_arguments [nil, Hash{Symbol => Object}] - def initialize(keyword_arguments: nil, argument_values:) + def initialize(keyword_arguments: nil, parent_object: nil, context: nil, argument_values:) @empty = argument_values.nil? || argument_values.empty? + @context = context + @parent_object = parent_object # This is only present when `extras` have been merged in: - if keyword_arguments + @keyword_arguments = if keyword_arguments # This is a little crazy. We expect the `:argument_details` extra to _include extras_, # but the object isn't created until _after_ extras are put together. # So, we have to use a special flag here to say, "at the last minute, add yourself to the keyword args." @@ -38,18 +40,15 @@ def initialize(keyword_arguments: nil, argument_values:) if keyword_arguments[:argument_details] == :__arguments_add_self keyword_arguments[:argument_details] = self end - @keyword_arguments = keyword_arguments.freeze - elsif !@empty - @keyword_arguments = {} - argument_values.each do |name, arg_val| - @keyword_arguments[name] = arg_val.value - end - @keyword_arguments.freeze + keyword_arguments.freeze + elsif @empty + NO_ARGS else - @keyword_arguments = NO_ARGS + nil end @argument_values = argument_values ? argument_values.freeze : NO_ARGS - freeze + @argument_values.each_value { |v| v.arguments = self } + # freeze TODO put this call elsewhere? end # @return [Hash{Symbol => ArgumentValue}] @@ -59,6 +58,22 @@ def empty? @empty end + # The Ruby-style arguments hash, ready for a resolver. + # This hash is the one used at runtime. + # + # @return [Hash] + def keyword_arguments + kws = {} + argument_values.each do |name, arg_val| + kws[name] = arg_val.value + end + if @keyword_arguments + @keyword_arguments.merge(kws) + else + kws + end + end + def_delegators :keyword_arguments, :key?, :[], :fetch, :keys, :each, :values, :size, :to_h def_delegators :argument_values, :each_value diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 4724830817..a853f85499 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -362,14 +362,33 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_resu 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| + args_hash = GraphQL::Execution::Interpreter::ArgumentsCache.prepare_args_hash(query, ast_node) + arguments = field_defn.create_runtime_arguments(owner_object, args_hash, context) + arguments.argument_values.each_value do |arg_value| + @dataloader.append_job(arg_value) + end + + @dataloader.append_job { + while !arguments.argument_values.each_value.all?(&:finished?) + puts "Waiting for argument_values finished @ #{field_defn.path} / #{context.current_path}" + @dataloader.yield # TODO this is a hack to let those finish first + end runtime_state = get_current_runtime_state # This might be in a different fiber runtime_state.current_field = field_defn - runtime_state.current_arguments = resolved_arguments + runtime_state.current_arguments = arguments runtime_state.current_result_name = result_name runtime_state.current_result = selections_result - evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) - end + evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + } + + # @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 + # runtime_state.current_field = field_defn + # runtime_state.current_arguments = resolved_arguments + # runtime_state.current_result_name = result_name + # runtime_state.current_result = selections_result + # evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + # end end end diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index b85fd2ffc5..c49aa48d83 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -354,15 +354,13 @@ def load_and_authorize_value(load_method_owner, coerced_value, context) elsif loads if type.list? loaded_values = [] - # We want to run these list items all together, - # but we also need to wait for the result so we can return it :S - context.dataloader.run_isolated do - coerced_value.each_with_index { |val, idx| - context.dataloader.append_job do - loaded_values[idx] = load_method_owner.load_and_authorize_application_object(self, val, context) - end - } - end + coerced_value.each_with_index { |val, idx| + loaded_values[idx] = NOT_CONFIGURED + context.dataloader.append_job do + loaded_values[idx] = load_method_owner.load_and_authorize_application_object(self, val, context) + end + } + context.schema.after_any_lazies(loaded_values, &:itself) else load_method_owner.load_and_authorize_application_object(self, coerced_value, context) diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index 98b36a8de6..c61483e85a 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -247,6 +247,46 @@ def argument_class(new_arg_class = nil) self.class.argument_class(new_arg_class) end + def create_runtime_arguments(parent_object, values, context) + argument_values = nil + arg_defns = context.types.arguments(self) + arg_defns.each do |argument_defn| + default_used = false + value = if values.key?(argument_defn.graphql_name) + values[argument_defn.graphql_name] + # elsif values.key?(argument_defn.keyword) TODO can I not need this? + elsif argument_defn.default_value? + default_used = true + argument_defn.default_value + else + next + end + + if value.nil? && argument_defn.replace_null_with_default? + value = default_value + default_used = true + end + + argument_values ||= {} + argument_values[argument_defn.keyword] = GraphQL::Execution::Interpreter::ArgumentValue.new( + value: NOT_CONFIGURED, + original_value: value, + definition: argument_defn, + default_used: default_used, + ) + end + + if argument_values + Execution::Interpreter::Arguments.new( + context: context, + parent_object: parent_object, + argument_values: argument_values + ) + else + Execution::Interpreter::Arguments::EMPTY + end + end + # @api private # If given a block, it will eventually yield the loaded args to the block. # diff --git a/spec/graphql/dataloader_spec.rb b/spec/graphql/dataloader_spec.rb index a5020de012..1b5f18b3ee 100644 --- a/spec/graphql/dataloader_spec.rb +++ b/spec/graphql/dataloader_spec.rb @@ -934,6 +934,17 @@ def self.included(child_class) context = { batched_calls_counter: BatchedCallsCounter.new } schema.execute(query_str, context: context) assert_equal 1, context[:batched_calls_counter].count + + database_log.clear + query_str = "{ r1: recipe(id: 5) { name } recipesById(ids: [6]) { name } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + result = schema.execute(query_str, context: context) + pp result.to_h + pp database_log + assert_graphql_equal({ "r1" => {"name" => "Cornbread" }, "recipesById" => [ { "name" => "Grits"}]}, result["data"]) + assert_equal 1, context[:batched_calls_counter].count + expected_log = [[:mget, ["5", "6"]]] + assert_equal expected_log, database_log end it "batches nested object calls in .authorized? after using lazy_resolve" do From 90baa97941ef9e08b6550d189380c28f247d6de7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 12:50:07 -0500 Subject: [PATCH 2/7] Add basic Lazy handling --- .../execution/interpreter/argument_value.rb | 58 +++++++++++++++---- lib/graphql/query/context.rb | 2 +- lib/graphql/schema/member/has_arguments.rb | 2 +- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index 683247e76f..7d38b86a66 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -12,37 +12,71 @@ def initialize(definition:, value:, original_value:, default_used:) @value = value @original_value = original_value @default_used = default_used + @state = :initialized end attr_writer :arguments + # Lazy api tODO conflicts with old attr_reader :value + def value + if @arguments.context&.schema&.lazy?(@value) # TODO InputObject still calls coerce_arguments which doesn't initialize this + @value = @arguments.context.schema.sync_lazy(@value) + @arguments.context.dataloader.append_job(self) + end + @value + rescue GraphQL::UnauthorizedError => err + @state = :finished + context = @arguments.context + @value = context.schema.unauthorized_object(err) + rescue StandardError => err + @state = :finished + context = @arguments.context + @value = context.schema.handle_or_reraise(context, err) + end + # @private implements Dataloader API def call - if NOT_CONFIGURED.equal?(@value) - context = @arguments.context - value = definition.type.coerce_input(@original_value, context) - value = definition.prepare_value(@arguments.parent_object, value, context: context) + context = @arguments.context + case @state + when :initialized + @value = definition.type.coerce_input(@original_value, context) + @state = :coerced + when :coerced + @value = definition.prepare_value(@arguments.parent_object, @value, context: context) + @state = :prepared + when :prepared if definition.loads && !definition.from_resolver? - value = definition.load_and_authorize_value(definition.owner, value, context) - while value.is_a?(Array) && value.any? { |v| NOT_CONFIGURED.equal?(v) } + @value = definition.load_and_authorize_value(definition.owner, @value, context) + while @value.is_a?(Array) && @value.any? { |v| NOT_CONFIGURED.equal?(v) } @arguments.context.dataloader.yield # TODO hack to wait for other work to finish end end - @value = value + @state = :finished end + + if context.schema.lazy?(@value) # TODO use runtime cached version + context.dataloader.lazy_at_depth(context[:current_result].depth, self) + elsif @state != :finished + # TODO non-recursive + call + end + rescue GraphQL::UnauthorizedError => err + @state = :finished + context = @arguments.context + @value = context.schema.unauthorized_object(err) rescue StandardError => err - @value = err + @state = :finished context = @arguments.context - context.schema.handle_or_reraise(context, err) + @value = context.schema.handle_or_reraise(context, err) end # @private is this value finished being dataloaded? def finished? - !NOT_CONFIGURED.equal?(@value) + @state == :finished end - # @return [Object] The Ruby-ready value for this Argument - attr_reader :value + # # @return [Object] The Ruby-ready value for this Argument + # attr_reader :value # @return [Object] The value of this argument _before_ `prepare` is applied. attr_reader :original_value diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 587148a556..7ec7a50ddb 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -84,7 +84,7 @@ def types attr_writer :types - RUNTIME_METADATA_KEYS = Set.new([:current_object, :current_arguments, :current_field, :current_path]).freeze + RUNTIME_METADATA_KEYS = Set.new([:current_object, :current_arguments, :current_field, :current_path, :current_result]).freeze # @!method []=(key, value) # Reassign `key` to the hash passed to {Schema#execute} as `context:` diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index c61483e85a..ce539cc85b 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -263,7 +263,7 @@ def create_runtime_arguments(parent_object, values, context) end if value.nil? && argument_defn.replace_null_with_default? - value = default_value + value = argument_defn.default_value default_used = true end From b328bc923dc5e9e4d50d5a196cdd41cc10ac34e1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 15:29:32 -0500 Subject: [PATCH 3/7] Add more lazy handling and error handling --- .../execution/interpreter/argument_value.rb | 27 +++++++++++++++---- lib/graphql/execution/interpreter/runtime.rb | 18 +++++++------ lib/graphql/schema/argument.rb | 1 + lib/graphql/schema/member/has_arguments.rb | 3 ++- spec/graphql/schema/argument_spec.rb | 6 ++--- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index 7d38b86a66..ef30818c36 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -6,12 +6,13 @@ class Interpreter # A container for metadata regarding arguments present in a GraphQL query. # @see Interpreter::Arguments#argument_values for a hash of these objects. class ArgumentValue - def initialize(definition:, value:, original_value:, default_used:) + def initialize(definition:, value:, original_value:, default_used:, ast_node:) @arguments = nil @definition = definition @value = value @original_value = original_value @default_used = default_used + @ast_node = ast_node @state = :initialized end @@ -28,6 +29,12 @@ def value @state = :finished context = @arguments.context @value = context.schema.unauthorized_object(err) + rescue GraphQL::ExecutionError => exec_err + @state = :errored + exec_err.path ||= context.current_path + exec_err.ast_node ||= @ast_node + context.errors << exec_err + @value = exec_err rescue StandardError => err @state = :finished context = @arguments.context @@ -61,18 +68,28 @@ def call call end rescue GraphQL::UnauthorizedError => err - @state = :finished + @state = :errored context = @arguments.context @value = context.schema.unauthorized_object(err) + @state = :finished + @value + rescue GraphQL::ExecutionError => exec_err + @state = :errored + exec_err.path ||= context.current_path + exec_err.ast_node ||= @ast_node + context.add_error(exec_err) rescue StandardError => err @state = :finished context = @arguments.context @value = context.schema.handle_or_reraise(context, err) end - # @private is this value finished being dataloaded? - def finished? - @state == :finished + def completed? + @state == :finished || @state == :errored + end + + def errored? + @state == :errored end # # @return [Object] The Ruby-ready value for this Argument diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index a853f85499..916b64b03d 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -363,22 +363,24 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_resu end else args_hash = GraphQL::Execution::Interpreter::ArgumentsCache.prepare_args_hash(query, ast_node) - arguments = field_defn.create_runtime_arguments(owner_object, args_hash, context) + arguments = field_defn.create_runtime_arguments(owner_object, args_hash, context, ast_node) arguments.argument_values.each_value do |arg_value| @dataloader.append_job(arg_value) end @dataloader.append_job { - while !arguments.argument_values.each_value.all?(&:finished?) + while !arguments.argument_values.each_value.all?(&:completed?) puts "Waiting for argument_values finished @ #{field_defn.path} / #{context.current_path}" @dataloader.yield # TODO this is a hack to let those finish first end - runtime_state = get_current_runtime_state # This might be in a different fiber - runtime_state.current_field = field_defn - runtime_state.current_arguments = arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = selections_result - evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + if !arguments.argument_values.each_value.any?(&:errored?) + runtime_state = get_current_runtime_state # This might be in a different fiber + runtime_state.current_field = field_defn + runtime_state.current_arguments = arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selections_result + evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + end } # @query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index c49aa48d83..7e8a1da69e 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -315,6 +315,7 @@ def coerce_into_values(parent_object, values, context, argument_values) original_value: resolved_coerced_value, definition: self, default_used: default_used, + ast_node: nil, ) end end diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index ce539cc85b..d98621df67 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -247,7 +247,7 @@ def argument_class(new_arg_class = nil) self.class.argument_class(new_arg_class) end - def create_runtime_arguments(parent_object, values, context) + def create_runtime_arguments(parent_object, values, context, ast_node) argument_values = nil arg_defns = context.types.arguments(self) arg_defns.each do |argument_defn| @@ -273,6 +273,7 @@ def create_runtime_arguments(parent_object, values, context) original_value: value, definition: argument_defn, default_used: default_used, + ast_node: ast_node, ) end diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 24187dca03..a48fa788cf 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -215,7 +215,7 @@ def self.resolve_type(type, obj, ctx) GRAPHQL res = SchemaArgumentTest::Schema.execute(query_str, context: {multiply_by: 3}) - assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect, 'f2' => nil }, res['data']) + assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect }, res['data']) assert_equal(res['errors'][0]['message'], 'boom!') assert_equal(res['errors'][0]['path'], ['f2']) end @@ -226,7 +226,7 @@ def self.resolve_type(type, obj, ctx) GRAPHQL res = SchemaArgumentTest::Schema.execute(query_str, context: {multiply_by: 3}) - assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect, 'f2' => nil }, res['data']) + assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect, 'f2' => {required_with_default_arg: 1, unauthorized_prepared_arg: nil}.inspect }, res['data']) assert_nil(res['errors']) end end @@ -340,7 +340,7 @@ def self.resolve_type(type, obj, ctx) res = SchemaArgumentTest::Schema.execute(query_str) assert_nil res["errors"] - assert_nil res["data"].fetch("field") + assert_equal "{required_with_default_arg: 1, unauthorized_instrument: nil}", res["data"].fetch("field") end it "handles applies authorization even when a custom load method is provided" do From aa2ac9fee9553b81bff5e26e1ac5e07e5df050e7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 15:42:16 -0500 Subject: [PATCH 4/7] Implement NullDataloader#yield for input objects --- lib/graphql/dataloader/null_dataloader.rb | 6 ++++-- lib/graphql/execution/interpreter/argument_value.rb | 2 ++ lib/graphql/execution/interpreter/runtime.rb | 2 +- spec/graphql/schema/argument_spec.rb | 2 +- spec/graphql/schema/input_object_spec.rb | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index 61b7aa820e..a14cd07a22 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -48,8 +48,10 @@ def run_isolated 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." + def yield(_source = nil) + run + # TODO solve this problem another way + # 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) diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index ef30818c36..b3d38bb37f 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -31,6 +31,7 @@ def value @value = context.schema.unauthorized_object(err) rescue GraphQL::ExecutionError => exec_err @state = :errored + context = @arguments.context exec_err.path ||= context.current_path exec_err.ast_node ||= @ast_node context.errors << exec_err @@ -92,6 +93,7 @@ def errored? @state == :errored end + attr_reader :state # # @return [Object] The Ruby-ready value for this Argument # attr_reader :value diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 916b64b03d..37c71f1f13 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -370,9 +370,9 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_resu @dataloader.append_job { while !arguments.argument_values.each_value.all?(&:completed?) - puts "Waiting for argument_values finished @ #{field_defn.path} / #{context.current_path}" @dataloader.yield # TODO this is a hack to let those finish first end + if !arguments.argument_values.each_value.any?(&:errored?) runtime_state = get_current_runtime_state # This might be in a different fiber runtime_state.current_field = field_defn diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index a48fa788cf..2d01d4d8ed 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -340,7 +340,7 @@ def self.resolve_type(type, obj, ctx) res = SchemaArgumentTest::Schema.execute(query_str) assert_nil res["errors"] - assert_equal "{required_with_default_arg: 1, unauthorized_instrument: nil}", res["data"].fetch("field") + assert_equal({required_with_default_arg: 1, unauthorized_instrument: nil}.inspect, res["data"].fetch("field")) end it "handles applies authorization even when a custom load method is provided" do diff --git a/spec/graphql/schema/input_object_spec.rb b/spec/graphql/schema/input_object_spec.rb index 59cc301ea4..7c74a32508 100644 --- a/spec/graphql/schema/input_object_spec.rb +++ b/spec/graphql/schema/input_object_spec.rb @@ -295,7 +295,7 @@ def self.resolve_type(type, obj, ctx) input = { "a" => 1, "b" => 2, "c" => 3, "d" => 4, "e" => 5, "instrumentId" => "Instrument/Drum Kit", "danger" => 1 } res = InputObjectPrepareTest::Schema.execute(query_str, context: { multiply_by: 3 }, variables: { input: input}) - assert_nil(res["data"]) + assert_equal({}, res["data"]) assert_equal("boom!", res["errors"][0]["message"]) assert_equal([{ "line" => 1, "column" => 33 }], res["errors"][0]["locations"]) From 57bbcbae112d37dfe57e0c8355411156b57ad9c9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 16:13:34 -0500 Subject: [PATCH 5/7] Improve error detection --- .../execution/interpreter/argument_value.rb | 15 ++++++--------- lib/graphql/execution/interpreter/runtime.rb | 15 ++++++++------- spec/graphql/schema/input_object_spec.rb | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index b3d38bb37f..75436c39ae 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -30,11 +30,10 @@ def value context = @arguments.context @value = context.schema.unauthorized_object(err) rescue GraphQL::ExecutionError => exec_err - @state = :errored + @state = :finished context = @arguments.context exec_err.path ||= context.current_path exec_err.ast_node ||= @ast_node - context.errors << exec_err @value = exec_err rescue StandardError => err @state = :finished @@ -69,16 +68,14 @@ def call call end rescue GraphQL::UnauthorizedError => err - @state = :errored + @state = :finished context = @arguments.context @value = context.schema.unauthorized_object(err) - @state = :finished - @value rescue GraphQL::ExecutionError => exec_err - @state = :errored + @state = :finished exec_err.path ||= context.current_path exec_err.ast_node ||= @ast_node - context.add_error(exec_err) + @value = exec_err rescue StandardError => err @state = :finished context = @arguments.context @@ -86,11 +83,11 @@ def call end def completed? - @state == :finished || @state == :errored + @state == :finished end def errored? - @state == :errored + @value.is_a?(StandardError) end attr_reader :state diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 37c71f1f13..118a2facbd 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -373,14 +373,15 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_resu @dataloader.yield # TODO this is a hack to let those finish first end - if !arguments.argument_values.each_value.any?(&:errored?) - runtime_state = get_current_runtime_state # This might be in a different fiber - runtime_state.current_field = field_defn - runtime_state.current_arguments = arguments - runtime_state.current_result_name = result_name - runtime_state.current_result = selections_result - evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + if (first_error = arguments.argument_values.each_value.find(&:errored?)) + arguments = first_error.value end + runtime_state = get_current_runtime_state # This might be in a different fiber + runtime_state.current_field = field_defn + runtime_state.current_arguments = arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selections_result + evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) } # @query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| diff --git a/spec/graphql/schema/input_object_spec.rb b/spec/graphql/schema/input_object_spec.rb index 7c74a32508..59cc301ea4 100644 --- a/spec/graphql/schema/input_object_spec.rb +++ b/spec/graphql/schema/input_object_spec.rb @@ -295,7 +295,7 @@ def self.resolve_type(type, obj, ctx) input = { "a" => 1, "b" => 2, "c" => 3, "d" => 4, "e" => 5, "instrumentId" => "Instrument/Drum Kit", "danger" => 1 } res = InputObjectPrepareTest::Schema.execute(query_str, context: { multiply_by: 3 }, variables: { input: input}) - assert_equal({}, res["data"]) + assert_nil(res["data"]) assert_equal("boom!", res["errors"][0]["message"]) assert_equal([{ "line" => 1, "column" => 33 }], res["errors"][0]["locations"]) From 3c933f67f076b4847107a5e403f6b17a7099c0dd Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 13 Jan 2026 16:23:38 -0500 Subject: [PATCH 6/7] Revert test changeg --- spec/graphql/schema/argument_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 2d01d4d8ed..90a7428a8a 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -215,7 +215,7 @@ def self.resolve_type(type, obj, ctx) GRAPHQL res = SchemaArgumentTest::Schema.execute(query_str, context: {multiply_by: 3}) - assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect }, res['data']) + assert_equal({ 'f1' => {arg: "echo", required_with_default_arg: 1}.inspect, 'f2' => nil }, res['data']) assert_equal(res['errors'][0]['message'], 'boom!') assert_equal(res['errors'][0]['path'], ['f2']) end From 5b900667655f1ae99904c0a44c7cf5ebf2dc72ac Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 14 Jan 2026 17:42:58 -0500 Subject: [PATCH 7/7] Try to improve error handling in resolvers --- .../execution/interpreter/arguments.rb | 19 +++++++++++++++++++ lib/graphql/execution/interpreter/runtime.rb | 15 ++------------- lib/graphql/query/context.rb | 1 - lib/graphql/schema/argument.rb | 2 ++ lib/graphql/schema/input_object.rb | 13 ++++--------- lib/graphql/schema/member/has_arguments.rb | 3 ++- lib/graphql/schema/resolver.rb | 9 +++++++++ 7 files changed, 38 insertions(+), 24 deletions(-) diff --git a/lib/graphql/execution/interpreter/arguments.rb b/lib/graphql/execution/interpreter/arguments.rb index 399aa6afeb..1122da4dc2 100644 --- a/lib/graphql/execution/interpreter/arguments.rb +++ b/lib/graphql/execution/interpreter/arguments.rb @@ -54,6 +54,25 @@ def initialize(keyword_arguments: nil, parent_object: nil, context: nil, argumen # @return [Hash{Symbol => ArgumentValue}] attr_reader :argument_values + def wait_until_dataloaded + @argument_values.each_value do |arg_value| + @context.dataloader.append_job(arg_value) + end + + while !argument_values.each_value.all?(&:completed?) + @context.dataloader.yield # TODO this is a hack to let those finish first + end + p [@parent_object] + p argument_values.each_value.map(&:value) + if (first_error = argument_values.each_value.find(&:errored?)) + first_error.value + elsif @parent_object.is_a?(Class) && @parent_object < GraphQL::Schema::InputObject + @parent_object.new(self, ruby_kwargs: keyword_arguments, context: @context, defaults_used: nil) + else + self + end + end + def empty? @empty end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 118a2facbd..ce56b51176 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -362,20 +362,9 @@ def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_resu evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) end else - args_hash = GraphQL::Execution::Interpreter::ArgumentsCache.prepare_args_hash(query, ast_node) - arguments = field_defn.create_runtime_arguments(owner_object, args_hash, context, ast_node) - arguments.argument_values.each_value do |arg_value| - @dataloader.append_job(arg_value) - end - + arguments = field_defn.create_runtime_arguments(owner_object, context, ast_node) @dataloader.append_job { - while !arguments.argument_values.each_value.all?(&:completed?) - @dataloader.yield # TODO this is a hack to let those finish first - end - - if (first_error = arguments.argument_values.each_value.find(&:errored?)) - arguments = first_error.value - end + arguments = arguments.wait_until_dataloaded runtime_state = get_current_runtime_state # This might be in a different fiber runtime_state.current_field = field_defn runtime_state.current_arguments = arguments diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 7ec7a50ddb..546733a54a 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -5,7 +5,6 @@ class Query # Expose some query-specific info to field resolve functions. # It delegates `[]` to the hash that's passed to `GraphQL::Query#initialize`. class Context - class ExecutionErrors def initialize(ctx) @context = ctx diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index 7e8a1da69e..222606edfd 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -359,6 +359,8 @@ def load_and_authorize_value(load_method_owner, coerced_value, context) loaded_values[idx] = NOT_CONFIGURED context.dataloader.append_job do loaded_values[idx] = load_method_owner.load_and_authorize_application_object(self, val, context) + rescue GraphQL::ExecutionError => exec_err + loaded_values[idx] = exec_err end } diff --git a/lib/graphql/schema/input_object.rb b/lib/graphql/schema/input_object.rb index 05420201b4..24cd3855f6 100644 --- a/lib/graphql/schema/input_object.rb +++ b/lib/graphql/schema/input_object.rb @@ -233,15 +233,10 @@ def coerce_input(value, ctx) return nil end - arguments = coerce_arguments(nil, value, ctx) - - ctx.query.after_lazy(arguments) do |resolved_arguments| - if resolved_arguments.is_a?(GraphQL::Error) - raise resolved_arguments - else - self.new(resolved_arguments, ruby_kwargs: resolved_arguments.keyword_arguments, context: ctx, defaults_used: nil) - end - end + args = create_runtime_arguments(self, ctx, value) + result = args.wait_until_dataloaded + p [self, result] + result end # It's funny to think of a _result_ of an input object. diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index d98621df67..cba8c0d312 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -247,7 +247,8 @@ def argument_class(new_arg_class = nil) self.class.argument_class(new_arg_class) end - def create_runtime_arguments(parent_object, values, context, ast_node) + def create_runtime_arguments(parent_object, context, ast_node) + values = GraphQL::Execution::Interpreter::ArgumentsCache.prepare_args_hash(context.query, ast_node) argument_values = nil arg_defns = context.types.arguments(self) arg_defns.each do |argument_defn| diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index 7913fc6555..5157be4ea6 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -37,6 +37,7 @@ def initialize(object:, context:, field:) @object = object @context = context @field = field + @interpreter_arguments = context[:current_arguments] # Since this hash is constantly rebuilt, cache it for this call @arguments_by_keyword = {} context.types.arguments(self.class).each do |arg| @@ -193,6 +194,14 @@ def load_arguments(args) arg_defn = @arguments_by_keyword[key] if arg_defn prepped_value = prepared_args[key] = arg_defn.load_and_authorize_value(self, value, context) + while prepped_value.is_a?(Array) && prepped_value.any? { |v| NOT_CONFIGURED.equal?(v) } + @context.dataloader.yield # TODO hack to wait for other work to finish + end + + if prepped_value.is_a?(Array) && (first_error = prepped_value.find { |v| v.is_a?(GraphQL::ExecutionError) } ) + raise first_error + end + if context.schema.lazy?(prepped_value) prepare_lazies << context.query.after_lazy(prepped_value) do |finished_prepped_value| prepared_args[key] = finished_prepped_value