diff --git a/guides/execution/batching.md b/guides/execution/batching.md index 784f7e5b6a..2f4cc84d67 100644 --- a/guides/execution/batching.md +++ b/guides/execution/batching.md @@ -281,6 +281,10 @@ This depends on `current_path` so isn't possible yet. Actually this probably works but I haven't tested it. +## Custom Directives + +Not supported yet. This will need some new kind of integration. + ### Argument `as:` ✅ `as:` is applied: arguments are passed into Ruby methods by their `as:` names instead of their GraphQL names. diff --git a/lib/graphql/execution/batching.rb b/lib/graphql/execution/batching.rb index ca116e480d..c2dfecec4a 100644 --- a/lib/graphql/execution/batching.rb +++ b/lib/graphql/execution/batching.rb @@ -2,6 +2,7 @@ require "graphql/execution/batching/prepare_object_step" require "graphql/execution/batching/field_compatibility" require "graphql/execution/batching/field_resolve_step" +require "graphql/execution/batching/load_argument_step" require "graphql/execution/batching/runner" require "graphql/execution/batching/selections_step" module GraphQL diff --git a/lib/graphql/execution/batching/field_compatibility.rb b/lib/graphql/execution/batching/field_compatibility.rb index ddded9e3a6..b649496411 100644 --- a/lib/graphql/execution/batching/field_compatibility.rb +++ b/lib/graphql/execution/batching/field_compatibility.rb @@ -3,53 +3,11 @@ module GraphQL module Execution module Batching module FieldCompatibility - def resolve_all_load_arguments(frs, object_from_id_receiver, arguments, argument_owner, context) - arg_defns = context.types.arguments(argument_owner) - arg_defns.each do |arg_defn| - if arg_defn.loads - if arguments.key?(arg_defn.keyword) - id = arguments.delete(arg_defn.keyword) - if !id.nil? - value = if arg_defn.type.list? - id.map { |inner_id| - object_from_id_receiver.load_and_authorize_application_object(arg_defn, inner_id, context) - } - else - object_from_id_receiver.load_and_authorize_application_object(arg_defn, id, context) - end - - if frs.runner.resolves_lazies - value = frs.sync(value) - end - if value.is_a?(GraphQL::Error) - value.path = frs.path - return value - end - else - value = nil - end - arguments[arg_defn.keyword] = value - end - elsif (input_type = arg_defn.type.unwrap).kind.input_object? && - (value = arguments[arg_defn.keyword]) # TODO lists - resolve_all_load_arguments(frs, object_from_id_receiver, value, input_type, context) - end - end - nil - end - def resolve_batch(frs, objects, context, kwargs) if @batch_mode && !:direct_send.equal?(@batch_mode) return super end - if !@resolver_class - maybe_err = resolve_all_load_arguments(frs, self, kwargs, self, context) - if maybe_err - return maybe_err - end - end - if @owner.method_defined?(@resolver_method) results = [] frs.selections_step.graphql_objects.each_with_index do |obj_inst, idx| @@ -65,38 +23,6 @@ def resolve_batch(frs, objects, context, kwargs) end end results - elsif @resolver_class - objects.map do |o| - resolver_inst_kwargs = kwargs.dup - resolver_inst = @resolver_class.new(object: o, context: context, field: self) - maybe_err = resolve_all_load_arguments(frs, resolver_inst, resolver_inst_kwargs, self, context) - if maybe_err - next maybe_err - end - ruby_kwargs = if @resolver_class < Schema::HasSingleInputArgument - resolver_inst_kwargs[:input] - else - resolver_inst_kwargs - end - resolver_inst.prepared_arguments = ruby_kwargs - is_authed, new_return_value = resolver_inst.authorized?(**ruby_kwargs) - if frs.runner.resolves_lazies && frs.runner.schema.lazy?(is_authed) - is_authed, new_return_value = frs.runner.schema.sync_lazy(is_authed) - end - if is_authed - resolver_inst.call_resolve(ruby_kwargs) - else - new_return_value - end - rescue RuntimeError => err - err - rescue StandardError => stderr - begin - context.query.handle_or_reraise(stderr) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end elsif objects.first.is_a?(Hash) objects.map { |o| o[method_sym] || o[graphql_name] } elsif objects.first.is_a?(Interpreter::RawValue) diff --git a/lib/graphql/execution/batching/field_resolve_step.rb b/lib/graphql/execution/batching/field_resolve_step.rb index d1ebe7adae..f544bcf6f2 100644 --- a/lib/graphql/execution/batching/field_resolve_step.rb +++ b/lib/graphql/execution/batching/field_resolve_step.rb @@ -14,7 +14,6 @@ def initialize(parent_type:, runner:, key:, selections_step:) @field_results = nil @path = nil @enqueued_authorization = false - @pending_authorize_steps_count = 0 @all_next_objects = nil @all_next_results = nil @static_type = nil @@ -22,10 +21,13 @@ def initialize(parent_type:, runner:, key:, selections_step:) @object_is_authorized = nil @finish_extension_idx = nil @was_scoped = nil + @pending_steps = nil end attr_reader :ast_node, :key, :parent_type, :selections_step, :runner, - :field_definition, :object_is_authorized, :arguments, :was_scoped + :field_definition, :object_is_authorized, :was_scoped, :field_results + + attr_accessor :pending_steps, :arguments def path @path ||= [*@selections_step.path, @key].freeze @@ -58,54 +60,65 @@ def coerce_arguments(argument_owner, ast_arguments_or_hash) arg_defn = arg_defns.each_value.find { |a| a.keyword == key || a.graphql_name == (key_s ||= String(key)) } - arg_value = coerce_argument_value(arg_defn.type, value) - args_hash[arg_defn.keyword] = arg_value + maybe_err = coerce_argument_value(args_hash, arg_defn, value) + if maybe_err + return maybe_err + end end else ast_arguments_or_hash.each { |arg_node| arg_defn = arg_defns[arg_node.name] - arg_value = coerce_argument_value(arg_defn.type, arg_node.value) - arg_key = arg_defn.keyword - args_hash[arg_key] = arg_value + maybe_err = coerce_argument_value(args_hash, arg_defn, arg_node.value) + if maybe_err + return maybe_err + end } end - + # TODO refactor the loop above into this one arg_defns.each do |arg_graphql_name, arg_defn| if arg_defn.default_value? && !args_hash.key?(arg_defn.keyword) - args_hash[arg_defn.keyword] = arg_defn.default_value + maybe_err = coerce_argument_value(args_hash, arg_defn, arg_defn.default_value) + if maybe_err + return maybe_err + end end end args_hash end - def coerce_argument_value(arg_t, arg_value) + def coerce_argument_value(arguments, arg_defn, arg_value, target_keyword: arg_defn.keyword, as_type: nil) + arg_t = as_type || arg_defn.type if arg_t.non_null? arg_t = arg_t.of_type end - if arg_value.is_a?(Language::Nodes::VariableIdentifier) + arg_value = if arg_value.is_a?(Language::Nodes::VariableIdentifier) vars = @selections_step.query.variables - arg_value = if vars.key?(arg_value.name) + if vars.key?(arg_value.name) vars[arg_value.name] elsif vars.key?(arg_value.name.to_sym) vars[arg_value.name.to_sym] end elsif arg_value.is_a?(Language::Nodes::NullValue) - arg_value = nil + nil elsif arg_value.is_a?(Language::Nodes::Enum) - arg_value = arg_value.name + arg_value.name elsif arg_value.is_a?(Language::Nodes::InputObject) - arg_value = arg_value.arguments # rubocop:disable Development/ContextIsPassedCop + arg_value.arguments # rubocop:disable Development/ContextIsPassedCop + else + arg_value end - if arg_t.list? + arg_value = if arg_t.list? if arg_value.nil? arg_value else arg_value = Array(arg_value) inner_t = arg_t.of_type - arg_value.map { |v| coerce_argument_value(inner_t, v) } + result = Array.new(arg_value.size) + arg_value.each_with_index { |v, i| coerce_argument_value(result, arg_defn, v, target_keyword: i, as_type: inner_t) } + result end elsif arg_t.kind.leaf? begin @@ -123,6 +136,46 @@ def coerce_argument_value(arg_t, arg_value) else raise "Unsupported argument value: #{arg_t.to_type_signature} / #{arg_value.class} (#{arg_value.inspect})" end + + if arg_defn.loads && as_type.nil? && !arg_value.nil? + # This is for legacy compat: + load_receiver = if (r = @field_definition.resolver) + r.new(field: @field_definition, context: @selections_step.query.context, object: nil) + else + @field_definition + end + @pending_steps ||= [] + if arg_t.list? + results = Array.new(arg_value.size, nil) + arguments[arg_defn.keyword] = results + arg_value.each_with_index do |inner_v, idx| + loads_step = LoadArgumentStep.new( + field_resolve_step: self, + load_receiver: load_receiver, + argument_value: inner_v, + argument_definition: arg_defn, + arguments: results, + argument_key: idx, + ) + @pending_steps.push(loads_step) + @runner.add_step(loads_step) + end + else + loads_step = LoadArgumentStep.new( + field_resolve_step: self, + load_receiver: load_receiver, + argument_value: arg_value, + argument_definition: arg_defn, + arguments: arguments, + argument_key: arg_defn.keyword, + ) + @pending_steps.push(loads_step) + @runner.add_step(loads_step) + end + else + arguments[target_keyword] = arg_value + end + nil end # Implement that Lazy API @@ -145,17 +198,25 @@ def sync(lazy) @runner.schema.unauthorized_object(auth_err) rescue GraphQL::ExecutionError => err err + rescue StandardError => stderr + begin + @selections_step.query.handle_or_reraise(stderr) + rescue GraphQL::ExecutionError => ex_err + ex_err + end end def call - if @enqueued_authorization && @pending_authorize_steps_count == 0 + if @enqueued_authorization enqueue_next_steps elsif @finish_extension_idx finish_extensions elsif @field_results build_results - else + elsif @arguments execute_field + else + build_arguments end rescue StandardError => err if @field_definition && !err.message.start_with?("Resolving ") @@ -179,25 +240,38 @@ def self.[](_key) end end - def execute_field + def build_arguments query = @selections_step.query field_name = @ast_node.name @field_definition = query.get_field(@parent_type, field_name) || raise("Invariant: no field found for #{@parent_type.to_type_signature}.#{ast_node.name}") - objects = @selections_step.objects if field_name == "__typename" # TODO handle custom introspection - @field_results = Array.new(objects.size, @parent_type.graphql_name) + @field_results = Array.new(@selections_step.objects.size, @parent_type.graphql_name) @object_is_authorized = AlwaysAuthorized build_results return end - if @field_definition.dynamic_introspection - # TODO break this backwards compat somehow? - objects = @selections_step.graphql_objects + arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop + @arguments ||= arguments # may have already been set to an error + + if @pending_steps.nil? || @pending_steps.size == 0 + execute_field + end + end + + def execute_field + objects = @selections_step.objects + # TODO not as good because only one error? + if @arguments.is_a?(GraphQL::Error) + @field_results = Array.new(objects.size, @arguments) + @object_is_authorized = AlwaysAuthorized + build_results + return end - @arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop + query = @selections_step.query + @field_definition.extras.each do |extra| case extra when :lookahead @@ -219,8 +293,12 @@ def execute_field end end - ctx = query.context + if @field_definition.dynamic_introspection + # TODO break this backwards compat somehow? + objects = @selections_step.graphql_objects + end + ctx = query.context if @runner.authorization && @runner.authorizes?(@field_definition, ctx) authorized_objects = [] @object_is_authorized = objects.map { |o| @@ -230,6 +308,9 @@ def execute_field end is_authed } + if authorized_objects.size == 0 + return + end else authorized_objects = objects @object_is_authorized = AlwaysAuthorized @@ -367,7 +448,7 @@ def build_results end @enqueued_authorization = true - if @pending_authorize_steps_count == 0 + if @pending_steps.nil? || @pending_steps.size == 0 enqueue_next_steps else # Do nothing -- it will enqueue itself later @@ -448,9 +529,9 @@ def enqueue_next_steps end end - def authorized_finished - remaining = @pending_authorize_steps_count -= 1 - if @enqueued_authorization && remaining == 0 + def authorized_finished(step) + @pending_steps.delete(step) + if @enqueued_authorization && @pending_steps.size == 0 @runner.add_step(self) end end @@ -490,8 +571,7 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, (runtime_type = (@runner.runtime_type_at[graphql_result] = @runner.resolve_type(@static_type, field_result, @selections_step.query)) ) && @runner.authorizes?(runtime_type, @selections_step.query.context) ))) - @pending_authorize_steps_count += 1 - @runner.add_step(Batching::PrepareObjectStep.new( + obj_step = Batching::PrepareObjectStep.new( static_type: @static_type, object: field_result, runner: @runner, @@ -502,7 +582,10 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, is_non_null: is_nn, key: key, is_from_array: is_from_array, - )) + ) + ps = @pending_steps ||= [] + ps << obj_step + @runner.add_step(obj_step) else next_result_h = {} @all_next_results << next_result_h diff --git a/lib/graphql/execution/batching/load_argument_step.rb b/lib/graphql/execution/batching/load_argument_step.rb new file mode 100644 index 0000000000..e9156ec541 --- /dev/null +++ b/lib/graphql/execution/batching/load_argument_step.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +module GraphQL + module Execution + module Batching + class LoadArgumentStep + def initialize(field_resolve_step:, arguments:, load_receiver:, argument_value:, argument_definition:, argument_key:) + @field_resolve_step = field_resolve_step + @load_receiver = load_receiver + @arguments = arguments + @argument_value = argument_value + @argument_definition = argument_definition + @argument_key = argument_key + @loaded_value = nil + end + + def value + @loaded_value = @field_resolve_step.sync(@loaded_value) + assign_value + end + + def call + context = @field_resolve_step.selections_step.query.context + @loaded_value = @load_receiver.load_and_authorize_application_object(@argument_definition, @argument_value, context) + if (runner = @field_resolve_step.runner).resolves_lazies && runner.schema.lazy?(@loaded_value) + runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) + else + assign_value + end + rescue GraphQL::RuntimeError => err + @loaded_value = err + assign_value + rescue StandardError => stderr + @loaded_value = begin + context.query.handle_or_reraise(stderr) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + assign_value + end + + private + + def assign_value + if @loaded_value.is_a?(GraphQL::Error) + @loaded_value.path = @field_resolve_step.path + @field_resolve_step.arguments = @loaded_value + else + @arguments[@argument_key] = @loaded_value + end + + field_pending_steps = @field_resolve_step.pending_steps + field_pending_steps.delete(self) + if @field_resolve_step.arguments && field_pending_steps.size == 0 # rubocop:disable Development/ContextIsPassedCop + @field_resolve_step.runner.add_step(@field_resolve_step) + end + end + end + end + end +end diff --git a/lib/graphql/execution/batching/prepare_object_step.rb b/lib/graphql/execution/batching/prepare_object_step.rb index 66aa26a62c..5694fe407b 100644 --- a/lib/graphql/execution/batching/prepare_object_step.rb +++ b/lib/graphql/execution/batching/prepare_object_step.rb @@ -121,7 +121,7 @@ def create_result @runner.static_type_at[next_result_h] = @static_type end - @field_resolve_step.authorized_finished + @field_resolve_step.authorized_finished(self) end end end diff --git a/lib/graphql/schema/field.rb b/lib/graphql/schema/field.rb index 3d4f67ded0..8f3b4f850c 100644 --- a/lib/graphql/schema/field.rb +++ b/lib/graphql/schema/field.rb @@ -282,6 +282,9 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, description: NOT_CON elsif dig @batch_mode = :dig @batch_mode_key = dig + elsif resolver_class + @batch_mode = :resolver_class + @batch_mode_key = resolver_class else @batch_mode = :direct_send @batch_mode_key = @method_sym @@ -396,8 +399,22 @@ def resolve_batch(field_resolve_step, objects, context, args_hash) end when :dig objects.map { |o| o.dig(*@batch_mode_key) } + when :resolver_class + results = Array.new(objects.size, nil) + ps = field_resolve_step.pending_steps ||= [] + objects.each_with_index do |o, idx| + resolver_inst = @resolver_class.new(object: o, context: context, field: self) + ps << resolver_inst + resolver_inst.field_resolve_step = field_resolve_step + resolver_inst.prepared_arguments = args_hash + resolver_inst.exec_result = results + resolver_inst.exec_index = idx + field_resolve_step.runner.add_step(resolver_inst) + resolver_inst + end + results else - raise "Batching execution for #{path} not implemented; provide `resolve_static:`, `resolve_batch:`, `hash_key:`, `method:`, or use a compatibility plug-in" + raise "Batching execution for #{path} not implemented (batch_mode: #{@batch_mode.inspect}); provide `resolve_static:`, `resolve_batch:`, `hash_key:`, `method:`, or use a compatibility plug-in" end end diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index 5f01183f0f..5fd82b16a4 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -46,6 +46,8 @@ def initialize(object:, context:, field:) @prepared_arguments = nil end + attr_accessor :exec_result, :exec_index, :field_resolve_step + # @return [Object] The application object this field is being resolved on attr_accessor :object @@ -57,6 +59,44 @@ def initialize(object:, context:, field:) attr_writer :prepared_arguments + def call + if self.class < Schema::HasSingleInputArgument + @prepared_arguments = @prepared_arguments[:input] + end + q = context.query + trace_objs = [object] + q.current_trace.begin_execute_field(field, @prepared_arguments, trace_objs, q) + is_authed, new_return_value = authorized?(**@prepared_arguments) + + if (runner = @field_resolve_step.runner).resolves_lazies && runner.schema.lazy?(is_authed) + is_authed, new_return_value = runner.schema.sync_lazy(is_authed) + end + + result = if is_authed + call_resolve(@prepared_arguments) + else + new_return_value + end + q = context.query + q.current_trace.end_execute_field(field, @prepared_arguments, trace_objs, q, [result]) + + exec_result[exec_index] = result + rescue RuntimeError => err + exec_result[exec_index] = err + rescue StandardError => stderr + exec_result[exec_index] = begin + context.query.handle_or_reraise(stderr) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + ensure + field_pending_steps = field_resolve_step.pending_steps + field_pending_steps.delete(self) + if field_pending_steps.size == 0 && field_resolve_step.field_results + field_resolve_step.runner.add_step(field_resolve_step) + end + end + def arguments @prepared_arguments || raise("Arguments have not been prepared yet, still waiting for #load_arguments to resolve. (Call `.arguments` later in the code.)") end diff --git a/spec/graphql/dataloader_spec.rb b/spec/graphql/dataloader_spec.rb index 25b731dd23..5843dc54e6 100644 --- a/spec/graphql/dataloader_spec.rb +++ b/spec/graphql/dataloader_spec.rb @@ -287,7 +287,7 @@ def recipes_by_id_using_load_all(ids:) end def self.recipes_by_id(context, recipes:) - context.dataloader.with(DataObject).load_all(recipes) + recipes end def recipes_by_id(recipes:) @@ -338,8 +338,6 @@ def common_ingredients(recipe_1_id:, recipe_2_id:) end def self.common_ingredients_with_load(objects, context, recipe_1:, recipe_2:) - recipe_1, recipe_2 = context.dataloader.with(DataObject).load_all([recipe_1, recipe_2]) - common_ids = recipe_1[:ingredient_ids] & recipe_2[:ingredient_ids] results = context.dataloader.with(DataObject).load_all(common_ids) Array.new(objects.size, results) @@ -367,7 +365,8 @@ def common_ingredients_from_input_object(input:) end def self.common_ingredients_from_input_object(objects, context, input:) - recipe_1, recipe_2 = context.dataloader.with(DataObject).load_all([input[:recipe_1], input[:recipe_2]]) + recipe_1 = input[:recipe_1] + recipe_2 = input[:recipe_2] common_ids = recipe_1[:ingredient_ids] & recipe_2[:ingredient_ids] results = context.dataloader.with(DataObject).load_all(common_ids) Array.new(objects.size, results) @@ -1254,12 +1253,12 @@ def assert_last_max_fiber_count(expected_last_max_fiber_count, message = nil) res = exec_query(query_str, context: { dataloader: fiber_counting_dataloader_class.new }) assert_nil res.context.dataloader.fiber_limit - assert_equal((TESTING_BATCHING ? 9 : 10), FiberCounting.last_spawn_fiber_count) - assert_last_max_fiber_count((TESTING_BATCHING ? 8 : 9), "No limit works as expected") + assert_equal(10, FiberCounting.last_spawn_fiber_count) + assert_last_max_fiber_count((TESTING_BATCHING ? 9 : 9), "No limit works as expected") res = exec_query(query_str, context: { dataloader: fiber_counting_dataloader_class.new(fiber_limit: 4) }) assert_equal 4, res.context.dataloader.fiber_limit - assert_equal((TESTING_BATCHING ? 10 : 12), FiberCounting.last_spawn_fiber_count) + assert_equal((TESTING_BATCHING ? 11 : 12), FiberCounting.last_spawn_fiber_count) assert_last_max_fiber_count(4, "Limit of 4 works as expected") res = exec_query(query_str, context: { dataloader: fiber_counting_dataloader_class.new(fiber_limit: 6) }) diff --git a/spec/graphql/schema/resolver_spec.rb b/spec/graphql/schema/resolver_spec.rb index c4b73b9d45..0ac5b7c470 100644 --- a/spec/graphql/schema/resolver_spec.rb +++ b/spec/graphql/schema/resolver_spec.rb @@ -446,7 +446,7 @@ def resolve(**inputs) class MutationWithRequiredLoadsArgument < GraphQL::Schema::Mutation argument :label_id, ID, loads: HasValue - field :inputs, String, null: false + field :inputs, String, null: false, hash_key: :inputs def resolve(**inputs) { @@ -1028,7 +1028,6 @@ def load_input(input); end it "returns an error when nullable argument is provided an invalid value" do res = exec_query('mutation { mutationWithNullableLoadsArgument(labelId: "invalid") { inputs } }') - assert res["errors"] assert_equal 'No object found for `labelId: "invalid"`', res["errors"][0]["message"] end diff --git a/spec/graphql/tracing/active_support_notifications_trace_spec.rb b/spec/graphql/tracing/active_support_notifications_trace_spec.rb index b4614908fd..5a1d491180 100644 --- a/spec/graphql/tracing/active_support_notifications_trace_spec.rb +++ b/spec/graphql/tracing/active_support_notifications_trace_spec.rb @@ -76,7 +76,6 @@ def self.resolve_type(_abs, _obj, _ctx) "validate.graphql", "analyze.graphql", "authorized.graphql", - (TESTING_BATCHING ? "execute_field.graphql" : nil), # `loads:` happens during field execution in this case "dataloader_source.graphql", "execute_field.graphql", (TESTING_BATCHING ? "resolve_type.graphql" : nil), # `loads:`-related?