diff --git a/lib/graphql/execution/field_resolve_step.rb b/lib/graphql/execution/field_resolve_step.rb index 8577433833..c062efaa96 100644 --- a/lib/graphql/execution/field_resolve_step.rb +++ b/lib/graphql/execution/field_resolve_step.rb @@ -20,6 +20,7 @@ def initialize(parent_type:, runner:, key:, selections_step:) @results = nil @finish_extension_idx = nil @was_scoped = nil + @field_results_are_eager = nil @pending_steps = nil @arguments_without_loads = @post_processors = @directive_finalizers = nil end @@ -365,8 +366,10 @@ def execute_field query.current_trace.end_execute_field(@field_definition, authorized_objects, @arguments, query, @field_results) if any_lazy_results? + @field_results_are_eager = false @runner.dataloader.lazy_at_depth(path.size, self) elsif @pending_steps.nil? || @pending_steps.empty? + @field_results_are_eager = true if @field_results_are_eager.nil? if has_extensions finish_extensions else @@ -439,6 +442,7 @@ def finish_extensions end @finish_extension_idx += 1 if any_lazy_results? + @field_results_are_eager = false @runner.dataloader.lazy_at_depth(path.size, self) return end @@ -472,13 +476,24 @@ def build_results is_list = return_type.list? is_non_null = return_type.non_null? - i = 0 - s = @results.size - while i < s do - result_h = @results[i] - result = @field_results[i] - i += 1 - build_graphql_result(result_h, @key, result, return_type, is_non_null, is_list, false) + if skip_prepare_object_steps?(return_type) + i = 0 + s = @results.size + while i < s do + result_h = @results[i] + result = @field_results[i] + i += 1 + build_graphql_result_without_prepare_object_step(result_h, @key, result, return_type, is_non_null, is_list, false) + end + else + i = 0 + s = @results.size + while i < s do + result_h = @results[i] + result = @field_results[i] + i += 1 + build_graphql_result(result_h, @key, result, return_type, is_non_null, is_list, false) + end end @enqueued_authorization = true @@ -683,6 +698,50 @@ def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, end end + def build_graphql_result_without_prepare_object_step(graphql_result, key, field_result, return_type, is_nn, is_list, is_from_array) # rubocop:disable Metrics/ParameterLists + if field_result.nil? || field_result.is_a?(Finalizer) + build_graphql_result(graphql_result, key, field_result, return_type, is_nn, is_list, is_from_array) + elsif is_list + if is_nn + return_type = return_type.of_type + end + inner_type = return_type.of_type + inner_type_nn = inner_type.non_null? + inner_type_l = inner_type.list? + list_result = graphql_result[key] = [] + i = 0 + s = field_result.size + while i < s + inner_f_r = field_result[i] + build_graphql_result_without_prepare_object_step(list_result, i, inner_f_r, inner_type, inner_type_nn, inner_type_l, true) + i += 1 + end + else + if @runner.resolves_lazies + query = @selections_step.query + query.current_trace.begin_authorized(@static_type, field_result, query.context) + query.current_trace.end_authorized(@static_type, field_result, query.context, true) + end + next_result_h = {}.compare_by_identity + @all_next_results << next_result_h + @all_next_objects << field_result + @runner.static_type_at[next_result_h] = @static_type + graphql_result[key] = next_result_h + end + end + + def skip_prepare_object_steps?(return_type) + return false unless @field_results_are_eager && + !@was_scoped && + @post_processors.nil? && + @directive_finalizers.nil? && + @static_type.kind.object? && + !@runner.authorizes?(@static_type, @selections_step.query.context) + + outer_type = return_type.non_null? ? return_type.of_type : return_type + !outer_type.list? || !outer_type.of_type.list? + end + def resolve_batch(objects, context, args_hash) dyn_ins = @field_definition.dynamic_introspection method_receiver = dyn_ins ? @field_definition.owner : @parent_type diff --git a/lib/graphql/execution/input_values.rb b/lib/graphql/execution/input_values.rb index 7e9176dc26..5bb9c90817 100644 --- a/lib/graphql/execution/input_values.rb +++ b/lib/graphql/execution/input_values.rb @@ -2,6 +2,8 @@ module GraphQL module Execution class InputValues + ARGUMENT_NODES_INDEX_THRESHOLD = 32 + def initialize(query, runner) @query = query @runner = runner @@ -39,11 +41,20 @@ def argument_values(owner_defn, argument_nodes, field_resolve_step) arg_defns = @query.types.arguments(owner_defn) argument_values = {} errors = nil + argument_nodes_by_name = if argument_nodes.length >= ARGUMENT_NODES_INDEX_THRESHOLD + argument_nodes.each_with_object({}) do |arg_node, index| + index[arg_node.name] ||= arg_node + end + end arg_defns.each do |argument_definition| arg_ruby_key = argument_definition.keyword arg_graphql_key = argument_definition.graphql_name - arg_node = argument_nodes.find { |a| a.name == arg_graphql_key } + arg_node = if argument_nodes_by_name + argument_nodes_by_name[arg_graphql_key] + else + argument_nodes.find { |a| a.name == arg_graphql_key } + end if arg_node.nil? || (arg_node.value.is_a?(Language::Nodes::VariableIdentifier) && !variable_values.key?(arg_node.value.name)) if argument_definition.default_value? arg_value = value_from_ast(argument_definition.default_value, argument_definition.type) @@ -198,14 +209,13 @@ def argument_value(argument_values, argument_key, argument_definition, arg_value if argument_definition.type.list? results = Array.new(arg_value.size, nil) argument_values[argument_key] = results - arg_value.each_with_index do |inner_v, idx| - loads_step = LoadArgumentStep.new( + if !arg_value.empty? + loads_step = LoadArgumentsStep.new( field_resolve_step: field_resolve_step, load_receiver: load_receiver, - argument_value: inner_v, + argument_values: arg_value, argument_definition: argument_definition, arguments: results, - argument_key: idx, ) ps.push(loads_step) @runner.add_step(loads_step) diff --git a/lib/graphql/execution/load_arguments_step.rb b/lib/graphql/execution/load_arguments_step.rb new file mode 100644 index 0000000000..673c48d517 --- /dev/null +++ b/lib/graphql/execution/load_arguments_step.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class LoadArgumentsStep + def initialize(field_resolve_step:, arguments:, load_receiver:, argument_values:, argument_definition:) + @field_resolve_step = field_resolve_step + @load_receiver = load_receiver + @arguments = arguments + @argument_values = argument_values + @argument_definition = argument_definition + @loaded_values = Array.new(argument_values.size) + @authorization_states = Array.new(argument_values.size, true) + @errors = Array.new(argument_values.size) + @remaining_values = argument_values.size + @remaining_loads = argument_values.size + @phase = :start + @next_index = 0 + @lazy_indexes = [] + end + + def call + case @phase + when :start + @phase = :loading + enqueue_jobs(@argument_values.size) + when :loading + index = @next_index + @next_index += 1 + load_value(index) + when :resolving + index = @lazy_indexes[@next_index] + @next_index += 1 + resolve_lazy_value(index) + else + raise GraphQL::InvariantError, "Unexpected LoadArgumentsStep phase: #{@phase.inspect}" + end + nil + end + + def value + @phase = :resolving + @next_index = 0 + enqueue_jobs(@lazy_indexes.size) + nil + end + + private + + def load_value(index) + @field_resolve_step.set_current_field + context = @field_resolve_step.selections_step.query.context + begin + @loaded_values[index] = begin + @load_receiver.load_and_authorize_application_object( + @argument_definition, + @argument_values[index], + context, + ) + rescue GraphQL::UnauthorizedError => auth_err + @authorization_states[index] = false + context.schema.unauthorized_object(auth_err) + end + complete_load(index) + rescue GraphQL::RuntimeError => err + handle_runtime_error(index, err, loading: true) + complete_load(index) + rescue StandardError => stderr + handle_standard_error(index, stderr) + complete_load(index) + ensure + @field_resolve_step.set_current_field(nil) + end + end + + def resolve_lazy_value(index) + @field_resolve_step.set_current_field + schema = @field_resolve_step.runner.schema + begin + @loaded_values[index] = schema.sync_lazy(@loaded_values[index]) + complete_value(index) + rescue GraphQL::UnauthorizedError => auth_err + @authorization_states[index] = false + schema.unauthorized_object(auth_err) + rescue GraphQL::RuntimeError => err + handle_runtime_error(index, err, loading: false) + complete_value(index) + rescue StandardError => stderr + handle_standard_error(index, stderr) + complete_value(index) + ensure + @field_resolve_step.set_current_field(nil) + end + end + + def handle_runtime_error(index, error, loading:) + if error.is_a?(Schema::Subscription::EarlyUnsubscribe) + @authorization_states[index] = false if loading + @loaded_values[index] = error.unsubscribed_result + else + @loaded_values[index] = @errors[index] = error + end + end + + def handle_standard_error(index, error) + query = @field_resolve_step.selections_step.query + @loaded_values[index] = begin + query.handle_or_reraise( + error, + field: @field_resolve_step.field_definition, + arguments: @field_resolve_step.arguments, # rubocop:disable Development/ContextIsPassedCop + object: nil, + ) + rescue GraphQL::ExecutionError => execution_error + execution_error + end + end + + def record_error(index) + loaded_value = @loaded_values[index] + @errors[index] = loaded_value if loaded_value.is_a?(GraphQL::RuntimeError) + end + + def complete_load(index) + record_error(index) + load_completed(index) + end + + def complete_value(index) + record_error(index) + value_completed(index) + end + + def enqueue_jobs(count) + dataloader = @field_resolve_step.runner.dataloader + count.times { dataloader.append_job(self) } + end + + def load_completed(index) + runner = @field_resolve_step.runner + if runner.resolves_lazies && runner.lazy?(@loaded_values[index]) + @lazy_indexes << index + else + value_completed(index) + end + + @remaining_loads -= 1 + if @remaining_loads == 0 && !@lazy_indexes.empty? + @phase = :waiting + runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) + end + end + + def value_completed(index) + query = @field_resolve_step.selections_step.query + if (error = @errors[index]) + error.path = @field_resolve_step.path + @field_resolve_step.arguments = error + elsif @authorization_states[index] + loaded_value = @loaded_values[index] + query.current_trace.object_loaded(@argument_definition, loaded_value, query.context) + @arguments[index] = loaded_value + else + @field_resolve_step.arguments = EmptyObjects::EMPTY_HASH + @field_resolve_step.pending_steps.clear + @field_resolve_step.build_errors_result(nil, nil) + end + + @remaining_values -= 1 + return if @remaining_values > 0 || (!@authorization_states[index] && error.nil?) + + finish + end + + def finish + @phase = :finished + field_pending_steps = @field_resolve_step.pending_steps + field_pending_steps.delete(self) + if @field_resolve_step.arguments && field_pending_steps.empty? # rubocop:disable Development/ContextIsPassedCop + @field_resolve_step.runner.add_step(@field_resolve_step) + end + end + end + end +end diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 7dc42d4786..e26cdd68ed 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -4,6 +4,7 @@ require "graphql/execution/field_resolve_step" require "graphql/execution/finalize" require "graphql/execution/load_argument_step" +require "graphql/execution/load_arguments_step" require "graphql/execution/resolve_type_step" require "graphql/execution/runner" require "graphql/execution/selections_step" diff --git a/spec/graphql/execution/input_values_spec.rb b/spec/graphql/execution/input_values_spec.rb index ead23b3c82..94f41ef1c9 100644 --- a/spec/graphql/execution/input_values_spec.rb +++ b/spec/graphql/execution/input_values_spec.rb @@ -23,6 +23,12 @@ class Mutation < GraphQL::Schema::Object field :test_list_input, Boolean do argument :input, [TestInput, null: true], required: false end + + field :test_many_arguments, Boolean do + 8.times do |i| + argument :"arg#{i}", String, required: false + end + end end mutation(Mutation) @@ -74,6 +80,19 @@ def test_it_produces_argument_values_for_input_objects assert_equal_input( {input: { string: "a", enum: "ACTIVE" } }, input.argument_values(TestSchema.find("Mutation.testInput"), get_argument_nodes("input: { string: \"a\", enum: ACTIVE }"), nil)) end + def test_it_keeps_the_first_duplicate_argument + input = get_input_values + argument_strings = [ + 'arg0: "first"', + 'arg0: "second"', + ] + 30.times.map { |i| %(arg#{(i % 7) + 1}: "value") } + argument_nodes = get_argument_nodes(argument_strings.join(", ")) + argument_values, errors = input.argument_values(TestSchema.find("Mutation.testManyArguments"), argument_nodes, nil) + + assert_nil errors + assert_equal "first", argument_values[:arg0] + end + def assert_equal_input(expected_ruby_hash, graphql_input, path = []) if path.empty? && graphql_input.is_a?(Array) && graphql_input.last.nil? && expected_ruby_hash.is_a?(Hash) graphql_input = graphql_input.first # ignore the `nil` errors in the multiple return diff --git a/spec/graphql/execution/load_arguments_step_spec.rb b/spec/graphql/execution/load_arguments_step_spec.rb new file mode 100644 index 0000000000..7ceba03360 --- /dev/null +++ b/spec/graphql/execution/load_arguments_step_spec.rb @@ -0,0 +1,538 @@ +# frozen_string_literal: true +require "spec_helper" +require "async" if RUBY_VERSION >= "3.2.0" + +class ExecutionLoadArgumentsStepTest < Minitest::Test + Item = Struct.new(:id) + + class HookError < StandardError + end + + class LazyValue + def initialize(&block) + @block = block + end + + def value + @block.call + end + end + + class ItemType < GraphQL::Schema::Object + field :id, ID, null: false + + def self.authorized?(item, context) + context[:load_state][:authorization_calls] << item.id + !item.id.start_with?("unauthorized") + end + end + + class ItemSource < GraphQL::Dataloader::Source + def initialize(fetches) + @fetches = fetches + end + + def fetch(ids) + @fetches << ids.dup + ids.map { |id| Item.new(id) } + end + end + + module ItemLoading + def object_from_id(_type, id, context) + context[:load_state][:load_calls] << id + load_mode = if (load_modes = context[:load_modes]) + load_modes.fetch(id, context[:load_mode]) + else + context[:load_mode] + end + case load_mode + when :lazy + LazyValue.new { object_for(id) } + when :lazy_dataloader + LazyValue.new { context.dataloader.with(ItemSource, context[:load_state][:fetches]).load(id) } + when :dataloader + context.dataloader.with(ItemSource, context[:load_state][:fetches]).load(id) + else + object_for(id) + end + end + + def load_application_object_failed(error) + context[:load_state][:missing_calls] << error.id + if context[:replace_missing] + Item.new("missing-replacement:#{error.id}") + else + super + end + end + + def unauthorized_object(error) + context[:load_state][:resolver_unauthorized_calls] << error.object.id + if context[:replace_unauthorized] + Item.new("unauthorized-replacement:#{error.object.id}") + else + super + end + end + + private + + def object_for(id) + case id + when /^missing/ + nil + when /^error/ + raise GraphQL::ExecutionError, "Failed to load #{id}" + when "unsubscribe" + error = GraphQL::Schema::Subscription::EarlyUnsubscribe.new + error.unsubscribed_result = nil + raise error + else + Item.new(id) + end + end + end + + class ListResolver < GraphQL::Schema::Resolver + include ItemLoading + + type [String], null: false + argument :ids, [ID], loads: ItemType, as: :items + + def resolve(items:) + items.map(&:id) + end + end + + class ListSubscription < GraphQL::Schema::Subscription + include ItemLoading + + type [String], null: false + argument :ids, [ID], loads: ItemType, as: :items + + def subscribe(items:) + items.map(&:id) + end + + def update(items:) + items.map(&:id) + end + end + + class ScalarResolver < GraphQL::Schema::Resolver + type String, null: false + argument :id, ID, loads: ItemType, as: :item + + def object_from_id(_type, id, context) + context[:load_state][:load_calls] << id + Item.new(id) + end + + def resolve(item:) + item.id + end + end + + class Query < GraphQL::Schema::Object + field :loaded_items, resolver: ListResolver + field :loaded_item, resolver: ScalarResolver + end + + class Mutation < GraphQL::Schema::Object + field :loaded_items, resolver: ListResolver + end + + class Subscription < GraphQL::Schema::Object + field :loaded_items, subscription: ListSubscription + end + + module LoadTrace + def object_loaded(argument_definition, object, context) + context[:load_state][:trace_calls] << [argument_definition.graphql_name, object&.id] + if context[:raise_object_loaded_trace] + context[:load_state][:trace_current_fields] << GraphQL::Current.field&.path + raise HookError, "object_loaded trace failed" + end + super + end + end + + class Schema < GraphQL::Schema + query(Query) + mutation(Mutation) + subscription(Subscription) + use GraphQL::Dataloader + use GraphQL::Execution::Next + lazy_resolve(LazyValue, :value) + trace_with(LoadTrace) + + rescue_from(HookError) do |error, _object, _arguments, context, _field| + context[:load_state][:handled_errors] << [error.message, GraphQL::Current.field&.path] + raise GraphQL::ExecutionError, "Handled: #{error.message}" + end + + def self.resolve_type(_type, _object, _context) + ItemType + end + + def self.unauthorized_object(error) + error.context[:load_state][:schema_unauthorized_calls] << error.object.id + if error.context[:raise_unauthorized_hook] + raise HookError, "unauthorized hook failed" + end + nil + end + end + + class NullDataloaderSchema < Schema + self.dataloader_class = GraphQL::Dataloader::NullDataloader + end + + if RUBY_VERSION >= "3.2.0" + class AsyncDataloaderSchema < Schema + use GraphQL::Dataloader::AsyncDataloader + end + end + + class LegacyListLoadStep + def initialize(field_resolve_step:, arguments:, load_receiver:, argument_values:, argument_definition:) + @field_resolve_step = field_resolve_step + @arguments = arguments + @load_receiver = load_receiver + @argument_values = argument_values + @argument_definition = argument_definition + end + + def call + pending_steps = @field_resolve_step.pending_steps + pending_steps.delete(self) + @argument_values.each_with_index do |argument_value, index| + step = GraphQL::Execution::LoadArgumentStep.new( + field_resolve_step: @field_resolve_step, + arguments: @arguments, + load_receiver: @load_receiver, + argument_value: argument_value, + argument_definition: @argument_definition, + argument_key: index, + ) + pending_steps << step + @field_resolve_step.runner.add_step(step) + end + if pending_steps.empty? + @field_resolve_step.runner.add_step(@field_resolve_step) + end + nil + end + end + + QUERY = "query($ids: [ID!]!) { loadedItems(ids: $ids) }" + MUTATION = "mutation($ids: [ID!]!) { loadedItems(ids: $ids) }" + SUBSCRIPTION = "subscription($ids: [ID!]!) { loadedItems(ids: $ids) }" + + def test_eager_list_loads_match_individual_steps + [0, 1, 10, 100].each do |size| + ids = size.times.map(&:to_s) + result = assert_matches_individual_steps(ids) + + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal ids, result[:load_calls] + assert_equal ids, result[:trace_calls].map(&:last) + end + end + + def test_lazy_list_loads_match_individual_steps + [1, 10, 100].each do |size| + ids = size.times.map(&:to_s) + result = assert_matches_individual_steps(ids, load_mode: :lazy) + + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal ids, result[:load_calls] + assert_equal ids, result[:trace_calls].map(&:last) + end + end + + def test_mixed_eager_and_lazy_values_use_completion_order + lazy_first = assert_matches_individual_steps( + ["lazy", "eager"], + load_modes: { "lazy" => :lazy }, + ) + assert_equal ["eager", "lazy"], lazy_first[:trace_calls].map(&:last) + + lazy_last = assert_matches_individual_steps( + ["eager", "lazy"], + load_modes: { "lazy" => :lazy }, + ) + assert_equal ["eager", "lazy"], lazy_last[:trace_calls].map(&:last) + end + + def test_mixed_eager_and_lazy_errors_use_completion_order + result = assert_matches_individual_steps( + ["error-first", "error-last"], + load_modes: { "error-first" => :lazy }, + ) + + assert_equal ["Failed to load error-first"], result[:result]["errors"].map { |error| error["message"] } + end + + def test_mixed_unauthorized_and_error_values_match_individual_steps + eager_unauthorized = assert_matches_individual_steps( + ["unauthorized-value", "error-value"], + load_modes: { "error-value" => :lazy }, + ) + assert_nil eager_unauthorized[:result].dig("data", "loadedItems") + assert_equal ["unauthorized-value"], eager_unauthorized[:schema_unauthorized_calls] + + lazy_unauthorized = assert_matches_individual_steps( + ["error-value", "unauthorized-value"], + load_modes: { "unauthorized-value" => :lazy }, + ) + assert_nil lazy_unauthorized[:result].dig("data", "loadedItems") + assert_equal ["unauthorized-value"], lazy_unauthorized[:schema_unauthorized_calls] + end + + def test_duplicate_ids_keep_hook_and_result_order_and_dataloader_batching + ids = ["2", "1", "2", "3", "1"] + eager_result = assert_matches_individual_steps(ids) + result = execute_list(ids, load_mode: :dataloader) + + assert_equal ids, eager_result[:load_calls] + assert_equal ids, eager_result[:result].dig("data", "loadedItems") + assert_equal ids, result[:load_calls] + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal [["2", "1", "3"]], result[:fetches] + end + + def test_lazy_values_keep_dataloader_batching + ids = ["3", "2", "1"] + result = assert_matches_individual_steps(ids, load_mode: :lazy_dataloader) + + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal [ids], result[:fetches] + end + + if RUBY_VERSION >= "3.2.0" + def test_mixed_eager_and_lazy_values_match_individual_steps_with_async_dataloader + ids = ["lazy", "eager"] + result = assert_matches_individual_steps( + ids, + schema: AsyncDataloaderSchema, + load_modes: { "lazy" => :lazy }, + ) + + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal ids, result[:load_calls] + assert_equal ["eager", "lazy"], result[:trace_calls].map(&:last) + assert_empty result[:fetches] + end + + def test_list_loads_keep_one_batch_with_async_dataloader + ids = ["3", "2", "1"] + result = assert_matches_individual_steps( + ids, + schema: AsyncDataloaderSchema, + load_mode: :lazy_dataloader, + ) + + assert_equal ids, result[:result].dig("data", "loadedItems") + assert_equal ids, result[:load_calls] + assert_equal ids, result[:trace_calls].map(&:last) + assert_equal [ids], result[:fetches] + end + end + + def test_eager_and_lazy_values_work_with_null_dataloader + ids = ["1", "2", "3"] + + eager = execute_list(ids, schema: NullDataloaderSchema) + lazy = execute_list(ids, schema: NullDataloaderSchema, load_mode: :lazy) + + assert_equal ids, eager[:result].dig("data", "loadedItems") + assert_equal ids, lazy[:result].dig("data", "loadedItems") + end + + def test_missing_and_execution_errors_select_the_same_error + missing = assert_matches_individual_steps(["missing-first", "ok", "missing-last"]) + assert_equal ["No object found for `ids: \"missing-last\"`"], missing[:result]["errors"].map { |error| error["message"] } + assert_equal ["missing-first", "missing-last"], missing[:missing_calls] + + failed = assert_matches_individual_steps(["error-first", "ok", "error-last"]) + assert_equal ["Failed to load error-last"], failed[:result]["errors"].map { |error| error["message"] } + + lazy_failed = assert_matches_individual_steps(["error-first", "ok", "error-last"], load_mode: :lazy) + assert_equal ["Failed to load error-last"], lazy_failed[:result]["errors"].map { |error| error["message"] } + end + + def test_missing_and_unauthorized_values_can_be_replaced + missing = assert_matches_individual_steps(["ok", "missing-value"], replace_missing: true) + assert_equal ["ok", "missing-replacement:missing-value"], missing[:result].dig("data", "loadedItems") + + unauthorized = assert_matches_individual_steps(["ok", "unauthorized-value"], replace_unauthorized: true) + assert_equal ["ok", "unauthorized-replacement:unauthorized-value"], unauthorized[:result].dig("data", "loadedItems") + assert_equal ["unauthorized-value"], unauthorized[:resolver_unauthorized_calls] + + lazy_unauthorized = assert_matches_individual_steps(["ok", "unauthorized-value"], load_mode: :lazy, replace_unauthorized: true) + assert_equal ["ok", "unauthorized-replacement:unauthorized-value"], lazy_unauthorized[:result].dig("data", "loadedItems") + end + + def test_unhandled_unauthorized_and_early_unsubscribe_match_individual_steps + unauthorized = assert_matches_individual_steps(["ok", "unauthorized-value"]) + assert_nil unauthorized[:result].dig("data", "loadedItems") + assert_equal ["unauthorized-value"], unauthorized[:schema_unauthorized_calls] + + unsubscribed = assert_matches_individual_steps(["ok", "unsubscribe"]) + assert_nil unsubscribed[:result].dig("data", "loadedItems") + end + + def test_unauthorized_hook_errors_use_query_error_handling + result = assert_matches_individual_steps( + ["unauthorized-value"], + raise_unauthorized_hook: true, + ) + + assert_equal ["Handled: unauthorized hook failed"], result[:result]["errors"].map { |error| error["message"] } + assert_equal [["unauthorized hook failed", "Query.loadedItems"]], result[:handled_errors] + end + + def test_object_loaded_trace_errors_keep_current_field_and_use_query_error_handling + [{}, { load_mode: :lazy }].each do |context_values| + result = assert_matches_individual_steps( + ["loaded-value"], + raise_object_loaded_trace: true, + **context_values, + ) + + assert_equal ["Handled: object_loaded trace failed"], result[:result]["errors"].map { |error| error["message"] } + assert_equal ["Query.loadedItems"], result[:trace_current_fields] + assert_equal [["object_loaded trace failed", "Query.loadedItems"]], result[:handled_errors] + end + end + + def test_one_hundred_values_create_one_framework_load_step + batch_step_count = 0 + scalar_step_count = 0 + batch_new = GraphQL::Execution::LoadArgumentsStep.method(:new) + scalar_new = GraphQL::Execution::LoadArgumentStep.method(:new) + batch_factory = ->(**kwargs) { + batch_step_count += 1 + batch_new.call(**kwargs) + } + scalar_factory = ->(**kwargs) { + scalar_step_count += 1 + scalar_new.call(**kwargs) + } + + GraphQL::Execution::LoadArgumentsStep.stub(:new, batch_factory) do + GraphQL::Execution::LoadArgumentStep.stub(:new, scalar_factory) do + execute_list(100.times.map(&:to_s)) + end + end + + assert_equal 1, batch_step_count + assert_equal 0, scalar_step_count + end + + def test_scalar_loads_keep_the_individual_step + batch_step_count = 0 + scalar_step_count = 0 + batch_new = GraphQL::Execution::LoadArgumentsStep.method(:new) + scalar_new = GraphQL::Execution::LoadArgumentStep.method(:new) + + result = GraphQL::Execution::LoadArgumentsStep.stub(:new, ->(**kwargs) { batch_step_count += 1; batch_new.call(**kwargs) }) do + GraphQL::Execution::LoadArgumentStep.stub(:new, ->(**kwargs) { scalar_step_count += 1; scalar_new.call(**kwargs) }) do + execute_scalar("5") + end + end + + assert_equal "5", result[:result].dig("data", "loadedItem") + assert_equal 0, batch_step_count + assert_equal 1, scalar_step_count + end + + def test_mutation_list_loads_match_individual_steps + ids = ["lazy", "eager"] + expected = with_individual_load_steps do + execute(MUTATION, variables: { "ids" => ids }, schema: Schema, load_modes: { "lazy" => :lazy }) + end + actual = execute(MUTATION, variables: { "ids" => ids }, schema: Schema, load_modes: { "lazy" => :lazy }) + + assert_equal expected, actual + assert_equal ids, actual[:result].dig("data", "loadedItems") + end + + def test_subscription_update_early_unsubscribe_matches_individual_steps + ids = ["ok", "missing-value"] + expected = with_individual_load_steps { execute_subscription_update(ids) } + actual = execute_subscription_update(ids) + + assert_equal expected, actual + assert actual[:unsubscribed] + end + + private + + def assert_matches_individual_steps(ids, **context_values) + expected = with_individual_load_steps { execute_list(ids, **context_values) } + actual = execute_list(ids, **context_values) + assert_equal expected, actual + actual + end + + def with_individual_load_steps + GraphQL::Execution::LoadArgumentsStep.stub(:new, ->(**kwargs) { LegacyListLoadStep.new(**kwargs) }) do + yield + end + end + + def execute_list(ids, schema: Schema, **context_values) + execute(QUERY, variables: { "ids" => ids }, schema: schema, **context_values) + end + + def execute_scalar(id, schema: Schema, **context_values) + execute("query($id: ID!) { loadedItem(id: $id) }", variables: { "id" => id }, schema: schema, **context_values) + end + + def execute_subscription_update(ids) + field = Schema.subscription.fields["loadedItems"] + topic = GraphQL::Subscriptions::Event.serialize( + "loadedItems", + { ids: ids }, + field, + scope: nil, + ) + result = execute( + SUBSCRIPTION, + variables: { "ids" => ids }, + schema: Schema, + subscription_topic: topic, + ) + result[:unsubscribed] = result.delete(:subscriptions)[:unsubscribed] + result + end + + def execute(query, variables:, schema:, subscription_topic: nil, **context_values) + load_state = { + load_calls: [], + authorization_calls: [], + missing_calls: [], + resolver_unauthorized_calls: [], + schema_unauthorized_calls: [], + trace_calls: [], + trace_current_fields: [], + handled_errors: [], + fetches: [], + } + context = { load_state: load_state, **context_values } + result = schema.execute_next( + query, + variables: variables, + context: context, + subscription_topic: subscription_topic, + ) + { + result: result.to_h, + subscriptions: result.context.namespace(:subscriptions), + **load_state, + } + end +end diff --git a/spec/graphql/execution/prepare_object_step_spec.rb b/spec/graphql/execution/prepare_object_step_spec.rb new file mode 100644 index 0000000000..69a1a1cdf5 --- /dev/null +++ b/spec/graphql/execution/prepare_object_step_spec.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Execution::PrepareObjectStep do + module PrepareObjectStepSpec + ItemData = Struct.new(:name, keyword_init: true) + + class LazyResult + def initialize(value) + @value = value + end + + def value + @value + end + end + + module Node + include GraphQL::Schema::Interface + field :name, String, null: false + end + + class Item < GraphQL::Schema::Object + implements Node + field :name, String, null: false + end + + class AuthorizedItem < GraphQL::Schema::Object + field :name, String, null: false + + def self.authorized?(object, context) + context[:authorization_values] << object.name + true + end + end + + class ScopedItem < GraphQL::Schema::Object + field :name, String, null: false + reauthorize_scoped_objects(false) + + def self.scope_items(items, context) + context[:scope_calls] += 1 + items + end + + def self.authorized?(object, context) + context[:authorization_values] << object.name + true + end + end + + class EagerExtension < GraphQL::Schema::FieldExtension + def after_resolve(context:, value: nil, values: nil, **) + context[:extension_calls] += 1 + values || value + end + end + + class RuntimeDirective < GraphQL::Schema::Directive + graphql_name "prepareObjectStepRuntime" + locations FIELD + + def self.resolve(object, arguments, context) + context[:directive_calls] += 1 + yield + end + + def self.resolve_field(_ast_nodes, _parent_type, _field_definition, _objects, _arguments, context) + context[:directive_calls] += 1 + nil + end + end + + class PostProcessorDirective < GraphQL::Schema::Directive + graphql_name "prepareObjectStepPostProcessor" + locations FIELD + + def self.resolve_field(*) + Processor.new + end + + class Processor + include GraphQL::Execution::PostProcessor + + def after_resolve(field_results) + field_results + end + end + end + + class FinalizerDirective < GraphQL::Schema::Directive + graphql_name "prepareObjectStepFinalizer" + locations FIELD + repeatable true + argument :label, String + + def self.resolve_field(_ast_nodes, _parent_type, _field_definition, _objects, arguments, _context) + Recorder.new(arguments[:label]) + end + + class Recorder + include GraphQL::Execution::Finalizer + + def initialize(label) + @label = label + end + + def finalize_graphql_result(query, _result_data, result_key) + query.context[:finalizer_calls] << [@label, path.dup, result_key] + end + end + end + + class Query < GraphQL::Schema::Object + field :items, [Item], null: false, scope: false, resolve_legacy_instance_method: true + field :authorized_items, [AuthorizedItem], null: false, scope: false, resolve_legacy_instance_method: true + field :lazy_items, [Item], null: false, scope: false, resolve_legacy_instance_method: true + field :node, Node, null: false, resolve_legacy_instance_method: true + field :nested_items, [[Item]], null: false, scope: false, resolve_legacy_instance_method: true + field :scoped_items, [ScopedItem], null: false, scope: true, resolve_legacy_instance_method: true + field :extended_items, [Item], null: false, scope: false, extensions: [EagerExtension], resolve_legacy_instance_method: true + field :items_connection, Item.connection_type, null: false, resolve_legacy_instance_method: true + field :raw_item, Item, null: false, resolve_legacy_instance_method: true + field :runtime_error_item, Item, resolve_legacy_instance_method: true + field :invalid_items, [Item, null: true], null: false, scope: false, resolve_legacy_instance_method: true + + def items + context[:items] + end + + def authorized_items + context[:items] + end + + def lazy_items + LazyResult.new(context[:items]) + end + + def node + context[:items].first + end + + def nested_items + [context[:items]] + end + + def scoped_items + context[:items] + end + + def extended_items + context[:items] + end + + def items_connection + context[:items] + end + + def raw_item + raw_value({ "name" => "finalized" }) + end + + def runtime_error_item + GraphQL::ExecutionError.new("object failed") + end + + def invalid_items + [ItemData.new(name: nil)] + end + end + + module Trace + def objects(type, objects, context) + context[:object_batches] << [type.graphql_name, objects.size] + super + end + + def begin_authorized(type, object, context) + if type != Query + object_name = object.respond_to?(:name) ? object.name : object.class.name + context[:authorization_trace] << [:begin, type.graphql_name, object_name] + end + super + end + + def end_authorized(type, object, context, authorized_result) + if type != Query + object_name = object.respond_to?(:name) ? object.name : object.class.name + context[:authorization_trace] << [:end, type.graphql_name, object_name, authorized_result] + end + super + end + + def begin_resolve_type(type, object, context) + context[:resolve_type_trace] << [type.graphql_name, object.name] + super + end + end + + class Schema < GraphQL::Schema + query(Query) + directive(RuntimeDirective) + directive(PostProcessorDirective) + directive(FinalizerDirective) + lazy_resolve(LazyResult, :value) + trace_with(Trace) + use GraphQL::Execution::Next + + def self.resolve_type(_abstract_type, _object, _context) + Item + end + end + + class SchemaWithoutLazyResolver < GraphQL::Schema + query(Query) + trace_with(Trace) + use GraphQL::Execution::Next + end + end + + def test_context + { + items: [ + PrepareObjectStepSpec::ItemData.new(name: "One"), + PrepareObjectStepSpec::ItemData.new(name: "Two"), + ], + authorization_values: [], + authorization_trace: [], + resolve_type_trace: [], + object_batches: [], + scope_calls: 0, + extension_calls: 0, + directive_calls: 0, + finalizer_calls: [], + } + end + + def execute_next_with_step_count(query, context: test_context, schema: PrepareObjectStepSpec::Schema) + prepare_object_step_count = 0 + original_new = GraphQL::Execution::PrepareObjectStep.method(:new) + counting_new = ->(*args, **kwargs) do + prepare_object_step_count += 1 + original_new.call(*args, **kwargs) + end + result = GraphQL::Execution::PrepareObjectStep.stub(:new, counting_new) do + schema.execute_next(query, context: context) + end + [result, prepare_object_step_count] + end + + def assert_matches_legacy(query, schema: PrepareObjectStepSpec::Schema) + legacy_context = test_context + next_context = test_context + legacy_result = schema.execute(query, context: legacy_context) + next_result, step_count = execute_next_with_step_count(query, context: next_context, schema: schema) + assert_graphql_equal legacy_result.to_h, next_result.to_h + [next_result, step_count, legacy_context, next_context] + end + + it "preserves authorization traces when the schema has a lazy resolver" do + result, step_count, legacy_context, next_context = assert_matches_legacy("{ items { name } }") + + assert_equal 0, step_count + assert_equal ["One", "Two"], result["data"]["items"].map { |item| item["name"] } + assert_equal [["Query", 1], ["Item", 2]], next_context[:object_batches] + assert_equal legacy_context[:authorization_trace], next_context[:authorization_trace] + assert_equal [ + [:begin, "Item", "One"], + [:end, "Item", "One", true], + [:begin, "Item", "Two"], + [:end, "Item", "Two", true], + ], next_context[:authorization_trace] + end + + it "does not add authorization traces when the schema has no lazy resolver" do + schema = PrepareObjectStepSpec::SchemaWithoutLazyResolver + result, step_count, _legacy_context, next_context = assert_matches_legacy("{ items { name } }", schema: schema) + + refute schema.resolves_lazies? + assert_equal 0, step_count + assert_equal ["One", "Two"], result["data"]["items"].map { |item| item["name"] } + assert_empty next_context[:authorization_trace] + end + + it "keeps object preparation for custom authorization" do + _result, step_count, legacy_context, next_context = assert_matches_legacy("{ authorizedItems { name } }") + + assert_equal 2, step_count + assert_equal legacy_context[:authorization_values], next_context[:authorization_values] + assert_equal legacy_context[:authorization_trace], next_context[:authorization_trace] + end + + it "keeps object preparation for lazy results" do + _result, step_count = execute_next_with_step_count("{ lazyItems { name } }") + + assert_equal 2, step_count + end + + it "keeps object preparation for abstract types" do + _result, step_count, legacy_context, next_context = assert_matches_legacy("{ node { name } }") + + assert_equal 1, step_count + assert_equal legacy_context[:resolve_type_trace], next_context[:resolve_type_trace] + end + + it "keeps object preparation for nested lists" do + _result, step_count = execute_next_with_step_count("{ nestedItems { name } }") + + assert_equal 2, step_count + end + + it "keeps object preparation for scoped results" do + _result, step_count, legacy_context, next_context = assert_matches_legacy("{ scopedItems { name } }") + + assert_equal 2, step_count + assert_equal legacy_context[:scope_calls], next_context[:scope_calls] + assert_empty next_context[:authorization_values] + end + + it "uses the fast path after eager extensions and runtime directives" do + query = <<~GRAPHQL + { + extendedItems { name } + items @prepareObjectStepRuntime { name } + } + GRAPHQL + _result, step_count, legacy_context, next_context = assert_matches_legacy(query) + + assert_equal 0, step_count + assert_equal legacy_context[:extension_calls], next_context[:extension_calls] + assert_equal legacy_context[:directive_calls], next_context[:directive_calls] + end + + it "keeps object preparation for post-processed results" do + query = "{ items @prepareObjectStepPostProcessor { name } }" + _result, step_count = assert_matches_legacy(query) + + assert_equal 2, step_count + end + + it "keeps object preparation for directive finalizers" do + query = <<~GRAPHQL + { + items @prepareObjectStepFinalizer(label: "first") @prepareObjectStepFinalizer(label: "second") { name } + } + GRAPHQL + _result, step_count, _legacy_context, next_context = assert_matches_legacy(query) + + assert_equal 2, step_count + assert_equal [ + ["first", ["items"], nil], + ["second", ["items"], nil], + ], next_context[:finalizer_calls] + end + + it "preserves connection handling" do + _result, step_count = assert_matches_legacy("{ itemsConnection(first: 1) { nodes { name } } }") + + assert_operator step_count, :>, 0 + end + + it "preserves finalizers and runtime errors" do + query = "{ rawItem { name } runtimeErrorItem { name } }" + result, step_count = execute_next_with_step_count(query) + + assert_equal 0, step_count + assert_equal({ "rawItem" => { "name" => "finalized" }, "runtimeErrorItem" => nil }, result["data"]) + assert_equal ["object failed"], result["errors"].map { |error| error["message"] } + end + + it "preserves non-null propagation" do + result, step_count, _legacy_context, _next_context = assert_matches_legacy("{ invalidItems { name } }") + + assert_equal 0, step_count + assert_nil result["data"]["invalidItems"].first + assert_equal [["invalidItems", 0, "name"]], result["errors"].map { |error| error["path"] } + end +end