diff --git a/lib/graphql/execution/field_resolve_step.rb b/lib/graphql/execution/field_resolve_step.rb index ead37d1f71..3a33375101 100644 --- a/lib/graphql/execution/field_resolve_step.rb +++ b/lib/graphql/execution/field_resolve_step.rb @@ -48,173 +48,6 @@ def append_selection(ast_node) nil end - def coerce_arguments(argument_owner, ast_arguments_or_hash, run_loads = true) - arg_defns = @selections_step.query.types.arguments(argument_owner) - if arg_defns.empty? - return EmptyObjects::EMPTY_HASH - end - args_hash = {} - - if ast_arguments_or_hash.nil? # This can happen with `.trigger` - return args_hash - end - - arg_inputs_are_h = ast_arguments_or_hash.is_a?(Hash) - - arg_defns.each do |arg_defn| - arg_value = nil - was_found = false - if arg_inputs_are_h - ast_arguments_or_hash.each do |key, value| - if key == arg_defn.keyword || key.to_s == arg_defn.graphql_name - arg_value = value - was_found = true - break - end - end - else - ast_arguments_or_hash.each do |arg_node| - if arg_node.name == arg_defn.graphql_name - arg_value = arg_node.value - was_found = true - break - end - end - end - - if arg_value.is_a?(Language::Nodes::VariableIdentifier) - vars = @selections_step.query.variables - arg_value = if vars.key?(arg_value.name) - vars[arg_value.name] - elsif vars.key?(arg_value.name.to_sym) - vars[arg_value.name.to_sym] - else - was_found = false - nil - end - end - - if !was_found && arg_defn.default_value? - was_found = true - arg_value = arg_defn.default_value - end - - if was_found - coerce_argument_value(args_hash, arg_defn, arg_value, run_loads) - end - end - - args_hash - end - - def coerce_argument_value(arguments, arg_defn, arg_value, run_loads, target_keyword: run_loads ? arg_defn.keyword : arg_defn.graphql_name, 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) - vars = @selections_step.query.variables - arg_value = if vars.key?(arg_value.name) - vars[arg_value.name] - elsif vars.key?(arg_value.name.to_sym) - vars[arg_value.name.to_sym] - else - nil - end - end - - if arg_value.is_a?(Language::Nodes::NullValue) - arg_value = nil - elsif arg_value.is_a?(Language::Nodes::Enum) - arg_value = arg_value.name - end - - ctx = @selections_step.query.context - arg_value = if arg_t.list? - if arg_value.nil? - arg_value - else - arg_value = Array(arg_value) - inner_t = arg_t.of_type - result = Array.new(arg_value.size) - arg_value.each_with_index { |v, i| coerce_argument_value(result, arg_defn, v, run_loads, target_keyword: i, as_type: inner_t) } - result - end - elsif arg_t.kind.leaf? - begin - arg_t.coerce_input(arg_value, ctx) - rescue GraphQL::UnauthorizedEnumValueError => enum_err - begin - @runner.schema.unauthorized_object(enum_err) - rescue GraphQL::ExecutionError => ex_err - ex_err - end - end - elsif arg_t.kind.input_object? - input_obj_vals = arg_value.is_a?(Language::Nodes::InputObject) ? arg_value.arguments : arg_value # rubocop:disable Development/ContextIsPassedCop - input_obj_args = coerce_arguments(arg_t, input_obj_vals) - arg_t.new(nil, ruby_kwargs: input_obj_args, context: @selections_step.query.context, defaults_used: nil) - else - raise "Unsupported argument value: #{arg_t.to_type_signature} / #{arg_value.class} (#{arg_value.inspect})" - end - - if as_type.nil? # only on root arguments, not list elements - arg_value = begin - begin - arg_defn.prepare_value(nil, arg_value, context: ctx) - rescue StandardError => err - @runner.schema.handle_or_reraise(ctx, err) - end - rescue GraphQL::ExecutionError => exec_err - exec_err - end - end - - if arg_value.is_a?(GraphQL::RuntimeError) - @arguments = arg_value - elsif run_loads && 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 def value query = @selections_step.query query.current_trace.begin_execute_field(@field_definition, @arguments, @field_results, query) @@ -280,7 +113,7 @@ def build_arguments query = @selections_step.query field_name = @ast_node.name @field_definition = query.types.field(@parent_type, field_name) || raise("Invariant: no field found for #{@parent_type.to_type_signature}.#{ast_node.name}") - arguments = coerce_arguments(@field_definition, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop + arguments = @runner.input_values[query].argument_values(@field_definition, @ast_node.arguments, self) # rubocop:disable Development/ContextIsPassedCop @arguments ||= arguments # may have already been set to an error if (@pending_steps.nil? || @pending_steps.size == 0) && @@ -389,7 +222,7 @@ def execute_field if (dir_defn = @runner.runtime_directives[dir_node.name]) # TODO: `coerce_arguments` modifies self, assuming it's field arguments. Extract to pure function for use # here and with fragments. - dir_args = coerce_arguments(dir_defn, dir_node.arguments, false) # rubocop:disable Development/ContextIsPassedCop + dir_args = @runner.input_values[query].argument_values(dir_defn, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop result = dir_defn.resolve_field(ast_nodes, @parent_type, field_definition, authorized_objects, dir_args, ctx) if !result.nil? if result.is_a?(Finalizer) diff --git a/lib/graphql/execution/input_values.rb b/lib/graphql/execution/input_values.rb new file mode 100644 index 0000000000..5d8213a182 --- /dev/null +++ b/lib/graphql/execution/input_values.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class InputValues + def initialize(query, runner) + @query = query + @runner = runner + @variables = query.variables + @variable_values = nil + end + + def variable_values + @variable_values ||= begin + variable_nodes = @query.selected_operation.variables + if variable_nodes.empty? + EmptyObjects::EMPTY_HASH + else + raw_values = @query.provided_variables + values = {} + variable_nodes.each do |var_node| + var_ast_value = if raw_values.key?(var_node.name) + raw_values[var_node.name] + elsif raw_values.key?(sym_name = var_node.name.to_sym) + raw_values[sym_name] + elsif !var_node.default_value.nil? + var_node.default_value + else + nil + end + + var_type = @runner.schema.type_from_ast(var_node.type, context: @query.context) + values[var_node.name] = variable_value(var_ast_value, var_type) + end + values + end + end + end + + def argument_values(owner_defn, argument_nodes, field_resolve_step) + arg_defns = @query.types.arguments(owner_defn) + argument_values = {} + + 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 } + if arg_node.nil? + if argument_definition.default_value? + argument_value(argument_values, arg_ruby_key, argument_definition, argument_definition.default_value, nil, field_resolve_step) + end + else + argument_value(argument_values, arg_ruby_key, argument_definition, arg_node.value, nil, field_resolve_step) + end + end + + argument_values + end + + private + + def variable_value(value, type) + if type.non_null? + type = type.of_type + end + + if value.is_a?(Language::Nodes::Enum) + value = value.name + end + + if value.nil? + nil + elsif type.list? + inner_type = type.of_type + if value.is_a?(Array) + value.map { |v| variable_value(v, inner_type) }.freeze + else + [variable_value(value, inner_type)].freeze + end + elsif type.kind.input_object? + coerced_obj = {} + + @query.types.arguments(type).each do |arg| + arg_key = arg.keyword + if value.key?(arg.graphql_name) + arg_value = value[arg.graphql_name] + elsif value.key?(sym_name = arg.graphql_name.to_sym) + arg_value = value[sym_name] + elsif arg.default_value? + coerced_obj[arg_key] = arg.default_value # todo coerce + next + else + next + end + + coerced_obj[arg_key] = variable_value(arg_value, arg.type) + end + + coerced_obj.freeze + elsif type.kind.leaf? + result = begin + type.coerce_input(value, @query.context) + rescue GraphQL::ExecutionError => e + e + end + + result + else + raise InputCoercionError, "Unexpected input type: #{type.graphql_name}." + end + end + + def argument_value(argument_values, argument_key, argument_definition, arg_value, override_type, field_resolve_step) + treat_as_type = override_type || argument_definition.type + if treat_as_type.non_null? + treat_as_type = treat_as_type.of_type + end + + arg_value = value_from_ast(arg_value, treat_as_type) + + if treat_as_type.kind.list? && !arg_value.nil? + inner_t = treat_as_type.unwrap + arg_value = if arg_value.is_a?(Array) + values = Array.new(arg_value.size) + arg_value.each_with_index { |inner_v, idx| argument_value(values, idx, argument_definition, inner_v, inner_t, field_resolve_step)} + values + else + values = [nil] + argument_value(values, 0, argument_definition, arg_value, inner_t, field_resolve_step) + values + end + end + + if override_type.nil? # only on root arguments, not list elements + arg_value = begin + begin + argument_definition.prepare_value(nil, arg_value, context: @query.context) + rescue StandardError => err + @runner.schema.handle_or_reraise(@query.context, err) + end + rescue GraphQL::ExecutionError => exec_err + exec_err + end + end + + if arg_value && treat_as_type.kind.input_object? + arg_defns = @query.types.arguments(treat_as_type) + arg_value = arg_value.dup + arg_defns.each do |inner_arg_defn| + inner_arg_key = inner_arg_defn.keyword + inner_arg_value = arg_value[inner_arg_key] + if !inner_arg_value.nil? + argument_value(arg_value, inner_arg_key, inner_arg_defn, inner_arg_value, nil, field_resolve_step) + end + end + end + + if field_resolve_step && arg_value && override_type.nil? && argument_definition.loads + field_defn = field_resolve_step.field_definition + load_receiver = if (r = field_defn.resolver) + r.new(field: field_defn, context: @query.context, object: nil) + else + field_defn + end + ps = field_resolve_step.pending_steps ||= [] + + 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( + field_resolve_step: field_resolve_step, + load_receiver: load_receiver, + argument_value: inner_v, + argument_definition: argument_definition, + arguments: results, + argument_key: idx, + ) + ps.push(loads_step) + @runner.add_step(loads_step) + end + else + loads_step = LoadArgumentStep.new( + field_resolve_step: field_resolve_step, + load_receiver: load_receiver, + argument_value: arg_value, + argument_definition: argument_definition, + arguments: argument_values, + argument_key: argument_key, + ) + ps.push(loads_step) + @runner.add_step(loads_step) + end + else + argument_values[argument_key] = arg_value + end + nil + end + + def value_from_ast(value_node, type) + if type.non_null? + type = type.of_type + end + + if value_node.nil? + nil + elsif value_node.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + variable_values[value_node.name] + elsif value_node.is_a?(GraphQL::Language::Nodes::NullValue) + nil + elsif type.list? + inner_type = type.of_type + if value_node.is_a?(Array) + coerced_items = value_node.map do |inner_value_node| + value_from_ast(inner_value_node, inner_type) + end + coerced_items.freeze + else + item_value = value_from_ast(value_node, inner_type) + [item_value].freeze + end + + elsif type.kind.input_object? + coerced_obj = {} + arg_nodes_by_name = value_node.arguments.each_with_object({}) do |arg_node, acc| # rubocop:disable Development/ContextIsPassedCop + acc[arg_node.name] = arg_node + end + + @query.types.arguments(type).each do |arg| + arg_node = arg_nodes_by_name[arg.graphql_name] + arg_key = arg.keyword + if arg_node.nil? + if arg.default_value? + coerced_obj[arg_key] = arg.default_value + end + next + end + + arg_value = value_from_ast(arg_node.value, arg.type) + coerced_obj[arg_key] = arg_value + end + + coerced_obj + elsif type.kind.leaf? + if type.kind.enum? + if value_node.is_a?(GraphQL::Language::Nodes::Enum) + value_node = value_node.name + end + end + + begin + type.coerce_input(value_node, @query.context) + rescue GraphQL::UnauthorizedEnumValueError => enum_err + begin + @runner.schema.unauthorized_object(enum_err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + rescue GraphQL::ExecutionError => exec_err + exec_err + end + else + raise "Unexpected input type: #{type.to_type_signature}." + end + end + end + end +end diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 3b413ca1ff..cf32b23fec 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require "graphql/execution/prepare_object_step" +require "graphql/execution/input_values" require "graphql/execution/field_resolve_step" require "graphql/execution/finalize" require "graphql/execution/load_argument_step" diff --git a/lib/graphql/execution/runner.rb b/lib/graphql/execution/runner.rb index b607c3b315..5dd17729cb 100644 --- a/lib/graphql/execution/runner.rb +++ b/lib/graphql/execution/runner.rb @@ -12,6 +12,9 @@ def initialize(multiplex, authorization:) @selected_operation = nil @dataloader = multiplex.context[:dataloader] ||= @schema.dataloader_class.new @resolves_lazies = @schema.resolves_lazies? + @input_values = Hash.new do |h, query| + h[query] = InputValues.new(query, self) + end.compare_by_identity @runtime_directives = nil @schema.directives.each do |name, dir_class| @@ -60,7 +63,7 @@ def add_step(step) @dataloader.append_job(step) end - attr_reader :authorization, :steps_queue, :schema, :variables, :dataloader, :resolves_lazies, :authorizes, :static_type_at, :runtime_type_at, :finalizers + attr_reader :authorization, :steps_queue, :schema, :variables, :dataloader, :resolves_lazies, :authorizes, :static_type_at, :runtime_type_at, :finalizers, :input_values # @return [void] def add_finalizer(query, result_value, key, finalizer) @@ -354,24 +357,14 @@ def begin_execute(isolated_steps, results, query, root_type, root_value) @static_type_at[data] = root_type end - def dir_arg_value(query, arg_node) - if arg_node.value.is_a?(Language::Nodes::VariableIdentifier) - var_key = arg_node.value.name - if query.variables.key?(var_key) - query.variables[var_key] - else - query.variables[var_key.to_sym] - end - else - arg_node.value - end - end def directives_include?(query, ast_selection) if ast_selection.directives.any? { |dir_node| if dir_node.name == "skip" - dir_node.arguments.any? { |arg_node| arg_node.name == "if" && dir_arg_value(query, arg_node) == true } # rubocop:disable Development/ContextIsPassedCop + skip_args = @input_values[query].argument_values(GraphQL::Schema::Directive::Skip, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + skip_args[:if] == true elsif dir_node.name == "include" - dir_node.arguments.any? { |arg_node| arg_node.name == "if" && dir_arg_value(query, arg_node) == false } # rubocop:disable Development/ContextIsPassedCop + include_args = @input_values[query].argument_values(GraphQL::Schema::Directive::Include, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + include_args[:if] == false end } false diff --git a/lib/graphql/execution/selections_step.rb b/lib/graphql/execution/selections_step.rb index f4d18ff1ec..6dee08ec9a 100644 --- a/lib/graphql/execution/selections_step.rb +++ b/lib/graphql/execution/selections_step.rb @@ -38,13 +38,7 @@ def call directives.each do |dir_node| dir_defn = @runner.runtime_directives[dir_node.name] if dir_defn # not present for `skip` or `include` - dummy_frs = FieldResolveStep.new( - selections_step: self, - key: nil, - parent_type: @parent_type, - runner: @runner, - ) - dir_args = dummy_frs.coerce_arguments(dir_defn, dir_node.arguments, false) # rubocop:disable Development/ContextIsPassedCop + dir_args = @runner.input_values[query].argument_values(dir_defn, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop result = case directives_owner when Language::Nodes::FragmentSpread dir_defn.resolve_fragment_spread(directives_owner, @parent_type, @objects, dir_args, self.query.context) diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index b5312d1fec..6f2c4fceda 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -80,8 +80,8 @@ def call result = if is_authed Schema::Validator.validate!(self.class.validators, object, context, @prepared_arguments, as: @field) if q.subscription? && @field.owner == context.schema.subscription - # This needs to use arguments without `loads:` - @original_arguments = @field_resolve_step.coerce_arguments(@field, @field_resolve_step.ast_node.arguments, false) + # This needs to use arguments without `loads:`. TODO extract this into subscription-related code somehow? + @original_arguments = @field_resolve_step.runner.input_values[q].argument_values(@field, @field_resolve_step.ast_node.arguments, nil) end call_resolve(@prepared_arguments) elsif new_return_value.nil? diff --git a/spec/graphql/execution/next/finalize_spec.rb b/spec/graphql/execution/finalize_spec.rb similarity index 99% rename from spec/graphql/execution/next/finalize_spec.rb rename to spec/graphql/execution/finalize_spec.rb index e7e69edc0c..14d659752e 100644 --- a/spec/graphql/execution/next/finalize_spec.rb +++ b/spec/graphql/execution/finalize_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "spec_helper" -class ErrorResultFormatterTest < Minitest::Test +class ExecutionFinalizeTest < Minitest::Test class HashKeyResolver def initialize(key) @key = key diff --git a/spec/graphql/execution/input_values_spec.rb b/spec/graphql/execution/input_values_spec.rb new file mode 100644 index 0000000000..efa3082dbd --- /dev/null +++ b/spec/graphql/execution/input_values_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true +require "spec_helper" + +class ExecutionInputValuesTest < Minitest::Test + TEST_SCHEMA = GraphQL::Schema.from_definition(%| + enum TestStatus { + ACTIVE + INACTIVE + } + + input NestedInput { + value: String + nested: NestedInput + } + + input TestInput { + string: String + int: Int + float: Float + boolean: Boolean + id: ID + enum: TestStatus + nested: NestedInput + stringList: [String] + nonNullItemList: [String!] + nestedList: [NestedInput] + } + + input RequiredFieldsInput { + required: String! + optional: String + } + + input ValidatedFieldsInput { + scalar: String + list: [String] + input: ValidatedFieldsInput + inputList: [ValidatedFieldsInput] + } + + input OneOfInput @oneOf { + string: String + int: Int + } + + type Mutation { + testInput(input: TestInput): Boolean + requiredFields(input: RequiredFieldsInput!): Boolean + argWithDefault(input: String = "fallback-value"): Boolean + oneOf(input: OneOfInput!): Boolean + validates(input: ValidatedFieldsInput!): Boolean + validatesOneArg(a: String, b: String): Boolean + } + + type Query { + ping: Boolean + } + |) + + TEST_SCHEMA.mutation.fields["validatesOneArg"].tap do |f| + f.validates(required: { one_of: [:a, :b] }) + end + + TEST_SCHEMA.get_type("ValidatedFieldsInput").tap do |t| + t.arguments["scalar"].validates(length: { minimum: 2 }) + t.arguments["list"].validates(length: { minimum: 2 }) + end + + class DummyRunner + def add_step(s); end + def schema; TEST_SCHEMA; end + end + + def get_input_values(variables_string: "", variables: nil) + query_str = "query(#{variables_string}) { __typename }" + query = GraphQL::Query.new(TEST_SCHEMA, query_str, validate: false, variables: variables) + GraphQL::Execution::InputValues.new(query, DummyRunner.new) + end + + def get_argument_nodes(arg_string) + GraphQL.parse("query @something(#{arg_string}) { t }").definitions.first.directives.first.arguments + end + + def test_coerce_variable_values_empty_inputs_returns_empty + input = get_input_values + assert_equal({}, input.variable_values) + end + + def test_it_works_with_simple_scalars + input = get_input_values(variables_string: "$name: String, $count: Int, $average: Float, $isOk: Boolean", variables: { "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }) + assert_equal({ "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }, input.variable_values) + + with_defaults_str = "$name: String = \"def\", $count: Int = 10, $average: Float = 300.4, $isOk: Boolean = true" + + input = get_input_values(variables_string: with_defaults_str, variables: { "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }) + assert_equal({ "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }, input.variable_values) + + input = get_input_values(variables_string: with_defaults_str) + assert_equal({ "name" => "def", "count" => 10, "average" => 300.4, "isOk" => true }, input.variable_values) + end + + def test_it_produces_argument_values_for_simple_scalars + vs = "$if: Boolean = false" + input = get_input_values(variables_string: vs) + assert_equal( { if: false }, input.argument_values(GraphQL::Schema::Directive::Skip, get_argument_nodes("if: $if"), nil)) + assert_equal( { if: true }, input.argument_values(GraphQL::Schema::Directive::Skip, get_argument_nodes("if: true"), nil)) + end + + def test_it_produces_argument_values_for_input_objects + input = get_input_values + assert_equal({input: { string: "a", enum: "ACTIVE" } }, input.argument_values(TEST_SCHEMA.find("Mutation.testInput"), get_argument_nodes("input: { string: \"a\", enum: ACTIVE }"), nil)) + end +end