diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb new file mode 100644 index 0000000000..056260fff6 --- /dev/null +++ b/lib/graphql/execution/next.rb @@ -0,0 +1,508 @@ +# frozen_string_literal: true +module GraphQL + module Execution + module Next + def self.run(schema:, query_string: nil, document: nil, context:, validate: true, variables:, root_object:) + document ||= GraphQL.parse(query_string) + if validate + validation_errors = schema.validate(document, context: context) + if !validation_errors.empty? + return { + "errors" => validation_errors.map(&:to_h) + } + end + end + dummy_q = GraphQL::Query.new(schema, document: document, context: context, variables: variables, root_value: root_object) + query_context = dummy_q.context + + runner = Runner.new(schema, document, query_context, variables, root_object) + runner.execute + end + + + class Runner + def initialize(schema, document, context, variables, root_object) + @schema = schema + @document = document + @context = context + @variables = variables + @root_object = root_object + @path = @context[:current_path_next] = [] + @steps_queue = [] + @data = {} + @runtime_types_at_result = {}.compare_by_identity + @selected_operation = nil + @root_type = nil + end + + attr_reader :steps_queue, :schema, :context, :variables, :runtime_types_at_result + + def execute + @selected_operation = @document.definitions.first # TODO select named operation + isolated_steps = case @selected_operation.operation_type + when nil, "query" + [ + SelectionsStep.new( + parent_type: @root_type = @schema.query, + selections: @selected_operation.selections, + objects: [@root_object], + results: [@data], + path: EmptyObjects::EMPTY_ARRAY, + runner: self, + ) + ] + when "mutation" + fields = {} + gather_selections(@schema.mutation, @selected_operation.selections, nil, into: fields) + fields.each_value.map do |field_resolve_step| + SelectionsStep.new( + parent_type: @root_type = @schema.mutation, + selections: field_resolve_step.ast_nodes || Array(field_resolve_step.ast_node), + objects: [@root_object], + results: [@data], + path: EmptyObjects::EMPTY_ARRAY, + runner: self, + ) + end + else + raise ArgumentError, "Unhandled operation type: #{operation.operation_type.inspect}" + end + + while (next_isolated_step = isolated_steps.shift) + @steps_queue << next_isolated_step + while (step = @steps_queue.shift) + step.execute + end + end + result = if @context.errors.empty? + { + "data" => @data + } + else + data = propagate_errors(@data, @context.errors) + { + "errors" => @context.errors.map(&:to_h), + "data" => data + } + end + result + end + + def gather_selections(type_defn, ast_selections, selections_step, into:) + ast_selections.each do |ast_selection| + next if !directives_include?(ast_selection) + case ast_selection + when GraphQL::Language::Nodes::Field + key = ast_selection.alias || ast_selection.name + step = into[key] ||= FieldResolveStep.new( + selections_step: selections_step, + key: key, + parent_type: type_defn, + runner: self, + ) + step.append_selection(ast_selection) + when GraphQL::Language::Nodes::InlineFragment + type_condition = ast_selection.type.name + if type_condition_applies?(type_defn, type_condition) + gather_selections(type_defn, ast_selection.selections, selections_step, into: into) + end + when GraphQL::Language::Nodes::FragmentSpread + fragment_definition = @document.definitions.find { |defn| defn.is_a?(GraphQL::Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } + type_condition = fragment_definition.type.name + if type_condition_applies?(type_defn, type_condition) + gather_selections(type_defn, fragment_definition.selections, selections_step, into: into) + end + else + raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" + end + end + end + + def add_non_null_error(type, field, ast_node, is_from_array, path) + err = InvalidNullError.new(type, field, ast_node, is_from_array: is_from_array, path: path) + @schema.type_error(err, @context) + end + + private + + def propagate_errors(data, errors) + paths_to_check = errors.map(&:path) + check_object_result(data, @root_type, @selected_operation.selections, [], [], paths_to_check) + end + + def check_object_result(result_h, static_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + current_path_len = current_exec_path.length + ast_selections.each do |ast_selection| + case ast_selection + when Language::Nodes::Field + begin + key = ast_selection.alias || ast_selection.name + current_exec_path << key + current_result_path << key + if paths_to_check.any? { |path_to_check| path_to_check[current_path_len] == key } + result_value = result_h[key] + field_defn = @context.types.field(static_type, ast_selection.name) + result_type = field_defn.type + if (result_type_non_null = result_type.non_null?) + result_type = result_type.of_type + end + + new_result_value = if result_value.is_a?(GraphQL::Error) + result_value.path = current_result_path.dup + nil + else + if result_type.list? + check_list_result(result_value, result_type.of_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + elsif result_type.kind.leaf? + result_value + else + check_object_result(result_value, result_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + end + end + + if new_result_value.nil? && result_type_non_null + return nil + else + result_h[key] = new_result_value + end + end + ensure + current_exec_path.pop + current_result_path.pop + end + when Language::Nodes::InlineFragment + runtime_type_at_result = @runtime_types_at_result[result_h] + if type_condition_applies?(runtime_type_at_result, ast_selection.type.name) + result_h = check_object_result(result_h, static_type, ast_selection.selections, current_exec_path, current_result_path, paths_to_check) + end + when Language::Nodes::FragmentSpread + fragment_defn = @document.definitions.find { |defn| defn.is_a?(Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } + runtime_type_at_result = @runtime_types_at_result[result_h] + if type_condition_applies?(runtime_type_at_result, fragment_defn.type.name) + result_h = check_object_result(result_h, static_type, fragment_defn.selections, current_exec_path, current_result_path, paths_to_check) + end + end + end + + result_h + end + + def check_list_result(result_arr, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + inner_type_non_null = false + if inner_type.non_null? + inner_type_non_null = true + inner_type = inner_type.of_type + end + + new_invalid_null = false + result_arr.map!.with_index do |result_item, idx| + current_result_path << idx + new_result = if result_item.is_a?(GraphQL::Error) + result_item.path = current_result_path.dup + nil + elsif inner_type.list? + check_list_result(result_item, inner_type.of_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + elsif inner_type.kind.leaf? + result_item + else + check_object_result(result_item, inner_type, ast_selections, current_exec_path, current_result_path, paths_to_check) + end + + if new_result.nil? && inner_type_non_null + new_invalid_null = true + break + else + new_result + end + ensure + current_result_path.pop + end + + if new_invalid_null + nil + else + result_arr + end + end + + def directives_include?(ast_selection) + if ast_selection.directives.any? { |dir_node| + # TODO support variables here + (dir_node.name == "skip" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == true }) || # rubocop:disable Development/ContextIsPassedCop + (dir_node.name == "include" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == false }) # rubocop:disable Development/ContextIsPassedCop + } + false + else + true + end + end + + def type_condition_applies?(concrete_type, type_name) + if type_name == concrete_type.graphql_name + true + else + abs_t = @schema.get_type(type_name, @context) + p_types = @schema.possible_types(abs_t, @context) + p_types.include?(concrete_type) + end + end + + class FieldResolveStep + def initialize(parent_type:, runner:, key:, selections_step:) + @selection_step = selections_step + @key = key + @parent_type = parent_type + @ast_node = @ast_nodes = nil + @objects = nil + @results = nil + @runner = runner + @path = nil + end + + attr_writer :objects, :results + + attr_reader :ast_node, :ast_nodes + + def path + @path ||= [*@selection_step.path, @key].freeze + end + + def append_selection(ast_node) + if @ast_node.nil? + @ast_node = ast_node + elsif @ast_nodes.nil? + @ast_nodes = [@ast_node, ast_node] + else + @ast_nodes << ast_node + end + nil + end + + def coerce_arguments(argument_owner, ast_arguments_or_hash) + arg_defns = argument_owner.arguments(@runner.context) + if arg_defns.empty? + return EmptyObjects::EMPTY_HASH + end + args_hash = {} + if ast_arguments_or_hash.is_a?(Hash) + ast_arguments_or_hash.each do |key, value| + arg_defn = arg_defns.each_value.find { |a| a.keyword == key } + arg_value = coerce_argument_value(arg_defn.type, value) + args_hash[key] = arg_value + 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 = Schema::Member::BuildType.underscore(arg_node.name).to_sym + args_hash[arg_key] = arg_value + } + end + + 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 + end + end + + args_hash + end + + def coerce_argument_value(arg_t, arg_value) + if arg_t.non_null? + arg_t = arg_t.of_type + end + + if arg_value.is_a?(Language::Nodes::VariableIdentifier) + arg_value = if @runner.variables.key?(arg_value.name) + @runner.variables[arg_value.name] + elsif @runner.variables.key?(arg_value.name.to_sym) + @runner.variables[arg_value.name.to_sym] + end + elsif arg_value.is_a?(Language::Nodes::NullValue) + arg_value = nil + elsif arg_value.is_a?(Language::Nodes::Enum) + arg_value = arg_value.name + elsif arg_value.is_a?(Language::Nodes::InputObject) + arg_value = arg_value.arguments # rubocop:disable Development/ContextIsPassedCop + end + + if arg_t.list? + arg_value = Array(arg_value) + inner_t = arg_t.of_type + arg_value.map { |v| coerce_argument_value(inner_t, v) } + elsif arg_t.kind.leaf? + arg_t.coerce_input(arg_value, @runner.context) + elsif arg_t.kind.input_object? + coerce_arguments(arg_t, arg_value) + else + raise "Unsupported argument value: #{arg_t.to_type_signature} / #{arg_value.class} (#{arg_value.inspect})" + end + end + + def execute + field_defn = @runner.schema.get_field(@parent_type, @ast_node.name) || raise("Invariant: no field found for #{@parent_type.to_type_signature}.#{ast_node.name}") + result_key = @ast_node.alias || @ast_node.name + + arguments = coerce_arguments(field_defn, @ast_node.arguments) # rubocop:disable Development/ContextIsPassedCop + + field_objs = if field_defn.dynamic_introspection + @objects.map { |o| @parent_type.scoped_new(o, @runner.context) } + else + @objects + end + field_results = if arguments.empty? + field_defn.resolve_all(field_objs, @runner.context) + else + field_defn.resolve_all(field_objs, @runner.context, **arguments) + end + + return_type = field_defn.type + return_result_type = return_type.unwrap + + if return_result_type.kind.composite? + if @ast_nodes + next_selections = [] + @ast_nodes.each do |ast_node| + next_selections.concat(ast_node.selections) + end + else + next_selections = @ast_node.selections + end + + all_next_objects = [] + all_next_results = [] + + is_list = return_type.list? + is_non_null = return_type.non_null? + field_results.each_with_index do |result, i| + result_h = @results[i] + result_h[result_key] = build_graphql_result(field_defn, result, return_type, is_non_null, is_list, all_next_objects, all_next_results, false) + end + + if !all_next_results.empty? + all_next_objects.compact! + + if return_result_type.kind.abstract? + next_objects_by_type = Hash.new { |h, obj_t| h[obj_t] = [] }.compare_by_identity + next_results_by_type = Hash.new { |h, obj_t| h[obj_t] = [] }.compare_by_identity + all_next_objects.each_with_index do |next_object, i| + object_type, _ignored_new_value = @runner.schema.resolve_type(return_result_type, next_object, @runner.context) + next_objects_by_type[object_type] << next_object + next_results_by_type[object_type] << all_next_results[i] + end + + next_objects_by_type.each do |obj_type, next_objects| + @runner.steps_queue << SelectionsStep.new( + path: path, # TODO pass self here? + parent_type: obj_type, + selections: next_selections, + objects: next_objects, + results: next_results_by_type[obj_type], + runner: @runner, + ) + end + else + @runner.steps_queue << SelectionsStep.new( + path: path, # TODO pass self here? + parent_type: return_result_type, + selections: next_selections, + objects: all_next_objects, + results: all_next_results, + runner: @runner, + ) + end + end + else + field_results.each_with_index do |result, i| + result_h = @results[i] || raise("Invariant: no result object at index #{i} for #{@parent_type.to_type_signature}.#{ast_node.name} (result: #{result.inspect})") + result_h[result_key] = if result.nil? + if return_type.non_null? + @runner.add_non_null_error(@parent_type, field_defn, @ast_node, false, path) + else + nil + end + else + return_type.coerce_result(result, @runner.context) + end + end + end + end + + private + + def build_graphql_result(field_defn, field_result, return_type, is_nn, is_list, all_next_objects, all_next_results, is_from_array) # rubocop:disable Metrics/ParameterLists + if field_result.nil? + if is_nn + @runner.add_non_null_error(@parent_type, field_defn, @ast_node, is_from_array, path) + else + nil + end + 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? + field_result.map do |inner_f_r| + build_graphql_result(field_defn, inner_f_r, inner_type, inner_type_nn, inner_type_l, all_next_objects, all_next_results, true) + end + else + next_result_h = {} + @runner.runtime_types_at_result[next_result_h] = return_type.unwrap + all_next_results << next_result_h + all_next_objects << field_result + next_result_h + end + end + end + + class SelectionsStep + def initialize(parent_type:, selections:, objects:, results:, runner:, path:) + @path = path + @parent_type = parent_type + @selections = selections + @objects = objects + @results = results + @runner = runner + end + + attr_reader :path + + def execute + grouped_selections = {} + @runner.gather_selections(@parent_type, @selections, self, into: grouped_selections) + grouped_selections.each_value do |frs| + frs.objects = @objects + frs.results = @results + @runner.steps_queue << frs + end + end + end + end + + module FieldCompatibility + def resolve_all(objects, context, **kwargs) + if objects.first.is_a?(Hash) + objects.map { |o| o[graphql_name] } + elsif @owner.method_defined?(@method_sym) + # Terrible perf but might work + objects.map { |o| + obj_inst = @owner.scoped_new(o, context) + if kwargs.empty? + obj_inst.public_send(@method_sym) + else + obj_inst.public_send(@method_sym, **kwargs) + end + } + else + objects.map { |o| o.public_send(@method_sym) } + end + end + end + + GraphQL::Schema::Field.include(FieldCompatibility) + end + end +end diff --git a/lib/graphql/invalid_null_error.rb b/lib/graphql/invalid_null_error.rb index a980c31b95..e81ed5b9ec 100644 --- a/lib/graphql/invalid_null_error.rb +++ b/lib/graphql/invalid_null_error.rb @@ -15,12 +15,14 @@ class InvalidNullError < GraphQL::Error # @return [Boolean] indicates an array result caused the error attr_reader :is_from_array - def initialize(parent_type, field, ast_node, is_from_array: false) + attr_accessor :path + + def initialize(parent_type, field, ast_node, is_from_array: false, path: nil) @parent_type = parent_type @field = field @ast_node = ast_node @is_from_array = is_from_array - + @path = path # For List elements, identify the non-null error is for an # element and the required element type so it's not ambiguous # whether it was caused by a null instead of the list or a diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 2011ab168c..001d861996 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1330,9 +1330,10 @@ def type_error(type_error, context) case type_error when GraphQL::InvalidNullError execution_error = GraphQL::ExecutionError.new(type_error.message, ast_node: type_error.ast_node) - execution_error.path = context[:current_path] + execution_error.path = type_error.path || context[:current_path] context.errors << execution_error + execution_error when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError raise type_error when GraphQL::IntegerDecodingError diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb new file mode 100644 index 0000000000..2a6eb35ce6 --- /dev/null +++ b/spec/graphql/execution/next_spec.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true +require "spec_helper" +require "graphql/execution/next" +describe "Next Execution" do + class NextExecutionSchema < GraphQL::Schema + class BaseField < GraphQL::Schema::Field + def initialize(value: nil, object_method: nil, **kwargs, &block) + @static_value = value + @object_method = object_method + super(**kwargs, &block) + end + + def resolve_all(objects, context, **arguments) + if !@static_value.nil? + Array.new(objects.length, @static_value) + elsif @object_method + objects.map { |o| o.public_send(@object_method) } + else + @all_method_name ||= :"all_#{method_sym}" + owner.public_send(@all_method_name, objects, context, **arguments) + end + end + end + + class BaseObject < GraphQL::Schema::Object + field_class BaseField + end + + module BaseInterface + include GraphQL::Schema::Interface + field_class BaseField + end + + CLEAN_DATA = [ + OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "🌻", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), + OpenStruct.new(name: "Nightshades", grows_in: ["🌻"], species: [OpenStruct.new(name: "Tomato")]), + OpenStruct.new(name: "Curcurbits", grows_in: ["🌻"], species: [OpenStruct.new(name: "Cucumber")]) + ] + + DATA = [] + + class Season < GraphQL::Schema::Enum + value "WINTER" + value "SPRING" + value "SUMMER", value: "🌻" + value "FALL" + end + + module Nameable + include BaseInterface + field :name, String, object_method: :name + end + + class PlantSpecies < BaseObject + implements Nameable + field :poisonous, Boolean, value: false + field :family, "NextExecutionSchema::PlantFamily" + + def self.all_family(objects, context) + objects.map { |species_obj| + DATA.find { |f| f.species.include?(species_obj) } + } + end + + field :grows_in, [Season] + + def self.all_grows_in(objects, context) + objects.map { |o| o.grows_in || [] } + end + end + + class PlantFamily < BaseObject + implements Nameable + field :name, String, null: false, object_method: :name + field :grows_in, [Season], object_method: :grows_in + field :species, [PlantSpecies], object_method: :species + field :plant_count, Integer + + def self.all_plant_count(objects, context) + objects.map { |o| o.species.length.to_f } # let it be coerced to int + end + end + + class Thing < GraphQL::Schema::Union + possible_types(PlantFamily, PlantSpecies) + end + + class Query < BaseObject + field :families, [PlantFamily], value: DATA + field :nullable_families, [PlantFamily, null: true], value: DATA + + field :str, String + + def self.all_str(objects, context) + objects.map { |obj| obj.class.name } + end + + field :find_species, PlantSpecies do + argument :name, String + end + + def self.all_find_species(objects, context, name:) + species = nil + DATA.each do |f| + if (species = f.species.find { |s| s.name == name }) + break + end + end + Array.new(objects.length, species) + end + + field :all_things, [Thing] + + def self.all_all_things(_objs, _ctx) + [DATA + DATA.map(&:species).flatten] + end + end + + class Mutation < BaseObject + class CreatePlantInput < GraphQL::Schema::InputObject + argument :name, String + argument :family, String + argument :grows_in, [Season], default_value: ["🌻"] + end + + field :create_plant, PlantSpecies do + argument :input, CreatePlantInput + end + + def self.all_create_plant(_objs, _ctx, input:) + name = input[:name] + family = input[:family] + grows_in = input[:grows_in] + family_obj = DATA.find { |f| f.name == family} + species_obj = OpenStruct.new(name: name, grows_in: grows_in ) + family_obj.species << species_obj + [species_obj] + end + end + + query(Query) + mutation(Mutation) + + def self.resolve_type(abs_type, obj, ctx) + if obj.respond_to?(:grows_in) + PlantFamily + else + PlantSpecies + end + end + end + + + def run_next(query_str, root_object: nil, variables: {}) + GraphQL::Execution::Next.run(schema: NextExecutionSchema, query_string: query_str, context: {}, variables: variables, root_object: root_object) + end + + before do + NextExecutionSchema::DATA.clear + NextExecutionSchema::DATA.concat(Marshal.load(Marshal.dump(NextExecutionSchema::CLEAN_DATA))) + end + + it "runs a query" do + result = run_next(" + query TestNext($name: String!) { + str + families { + ... on Nameable { name } + ... on PlantFamily { growsIn } + } + families { species { name } } + t: findSpecies(name: $name) { ...SpeciesInfo ... NameableInfo } + c: findSpecies(name: \"Cucumber\") { name ...SpeciesInfo } + x: findSpecies(name: \"Blue Rasperry\") { name } + allThings { + __typename + ... on Nameable { name } + ... on PlantFamily { growsIn } + } + } + + fragment SpeciesInfo on PlantSpecies { + poisonous + } + + fragment NameableInfo on Nameable { + name + } + ", root_object: "Abc", variables: { "name" => "Tomato" }) + expected_result = { + "data" => { + "str" => "String", + "families" => [ + {"name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"], "species" => [{"name" => "Snow Pea"}]}, + {"name" => "Nightshades", "growsIn" => ["SUMMER"], "species" => [{"name" => "Tomato"}]}, + {"name" => "Curcurbits", "growsIn" => ["SUMMER"], "species" => [{"name" => "Cucumber"}]} + ], + "t" => { "poisonous" => false, "name" => "Tomato" }, + "c" => { "name" => "Cucumber", "poisonous" => false }, + "x" => nil, + "allThings" => [ + {"__typename" => "PlantFamily", "name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"]}, + {"__typename" => "PlantFamily", "name" => "Nightshades", "growsIn" => ["SUMMER"]}, + {"__typename" => "PlantFamily", "name" => "Curcurbits", "growsIn" => ["SUMMER"]}, + {"__typename" => "PlantSpecies", "name" => "Snow Pea"}, + {"__typename" => "PlantSpecies", "name" => "Tomato"}, + {"__typename" => "PlantSpecies", "name" => "Cucumber"}, + ] + } + } + assert_graphql_equal(expected_result, result) + end + + it "runs mutations in isolation" do + result = run_next <<~GRAPHQL + mutation TestSequence { + p1: createPlant(input: { name: "Eggplant", family: "Nightshades", growsIn: [SUMMER] }) { growsIn family { plantCount } } + p2: createPlant(input: { name: "Ground Cherry", family: "Nightshades" }) { growsIn family { plantCount } } + p3: createPlant(input: { name: "Potato", family: "Nightshades", growsIn: [SPRING, SUMMER] }) { growsIn family { plantCount } } + } + GRAPHQL + + expected_result = { "data" => { + "p1" => { "growsIn" => ["SUMMER"], "family" => { "plantCount" => 2 }}, + "p2" => { "growsIn" => ["SUMMER"], "family" => { "plantCount" => 3 }}, + "p3" => { "growsIn" => ["SPRING", "SUMMER"], "family" => { "plantCount" => 4 }} + } } + assert_graphql_equal(expected_result, result) + end + + it "runs introspection" do + result = run_next(GraphQL::Introspection::INTROSPECTION_QUERY) + new_schema = GraphQL::Schema.from_introspection(result) + assert_equal NextExecutionSchema.to_definition, new_schema.to_definition + end + + it "skips and includes" do + result = run_next <<~GRAPHQL + { + c1: findSpecies(name: "Cucumber") @skip(if: true) { name } + c2: findSpecies(name: "Cucumber") @include(if: false) { name } + c3: findSpecies(name: "Cucumber") @skip(if: false) { name } + c4: findSpecies(name: "Cucumber") @include(if: true) { name } + } + GRAPHQL + + expected_result = { "data" => { + "c3" => {"name" => "Cucumber"}, + "c4" => {"name" => "Cucumber"} + } } + assert_equal expected_result, result + end + + it "does scalar coercion" do + result = run_next <<~GRAPHQL, variables: { input: { name: :Zucchini, family: "Curcurbits", grows_in: "🌻" }} + mutation TestCoerce($input: CreatePlantInput!) { + createPlant(input: $input) { + name + growsIn + family { name } + } + } + GRAPHQL + expected_result = { "data" => { "createPlant" => { + "name" => nil, # coerced away + "growsIn" => ["SUMMER"], # made into a one-item list + "family" => { "name" => "Curcurbits" } + }} } + assert_equal expected_result, result + end + + it "propagates nulls in lists" do + NextExecutionSchema::DATA << nil + result = run_next <<~GRAPHQL + { + families { name } + nullableFamilies { name } + } + GRAPHQL + + expected_result = { + "errors" => [ + { + "message" => "Cannot return null for non-nullable element of type 'PlantFamily' for Query.families", + "locations" => [{"line" => 2, "column" => 3}], + "path" => ["families", 3] + } + ], + "data" => { + "families" => nil, + "nullableFamilies" => [ + { "name" => "Legumes" }, + { "name" => "Nightshades" }, + { "name" => "Curcurbits" }, + nil, + ] + } + } + assert_equal expected_result, result + end + + it "propages nulls in objects" do + NextExecutionSchema::DATA << OpenStruct.new( + name: nil, + species: [OpenStruct.new(name: "Artichoke")] + ) + + result = run_next <<-GRAPHQL + { + findSpecies(name: "Artichoke") { + name + family { name } + } + } + GRAPHQL + + expected_result = { + "errors" => [{ + "message" => "Cannot return null for non-nullable field PlantFamily.name", + "locations" => [{"line" => 4, "column" => 20}], + "path" => ["findSpecies", "family", "name"] + }], + "data" => { + "findSpecies" => { + "name" => "Artichoke", + "family" => nil, + } + }, + } + assert_equal expected_result, result + end + + it "propages nested nulls in objects in lists" do + NextExecutionSchema::DATA << OpenStruct.new( + name: nil, + species: [OpenStruct.new(name: "Artichoke")] + ) + + result = run_next <<-GRAPHQL + { + families { + ...FamilyInfo + } + } + + fragment FamilyInfo on PlantFamily { + species { + family { + ... on Nameable { name } + } + } + } + GRAPHQL + + expected_result = { + "errors" => [ + { + "message" => "Cannot return null for non-nullable field PlantFamily.name", + "locations" => [{"line" => 10, "column" => 31}], + "path" => ["families", 3, "species", 0, "family", "name"] + } + ], + "data" => { + "families" => [ + {"species" => [{"family" => {"name" => "Legumes"}}]}, + {"species" => [{"family" => {"name" => "Nightshades"}}]}, + {"species" => [{"family" => {"name" => "Curcurbits"}}]}, + {"species" => [{"family" => nil}]} + ] + }, + } + assert_equal expected_result, result + end +end diff --git a/spec/graphql/schema/field_spec.rb b/spec/graphql/schema/field_spec.rb index 8a342fcb2a..f7690ca988 100644 --- a/spec/graphql/schema/field_spec.rb +++ b/spec/graphql/schema/field_spec.rb @@ -864,7 +864,7 @@ def resolve shapes = Set.new # This is custom state added by some test schemas: - custom_ivars = [:@upcase, :@future_schema, :@visible, :@allow_for, :@metadata, :@admin_only] + custom_ivars = [:@upcase, :@future_schema, :@visible, :@allow_for, :@metadata, :@admin_only, :@all_method_name, :@object_method, :@static_value] # Remove any invalid (non-retained) field instances from the heap GC.start