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/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 ca7845b05b..75436c39ae 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -6,15 +6,93 @@ 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 - # @return [Object] The Ruby-ready value for this Argument - attr_reader :value + 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 GraphQL::ExecutionError => exec_err + @state = :finished + context = @arguments.context + exec_err.path ||= context.current_path + exec_err.ast_node ||= @ast_node + @value = exec_err + rescue StandardError => err + @state = :finished + context = @arguments.context + @value = context.schema.handle_or_reraise(context, err) + end + + # @private implements Dataloader API + def call + 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) } + @arguments.context.dataloader.yield # TODO hack to wait for other work to finish + end + end + @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 GraphQL::ExecutionError => exec_err + @state = :finished + exec_err.path ||= context.current_path + exec_err.ast_node ||= @ast_node + @value = exec_err + rescue StandardError => err + @state = :finished + context = @arguments.context + @value = context.schema.handle_or_reraise(context, err) + end + + def completed? + @state == :finished + end + + def errored? + @value.is_a?(StandardError) + end + + attr_reader :state + # # @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/execution/interpreter/arguments.rb b/lib/graphql/execution/interpreter/arguments.rb index 4da23f25d4..1122da4dc2 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,27 +40,59 @@ 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}] 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 + # 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..ce56b51176 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -362,14 +362,25 @@ 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| + arguments = field_defn.create_runtime_arguments(owner_object, context, ast_node) + @dataloader.append_job { + 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 = 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/query/context.rb b/lib/graphql/query/context.rb index 587148a556..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 @@ -84,7 +83,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/argument.rb b/lib/graphql/schema/argument.rb index b85fd2ffc5..222606edfd 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 @@ -354,15 +355,15 @@ 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) + rescue GraphQL::ExecutionError => exec_err + loaded_values[idx] = exec_err + 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/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 98b36a8de6..cba8c0d312 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -247,6 +247,48 @@ def argument_class(new_arg_class = nil) self.class.argument_class(new_arg_class) end + 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| + 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 = argument_defn.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, + ast_node: ast_node, + ) + 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/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 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 diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 24187dca03..90a7428a8a 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -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}.inspect, res["data"].fetch("field")) end it "handles applies authorization even when a custom load method is provided" do