From 936abdfb62c2775883f060ec411df78bcee8b862 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 08:33:49 -0500 Subject: [PATCH 01/22] Run one field --- lib/graphql/execution/next.rb | 116 ++++++++++++++++++++++++++++ spec/graphql/execution/next_spec.rb | 40 ++++++++++ 2 files changed, 156 insertions(+) create mode 100644 lib/graphql/execution/next.rb create mode 100644 spec/graphql/execution/next_spec.rb diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb new file mode 100644 index 0000000000..f1b2e4ae54 --- /dev/null +++ b/lib/graphql/execution/next.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +module GraphQL + module Execution + module Next + def self.run(schema:, query_string:, context:, variables:) + + document = GraphQL.parse(query_string) + query = Query.new(schema, document, context, variables) + query.result + end + + + class Query + def initialize(schema, document, context, variables) + @schema = schema + @document = document + @context = context + @variables = variables + @result = nil + end + + attr_reader :schema, :document, :context, :variables + + def result + @result ||= Runner.new(self).execute + end + end + + + class Runner + def initialize(query) + @query = query + @schema = query.schema + @context = query.context + @steps_queue = [] + @data = {} + end + + attr_reader :steps_queue, :schema, :context + + def execute + operation = @query.document.definitions.first # TODO select named operation + root_type = case operation.operation_type + when nil, "query" + @schema.query + else + raise ArgumentError, "Unhandled operation type: #{operation.operation_type.inspect}" + end + + @steps_queue << SelectionsStep.new( + parent_type: root_type, + selections: operation.selections, + objects: [nil], # TODO support root_object, + results: [@data], + runner: self, + ) + + while (next_step = @steps_queue.shift) + next_step.execute + end + + { "data" => @data } + end + + class FieldResolveStep + def initialize(ast_node:, parent_type:, objects:, results:, runner:) + @parent_type = parent_type + @ast_node = ast_node + @objects = objects + @results = results + @runner = runner + end + + + def execute + field_defn = @runner.schema.get_field(@parent_type, @ast_node.name) + result_key = @ast_node.alias || @ast_node.name + field_results = field_defn.resolve_all(@objects, @runner.context) # Todo arguments here + field_results.each_with_index do |result, i| + @results[i][result_key] = result + end + end + end + + class SelectionsStep + def initialize(parent_type:, selections:, objects:, results:, runner:) + @parent_type = parent_type + @selections = selections + @objects = objects + @results = results + @runner = runner + end + + attr_reader :runner + + def execute + @selections.each do |ast_selection| + case ast_selection + when GraphQL::Language::Nodes::Field + runner.steps_queue << FieldResolveStep.new( + ast_node: ast_selection, + parent_type: @parent_type, + objects: @objects, + results: @results, + runner: @runner, + ) + else + raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" + end + end + end + end + end + end + end +end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb new file mode 100644 index 0000000000..6c2c6932ec --- /dev/null +++ b/spec/graphql/execution/next_spec.rb @@ -0,0 +1,40 @@ +# 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 resolve_all(objects, context) + @all_method_name ||= :"all_#{method_sym}" + owner.public_send(@all_method_name, objects, context) # TODO args + end + end + + class BaseObject < GraphQL::Schema::Object + field_class BaseField + end + + class Query < BaseObject + field :int, Integer + + def self.all_int(objects, context) + objects.each_with_index.map { |obj, i| i } + end + end + + query(Query) + end + + + def run_next(query_str) + GraphQL::Execution::Next.run(schema: NextExecutionSchema, query_string: query_str, context: {}, variables: {}) + end + + it "runs a query" do + result = run_next("{ int }") + expected_result = { + "data" => { "int" => 0 } + } + assert_equal(expected_result, result) + end +end From c3820dafa59d40e618da053ee37815c1c4778d3d Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 08:51:21 -0500 Subject: [PATCH 02/22] Support root_object --- lib/graphql/execution/next.rb | 31 ++++++++--------------------- spec/graphql/execution/next_spec.rb | 14 +++++++++---- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index f1b2e4ae54..a3c8e0abe9 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -2,36 +2,21 @@ module GraphQL module Execution module Next - def self.run(schema:, query_string:, context:, variables:) + def self.run(schema:, query_string:, context:, variables:, root_object:) document = GraphQL.parse(query_string) - query = Query.new(schema, document, context, variables) - query.result + runner = Runner.new(schema, document, context, variables, root_object) + runner.execute end - class Query - def initialize(schema, document, context, variables) + class Runner + def initialize(schema, document, context, variables, root_object) @schema = schema @document = document @context = context @variables = variables - @result = nil - end - - attr_reader :schema, :document, :context, :variables - - def result - @result ||= Runner.new(self).execute - end - end - - - class Runner - def initialize(query) - @query = query - @schema = query.schema - @context = query.context + @root_object = root_object @steps_queue = [] @data = {} end @@ -39,7 +24,7 @@ def initialize(query) attr_reader :steps_queue, :schema, :context def execute - operation = @query.document.definitions.first # TODO select named operation + operation = @document.definitions.first # TODO select named operation root_type = case operation.operation_type when nil, "query" @schema.query @@ -50,7 +35,7 @@ def execute @steps_queue << SelectionsStep.new( parent_type: root_type, selections: operation.selections, - objects: [nil], # TODO support root_object, + objects: [@root_object], results: [@data], runner: self, ) diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 6c2c6932ec..506723c596 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -20,20 +20,26 @@ class Query < BaseObject def self.all_int(objects, context) objects.each_with_index.map { |obj, i| i } end + + field :str, String + + def self.all_str(objects, context) + objects.map { |obj| obj.class.name } + end end query(Query) end - def run_next(query_str) - GraphQL::Execution::Next.run(schema: NextExecutionSchema, query_string: query_str, context: {}, variables: {}) + def run_next(query_str, root_object: nil) + GraphQL::Execution::Next.run(schema: NextExecutionSchema, query_string: query_str, context: {}, variables: {}, root_object: root_object) end it "runs a query" do - result = run_next("{ int }") + result = run_next("{ int str }", root_object: "Abc") expected_result = { - "data" => { "int" => 0 } + "data" => { "int" => 0, "str" => "String"} } assert_equal(expected_result, result) end From 1eee0a24f1da5b97754d919cd072420f7166f2d9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 09:36:49 -0500 Subject: [PATCH 03/22] Implement nested objects and lists of objects --- lib/graphql/execution/next.rb | 45 ++++++++++++++++++++++++++--- spec/graphql/execution/next_spec.rb | 32 +++++++++++++++++--- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index a3c8e0abe9..1d31d45ef7 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -58,11 +58,48 @@ def initialize(ast_node:, parent_type:, objects:, results:, runner:) def execute - field_defn = @runner.schema.get_field(@parent_type, @ast_node.name) + 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 - field_results = field_defn.resolve_all(@objects, @runner.context) # Todo arguments here - field_results.each_with_index do |result, i| - @results[i][result_key] = result + field_results = field_defn.resolve_all(@objects, @runner.context) # TODO arguments here + return_type = field_defn.type + return_result_type = return_type.unwrap + if return_result_type.kind.composite? + if return_type.list? + all_next_objects = [] + all_next_results = [] + field_results.each_with_index do |result_arr, i| + next_results = Array.new(result_arr.length) { Hash.new } + result_h = @results[i] + result_h[result_key] = next_results + all_next_objects.concat(result_arr) + all_next_results.concat(next_results) + end + @runner.steps_queue << SelectionsStep.new( + parent_type: return_result_type, + selections: @ast_node.selections, + objects: all_next_objects, + results: all_next_results, + runner: @runner, + ) + else + next_results = Array.new(field_results.length) { Hash.new } + field_results.each_with_index do |_result, i| + result_h = @results[i] + result_h[result_key] = next_results[i] + end + @runner.steps_queue << SelectionsStep.new( + parent_type: return_result_type, + selections: @ast_node.selections, + objects: field_results, + results: next_results, + runner: @runner, + ) + 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] = result + end end end end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 506723c596..17e53016cd 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -4,9 +4,21 @@ 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) - @all_method_name ||= :"all_#{method_sym}" - owner.public_send(@all_method_name, objects, context) # TODO args + 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) # TODO args + end end end @@ -14,7 +26,19 @@ class BaseObject < GraphQL::Schema::Object field_class BaseField end + ALL_FAMILIES = [ + OpenStruct.new(name: "Legumes"), + OpenStruct.new(name: "Nightshades"), + OpenStruct.new(name: "Curcurbits") + ] + + class PlantFamily < BaseObject + field :name, String, object_method: :name + end + class Query < BaseObject + field :families, [PlantFamily], value: ALL_FAMILIES + field :int, Integer def self.all_int(objects, context) @@ -37,9 +61,9 @@ def run_next(query_str, root_object: nil) end it "runs a query" do - result = run_next("{ int str }", root_object: "Abc") + result = run_next("{ int str families { name }}", root_object: "Abc") expected_result = { - "data" => { "int" => 0, "str" => "String"} + "data" => { "int" => 0, "str" => "String", "families" => [{"name" => "Legumes"}, {"name" => "Nightshades"}, {"name" => "Curcurbits"}]} } assert_equal(expected_result, result) end From 1f475f8499b105fdb2d7036bae66249627a920c4 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 09:54:14 -0500 Subject: [PATCH 04/22] Add basic arguments --- lib/graphql/execution/next.rb | 39 +++++++++++++----- spec/graphql/execution/next_spec.rb | 64 +++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 1d31d45ef7..3099b9b9fa 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -60,7 +60,15 @@ def initialize(ast_node:, parent_type:, objects:, results:, runner:) 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 - field_results = field_defn.resolve_all(@objects, @runner.context) # TODO arguments here + + arguments = @ast_node.arguments.each_with_object({}) { |arg_node, obj| obj[arg_node.name.to_sym] = arg_node.value } + + field_results = if arguments.empty? + field_defn.resolve_all(@objects, @runner.context) + else + field_defn.resolve_all(@objects, @runner.context, **arguments) + end + return_type = field_defn.type return_result_type = return_type.unwrap if return_result_type.kind.composite? @@ -82,18 +90,27 @@ def execute runner: @runner, ) else - next_results = Array.new(field_results.length) { Hash.new } - field_results.each_with_index do |_result, i| + next_results = nil + + field_results.each_with_index do |result, i| result_h = @results[i] - result_h[result_key] = next_results[i] + if result.nil? + result_h[result_key] = nil + else + next_results ||= [] + next_results << result_h[result_key] = {} + end + end + + if next_results + @runner.steps_queue << SelectionsStep.new( + parent_type: return_result_type, + selections: @ast_node.selections, + objects: field_results, + results: next_results, + runner: @runner, + ) end - @runner.steps_queue << SelectionsStep.new( - parent_type: return_result_type, - selections: @ast_node.selections, - objects: field_results, - results: next_results, - runner: @runner, - ) end else field_results.each_with_index do |result, i| diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 17e53016cd..84f215d24e 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -10,14 +10,14 @@ def initialize(value: nil, object_method: nil, **kwargs, &block) super(**kwargs, &block) end - def resolve_all(objects, context) + 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) # TODO args + owner.public_send(@all_method_name, objects, context, **arguments) end end end @@ -27,29 +27,51 @@ class BaseObject < GraphQL::Schema::Object end ALL_FAMILIES = [ - OpenStruct.new(name: "Legumes"), - OpenStruct.new(name: "Nightshades"), - OpenStruct.new(name: "Curcurbits") + OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "SUMMER", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), + OpenStruct.new(name: "Nightshades", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Tomato")]), + OpenStruct.new(name: "Curcurbits", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Cucumber")]) ] + class Season < GraphQL::Schema::Enum + value "WINTER" + value "SPRING" + value "SUMMER" + value "FALL" + end + + class Species < BaseObject + field :name, String, object_method: :name + end + class PlantFamily < BaseObject field :name, String, object_method: :name + field :grows_in, Season, object_method: :grows_in + field :species, [Species], object_method: :species end + class Query < BaseObject field :families, [PlantFamily], value: ALL_FAMILIES - field :int, Integer - - def self.all_int(objects, context) - objects.each_with_index.map { |obj, i| i } - end - field :str, String def self.all_str(objects, context) objects.map { |obj| obj.class.name } end + + field :find_species, Species do + argument :name, String + end + + def self.all_find_species(objects, context, name:) + species = nil + ALL_FAMILIES.each do |f| + if (species = f.species.find { |s| s.name == name }) + break + end + end + Array.new(objects.length, species) + end end query(Query) @@ -61,9 +83,25 @@ def run_next(query_str, root_object: nil) end it "runs a query" do - result = run_next("{ int str families { name }}", root_object: "Abc") + result = run_next("{ + str + families { name growsIn species { name } } + t: findSpecies(name: \"Tomato\") { name } + c: findSpecies(name: \"Cucumber\") { name } + x: findSpecies(name: \"Blue Rasperry\") { name } + }", root_object: "Abc") expected_result = { - "data" => { "int" => 0, "str" => "String", "families" => [{"name" => "Legumes"}, {"name" => "Nightshades"}, {"name" => "Curcurbits"}]} + "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" => { "name" => "Tomato" }, + "c" => { "name" => "Cucumber" }, + "x" => nil + } } assert_equal(expected_result, result) end From 1665e065fa4120f8f2de616bc98af8cb5566e8ab Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 09:59:46 -0500 Subject: [PATCH 05/22] Basic merging of selections --- lib/graphql/execution/next.rb | 43 +++++++++++++++++++---------- spec/graphql/execution/next_spec.rb | 3 +- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 3099b9b9fa..675ff0c184 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -48,20 +48,25 @@ def execute end class FieldResolveStep - def initialize(ast_node:, parent_type:, objects:, results:, runner:) + def initialize(parent_type:, objects:, results:, runner:) @parent_type = parent_type - @ast_node = ast_node + @ast_nodes = [] @objects = objects @results = results @runner = runner end + def append_selection(ast_node) + @ast_nodes << ast_node + 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 + ast_node = @ast_nodes.first + 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 = @ast_node.arguments.each_with_object({}) { |arg_node, obj| obj[arg_node.name.to_sym] = arg_node.value } + arguments = ast_node.arguments.each_with_object({}) { |arg_node, obj| obj[arg_node.name.to_sym] = arg_node.value } field_results = if arguments.empty? field_defn.resolve_all(@objects, @runner.context) @@ -72,6 +77,10 @@ def execute return_type = field_defn.type return_result_type = return_type.unwrap if return_result_type.kind.composite? + next_selections = [] # TODO optimize for one ast node + @ast_nodes.each do |ast_node| + next_selections.concat(ast_node.selections) + end if return_type.list? all_next_objects = [] all_next_results = [] @@ -84,7 +93,7 @@ def execute end @runner.steps_queue << SelectionsStep.new( parent_type: return_result_type, - selections: @ast_node.selections, + selections: next_selections, objects: all_next_objects, results: all_next_results, runner: @runner, @@ -105,7 +114,7 @@ def execute if next_results @runner.steps_queue << SelectionsStep.new( parent_type: return_result_type, - selections: @ast_node.selections, + selections: next_selections, objects: field_results, results: next_results, runner: @runner, @@ -133,16 +142,22 @@ def initialize(parent_type:, selections:, objects:, results:, runner:) attr_reader :runner def execute + grouped_selections = {} @selections.each do |ast_selection| case ast_selection when GraphQL::Language::Nodes::Field - runner.steps_queue << FieldResolveStep.new( - ast_node: ast_selection, - parent_type: @parent_type, - objects: @objects, - results: @results, - runner: @runner, - ) + key = ast_selection.alias || ast_selection.name + step = grouped_selections[key] ||= begin + frs = FieldResolveStep.new( + parent_type: @parent_type, + objects: @objects, + results: @results, + runner: @runner, + ) + runner.steps_queue << frs + frs + end + step.append_selection(ast_selection) else raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 84f215d24e..31cf44a7d7 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -85,7 +85,8 @@ def run_next(query_str, root_object: nil) it "runs a query" do result = run_next("{ str - families { name growsIn species { name } } + families { name growsIn } + families { species { name } } t: findSpecies(name: \"Tomato\") { name } c: findSpecies(name: \"Cucumber\") { name } x: findSpecies(name: \"Blue Rasperry\") { name } From 08bce720dfa8b436e2b7e86b5e466b1a55ece05f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 10:05:13 -0500 Subject: [PATCH 06/22] Add concrete type inline fragments --- lib/graphql/execution/next.rb | 15 +++++++++++++-- spec/graphql/execution/next_spec.rb | 11 +++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 675ff0c184..9408289c9e 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -143,11 +143,17 @@ def initialize(parent_type:, selections:, objects:, results:, runner:) def execute grouped_selections = {} - @selections.each do |ast_selection| + gather_selections(@selections, into: grouped_selections) + end + + private + + def gather_selections(ast_selections, into:) + ast_selections.each do |ast_selection| case ast_selection when GraphQL::Language::Nodes::Field key = ast_selection.alias || ast_selection.name - step = grouped_selections[key] ||= begin + step = into[key] ||= begin frs = FieldResolveStep.new( parent_type: @parent_type, objects: @objects, @@ -158,6 +164,11 @@ def execute frs end step.append_selection(ast_selection) + when GraphQL::Language::Nodes::InlineFragment + type_condition = ast_selection.type.name + if type_condition == @parent_type.graphql_name + gather_selections(ast_selection.selections, into: into) + end else raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 31cf44a7d7..38561970c9 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -39,14 +39,14 @@ class Season < GraphQL::Schema::Enum value "FALL" end - class Species < BaseObject + class PlantSpecies < BaseObject field :name, String, object_method: :name end class PlantFamily < BaseObject field :name, String, object_method: :name field :grows_in, Season, object_method: :grows_in - field :species, [Species], object_method: :species + field :species, [PlantSpecies], object_method: :species end @@ -59,7 +59,7 @@ def self.all_str(objects, context) objects.map { |obj| obj.class.name } end - field :find_species, Species do + field :find_species, PlantSpecies do argument :name, String end @@ -85,7 +85,10 @@ def run_next(query_str, root_object: nil) it "runs a query" do result = run_next("{ str - families { name growsIn } + families { + name + ... on PlantFamily { growsIn } + } families { species { name } } t: findSpecies(name: \"Tomato\") { name } c: findSpecies(name: \"Cucumber\") { name } From ddde2d95a517c21c9bb493846212d347d32ed524 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 10:33:23 -0500 Subject: [PATCH 07/22] Update Field object shape spec --- spec/graphql/schema/field_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 756fd3f49b7998c718ad2979da48a0c62d31bb36 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 11:27:23 -0500 Subject: [PATCH 08/22] start on abstract types support --- lib/graphql/execution/next.rb | 20 ++++++++- spec/graphql/execution/next_spec.rb | 65 +++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 9408289c9e..f420286336 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -21,7 +21,7 @@ def initialize(schema, document, context, variables, root_object) @data = {} end - attr_reader :steps_queue, :schema, :context + attr_reader :steps_queue, :schema, :context, :document def execute operation = @document.definitions.first # TODO select named operation @@ -148,6 +148,16 @@ def execute private + def type_condition_applies?(type_name) + if type_name == @parent_type.graphql_name + true + else + abs_t = @runner.schema.get_type(type_name, @runner.context) + p_types = @runner.schema.possible_types(abs_t, @runner.context) + p_types.include?(@parent_type) + end + end + def gather_selections(ast_selections, into:) ast_selections.each do |ast_selection| case ast_selection @@ -166,9 +176,15 @@ def gather_selections(ast_selections, into:) step.append_selection(ast_selection) when GraphQL::Language::Nodes::InlineFragment type_condition = ast_selection.type.name - if type_condition == @parent_type.graphql_name + if type_condition_applies?(type_condition) gather_selections(ast_selection.selections, into: into) end + when GraphQL::Language::Nodes::FragmentSpread + fragment_definition = @runner.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_condition) + gather_selections(fragment_definition.selections, into: into) + end else raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 38561970c9..bf37f3602c 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -26,6 +26,11 @@ class BaseObject < GraphQL::Schema::Object field_class BaseField end + module BaseInterface + include GraphQL::Schema::Interface + field_class BaseField + end + ALL_FAMILIES = [ OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "SUMMER", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), OpenStruct.new(name: "Nightshades", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Tomato")]), @@ -39,16 +44,34 @@ class Season < GraphQL::Schema::Enum value "FALL" end - class PlantSpecies < BaseObject + module Nameable + include BaseInterface field :name, String, object_method: :name + + def self.resolve_type(obj, ctx) + if obj.respond_to?(:grows_in) + PlantFamily + else + PlantSpecies + end + end + end + + class PlantSpecies < BaseObject + implements Nameable + field :poisonous, Boolean, value: false end class PlantFamily < BaseObject - field :name, String, object_method: :name + implements Nameable field :grows_in, Season, object_method: :grows_in field :species, [PlantSpecies], object_method: :species end + class Thing < GraphQL::Schema::Union + possible_types(PlantFamily, PlantSpecies) + end + class Query < BaseObject field :families, [PlantFamily], value: ALL_FAMILIES @@ -72,6 +95,8 @@ def self.all_find_species(objects, context, name:) end Array.new(objects.length, species) end + + field :all_things, [Thing], value: ALL_FAMILIES + ALL_FAMILIES.map { |f| f.species }.flatten end query(Query) @@ -86,14 +111,28 @@ def run_next(query_str, root_object: nil) result = run_next("{ str families { - name + ... on Nameable { name } ... on PlantFamily { growsIn } } families { species { name } } - t: findSpecies(name: \"Tomato\") { name } - c: findSpecies(name: \"Cucumber\") { name } + t: findSpecies(name: \"Tomato\") { ...SpeciesInfo ... NameableInfo } + c: findSpecies(name: \"Cucumber\") { name ...SpeciesInfo } x: findSpecies(name: \"Blue Rasperry\") { name } - }", root_object: "Abc") + allThings { + # __typename + ... on Nameable { name } + ... on PlantFamily { growsIn } + } + } + + fragment SpeciesInfo on PlantSpecies { + poisonous + } + + fragment NameableInfo on Nameable { + name + } + ", root_object: "Abc") expected_result = { "data" => { "str" => "String", @@ -102,9 +141,17 @@ def run_next(query_str, root_object: nil) {"name" => "Nightshades", "growsIn" => ["SUMMER"], "species" => [{"name" => "Tomato"}]}, {"name" => "Curcurbits", "growsIn" => ["SUMMER"], "species" => [{"name" => "Cucumber"}]} ], - "t" => { "name" => "Tomato" }, - "c" => { "name" => "Cucumber" }, - "x" => nil + "t" => { "name" => "Tomato", "poisonous" => false }, + "c" => { "name" => "Cucumber", "poisonous" => false }, + "x" => nil, + "allThings" => [ + {"name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"]}, + {"name" => "Nightshades", "growsIn" => ["SUMMER"]}, + {"name" => "Curcurbits", "growsIn" => ["SUMMER"]}, + {"name" => "Snow Pea"}, + {"name" => "Tomato"}, + {"name" => "Cucumber"}, + ] } } assert_equal(expected_result, result) From cde91ed0a9eca5b832094acc195a40b9d480096d Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 15:52:52 -0500 Subject: [PATCH 09/22] Implement spreads on abstract types --- lib/graphql/execution/next.rb | 44 ++++++++++++++++++----------- spec/graphql/execution/next_spec.rb | 19 +++++++------ 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index f420286336..820bca38b8 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -81,42 +81,54 @@ def execute @ast_nodes.each do |ast_node| next_selections.concat(ast_node.selections) end + all_next_objects = [] + all_next_results = [] if return_type.list? - all_next_objects = [] - all_next_results = [] field_results.each_with_index do |result_arr, i| + # TODO handle `nil` here - DRY with below next_results = Array.new(result_arr.length) { Hash.new } result_h = @results[i] result_h[result_key] = next_results all_next_objects.concat(result_arr) all_next_results.concat(next_results) end - @runner.steps_queue << SelectionsStep.new( - parent_type: return_result_type, - selections: next_selections, - objects: all_next_objects, - results: all_next_results, - runner: @runner, - ) else - next_results = nil - + all_next_objects.concat(field_results) field_results.each_with_index do |result, i| result_h = @results[i] if result.nil? result_h[result_key] = nil else - next_results ||= [] - next_results << result_h[result_key] = {} + all_next_results << result_h[result_key] = {} end end + end - if next_results + if !all_next_results.empty? + 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( + 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( parent_type: return_result_type, selections: next_selections, - objects: field_results, - results: next_results, + objects: all_next_objects, + results: all_next_results, runner: @runner, ) end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index bf37f3602c..f675a07f7d 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -47,14 +47,6 @@ class Season < GraphQL::Schema::Enum module Nameable include BaseInterface field :name, String, object_method: :name - - def self.resolve_type(obj, ctx) - if obj.respond_to?(:grows_in) - PlantFamily - else - PlantSpecies - end - end end class PlantSpecies < BaseObject @@ -100,6 +92,15 @@ def self.all_find_species(objects, context, name:) end query(Query) + + + def self.resolve_type(abs_type, obj, ctx) + if obj.respond_to?(:grows_in) + PlantFamily + else + PlantSpecies + end + end end @@ -141,7 +142,7 @@ def run_next(query_str, root_object: nil) {"name" => "Nightshades", "growsIn" => ["SUMMER"], "species" => [{"name" => "Tomato"}]}, {"name" => "Curcurbits", "growsIn" => ["SUMMER"], "species" => [{"name" => "Cucumber"}]} ], - "t" => { "name" => "Tomato", "poisonous" => false }, + "t" => { "poisonous" => false, "name" => "Tomato" }, "c" => { "name" => "Cucumber", "poisonous" => false }, "x" => nil, "allThings" => [ From e20fc744cce698835850034998ac905c63ebb7a3 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 16:02:12 -0500 Subject: [PATCH 10/22] basic variable lookup --- lib/graphql/execution/next.rb | 56 +++++++++++++++++------------ spec/graphql/execution/next_spec.rb | 11 +++--- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 820bca38b8..a08f1d12bd 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -21,7 +21,7 @@ def initialize(schema, document, context, variables, root_object) @data = {} end - attr_reader :steps_queue, :schema, :context, :document + attr_reader :steps_queue, :schema, :context, :document, :variables def execute operation = @document.definitions.first # TODO select named operation @@ -60,13 +60,19 @@ def append_selection(ast_node) @ast_nodes << ast_node end - def execute ast_node = @ast_nodes.first 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 = ast_node.arguments.each_with_object({}) { |arg_node, obj| obj[arg_node.name.to_sym] = arg_node.value } + arguments = ast_node.arguments.each_with_object({}) { |arg_node, obj| + arg_value = arg_node.value + if arg_value.is_a?(Language::Nodes::VariableIdentifier) + arg_value = @runner.variables.fetch(arg_value.name) + end + + obj[arg_node.name.to_sym] = arg_value + } field_results = if arguments.empty? field_defn.resolve_all(@objects, @runner.context) @@ -77,31 +83,37 @@ def execute return_type = field_defn.type return_result_type = return_type.unwrap if return_result_type.kind.composite? - next_selections = [] # TODO optimize for one ast node - @ast_nodes.each do |ast_node| - next_selections.concat(ast_node.selections) + if @ast_nodes.size == 1 + next_selections = @ast_nodes.first.selections + else + next_selections = [] + @ast_nodes.each do |ast_node| + next_selections.concat(ast_node.selections) + end end + all_next_objects = [] all_next_results = [] - if return_type.list? - field_results.each_with_index do |result_arr, i| - # TODO handle `nil` here - DRY with below - next_results = Array.new(result_arr.length) { Hash.new } - result_h = @results[i] - result_h[result_key] = next_results - all_next_objects.concat(result_arr) + is_list = return_type.list? + + field_results.each_with_index do |result, i| + result_h = @results[i] + if result.nil? + result_h[result_key] = nil + next + elsif is_list + next_results = Array.new(result.length) { Hash.new } + all_next_objects.concat(result) all_next_results.concat(next_results) + else + next_results = {} + all_next_results << next_results end - else + result_h[result_key] = next_results + end + + if !is_list && !all_next_results.empty? all_next_objects.concat(field_results) - field_results.each_with_index do |result, i| - result_h = @results[i] - if result.nil? - result_h[result_key] = nil - else - all_next_results << result_h[result_key] = {} - end - end end if !all_next_results.empty? diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index f675a07f7d..715b79ad53 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -104,19 +104,20 @@ def self.resolve_type(abs_type, obj, ctx) end - def run_next(query_str, root_object: nil) - GraphQL::Execution::Next.run(schema: NextExecutionSchema, query_string: query_str, context: {}, variables: {}, root_object: root_object) + 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 it "runs a query" do - result = run_next("{ + result = run_next(" + query TestNext($name: String) { str families { ... on Nameable { name } ... on PlantFamily { growsIn } } families { species { name } } - t: findSpecies(name: \"Tomato\") { ...SpeciesInfo ... NameableInfo } + t: findSpecies(name: $name) { ...SpeciesInfo ... NameableInfo } c: findSpecies(name: \"Cucumber\") { name ...SpeciesInfo } x: findSpecies(name: \"Blue Rasperry\") { name } allThings { @@ -133,7 +134,7 @@ def run_next(query_str, root_object: nil) fragment NameableInfo on Nameable { name } - ", root_object: "Abc") + ", root_object: "Abc", variables: { "name" => "Tomato" }) expected_result = { "data" => { "str" => "String", From cb90cf534821590c89447d2b05ea92d39b66348a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 21 Jan 2026 16:56:59 -0500 Subject: [PATCH 11/22] Implement isolated mutation roots --- lib/graphql/execution/next.rb | 143 ++++++++++++++++------------ spec/graphql/execution/next_spec.rb | 69 ++++++++++++-- 2 files changed, 141 insertions(+), 71 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index a08f1d12bd..101c322358 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -21,41 +21,99 @@ def initialize(schema, document, context, variables, root_object) @data = {} end - attr_reader :steps_queue, :schema, :context, :document, :variables + attr_reader :steps_queue, :schema, :context, :variables def execute operation = @document.definitions.first # TODO select named operation - root_type = case operation.operation_type + isolated_steps = case operation.operation_type when nil, "query" - @schema.query + [ + SelectionsStep.new( + parent_type: @schema.query, + selections: operation.selections, + objects: [@root_object], + results: [@data], + runner: self, + ) + ] + when "mutation" + fields = {} + gather_selections(@schema.mutation, operation.selections, into: fields) + fields.each_value.map do |field_resolve_step| + SelectionsStep.new( + parent_type: @schema.mutation, + selections: field_resolve_step.ast_nodes, + objects: [@root_object], + results: [@data], + runner: self, + ) + end else raise ArgumentError, "Unhandled operation type: #{operation.operation_type.inspect}" end - @steps_queue << SelectionsStep.new( - parent_type: root_type, - selections: operation.selections, - objects: [@root_object], - results: [@data], - runner: self, - ) - - while (next_step = @steps_queue.shift) - next_step.execute + while (next_isolated_step = isolated_steps.shift) + @steps_queue << next_isolated_step + while (step = @steps_queue.shift) + step.execute + end end { "data" => @data } end + def gather_selections(type_defn, ast_selections, into:) + ast_selections.each do |ast_selection| + case ast_selection + when GraphQL::Language::Nodes::Field + key = ast_selection.alias || ast_selection.name + step = into[key] ||= FieldResolveStep.new( + 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, 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, into: into) + end + else + raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" + end + end + end + + private + + 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:, objects:, results:, runner:) + def initialize(parent_type:, runner:) @parent_type = parent_type @ast_nodes = [] - @objects = objects - @results = results + @objects = nil + @results = nil @runner = runner end + attr_writer :objects, :results + + attr_reader :ast_nodes + def append_selection(ast_node) @ast_nodes << ast_node end @@ -82,6 +140,7 @@ def execute return_type = field_defn.type return_result_type = return_type.unwrap + if return_result_type.kind.composite? if @ast_nodes.size == 1 next_selections = @ast_nodes.first.selections @@ -163,55 +222,13 @@ def initialize(parent_type:, selections:, objects:, results:, runner:) @runner = runner end - attr_reader :runner - def execute grouped_selections = {} - gather_selections(@selections, into: grouped_selections) - end - - private - - def type_condition_applies?(type_name) - if type_name == @parent_type.graphql_name - true - else - abs_t = @runner.schema.get_type(type_name, @runner.context) - p_types = @runner.schema.possible_types(abs_t, @runner.context) - p_types.include?(@parent_type) - end - end - - def gather_selections(ast_selections, into:) - ast_selections.each do |ast_selection| - case ast_selection - when GraphQL::Language::Nodes::Field - key = ast_selection.alias || ast_selection.name - step = into[key] ||= begin - frs = FieldResolveStep.new( - parent_type: @parent_type, - objects: @objects, - results: @results, - runner: @runner, - ) - runner.steps_queue << frs - frs - end - step.append_selection(ast_selection) - when GraphQL::Language::Nodes::InlineFragment - type_condition = ast_selection.type.name - if type_condition_applies?(type_condition) - gather_selections(ast_selection.selections, into: into) - end - when GraphQL::Language::Nodes::FragmentSpread - fragment_definition = @runner.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_condition) - gather_selections(fragment_definition.selections, into: into) - end - else - raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" - end + @runner.gather_selections(@parent_type, @selections, into: grouped_selections) + grouped_selections.each_value do |frs| + frs.objects = @objects + frs.results = @results + @runner.steps_queue << frs end end end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 715b79ad53..c3a2b7c815 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -31,12 +31,14 @@ module BaseInterface field_class BaseField end - ALL_FAMILIES = [ + CLEAN_DATA = [ OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "SUMMER", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), OpenStruct.new(name: "Nightshades", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Tomato")]), OpenStruct.new(name: "Curcurbits", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Cucumber")]) ] + DATA = [] + class Season < GraphQL::Schema::Enum value "WINTER" value "SPRING" @@ -52,21 +54,32 @@ module Nameable 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 end class PlantFamily < BaseObject implements Nameable 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 } + end end class Thing < GraphQL::Schema::Union possible_types(PlantFamily, PlantSpecies) end - class Query < BaseObject - field :families, [PlantFamily], value: ALL_FAMILIES + field :families, [PlantFamily], value: DATA field :str, String @@ -80,7 +93,7 @@ def self.all_str(objects, context) def self.all_find_species(objects, context, name:) species = nil - ALL_FAMILIES.each do |f| + DATA.each do |f| if (species = f.species.find { |s| s.name == name }) break end @@ -88,11 +101,29 @@ def self.all_find_species(objects, context, name:) Array.new(objects.length, species) end - field :all_things, [Thing], value: ALL_FAMILIES + ALL_FAMILIES.map { |f| f.species }.flatten + field :all_things, [Thing] + + def self.all_all_things(_objs, _ctx) + [DATA + DATA.map(&:species).flatten] + end end - query(Query) + class Mutation < BaseObject + field :create_plant, PlantSpecies do + argument :name, String + argument :family, String + end + def self.all_create_plant(_objs, _ctx, name:,family:) + family_obj = DATA.find { |f| f.name == family} + species_obj = OpenStruct.new(name: name) + 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) @@ -104,10 +135,15 @@ def self.resolve_type(abs_type, obj, ctx) end - def run_next(query_str, root_object: nil, variables:) + 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) { @@ -156,6 +192,23 @@ def run_next(query_str, root_object: nil, variables:) ] } } - assert_equal(expected_result, result) + assert_graphql_equal(expected_result, result) + end + + it "runs mutations in isolation" do + result = run_next <<~GRAPHQL + mutation TestSequence { + p1: createPlant(name: "Eggplant", family: "Nightshades") { family { plantCount } } + p2: createPlant(name: "Ground Cherry", family: "Nightshades") { family { plantCount } } + p3: createPlant(name: "Potato", family: "Nightshades") { family { plantCount } } + } + GRAPHQL + + expected_result = { "data" => { + "p1" => { "family" => { "plantCount" => 2 }}, + "p2" => { "family" => { "plantCount" => 3 }}, + "p3" => { "family" => { "plantCount" => 4 }} + } } + assert_graphql_equal(expected_result, result) end end From a80dfb56c1a213874103c3952abfbd212f79cc22 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 22 Jan 2026 08:57:37 -0500 Subject: [PATCH 12/22] Support input objects and lists --- lib/graphql/execution/next.rb | 36 ++++++++++++++++++++++------- spec/graphql/execution/next_spec.rb | 32 +++++++++++++++++-------- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 101c322358..0767224b30 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -118,19 +118,39 @@ def append_selection(ast_node) @ast_nodes << ast_node end + def coerce_arguments(ast_arguments) + ast_arguments.each_with_object({}) { |arg_node, obj| + arg_value = coerce_argument_value(arg_node.value) + arg_key = Schema::Member::BuildType.underscore(arg_node.name).to_sym + obj[arg_key] = arg_value + } + end + + def coerce_argument_value(arg_value) + case arg_value + when String, Numeric, true, false, nil + arg_value + when Language::Nodes::VariableIdentifier + @runner.variables.fetch(arg_value.name) + when Language::Nodes::InputObject + coerce_arguments(arg_value.arguments) + when Language::Nodes::Enum + arg_value.name + when Array + arg_value.map { |v| coerce_argument_value(v) } + when Language::Nodes::NullValue + nil + else + raise "Unsupported argument value: #{arg_value.class} (#{arg_value.inspect})" + end + end + def execute ast_node = @ast_nodes.first 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 = ast_node.arguments.each_with_object({}) { |arg_node, obj| - arg_value = arg_node.value - if arg_value.is_a?(Language::Nodes::VariableIdentifier) - arg_value = @runner.variables.fetch(arg_value.name) - end - - obj[arg_node.name.to_sym] = arg_value - } + arguments = coerce_arguments(ast_node.arguments) field_results = if arguments.empty? field_defn.resolve_all(@objects, @runner.context) diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index c3a2b7c815..6604efe265 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -61,6 +61,12 @@ def self.all_family(objects, context) 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 @@ -109,14 +115,22 @@ def self.all_all_things(_objs, _ctx) end class Mutation < BaseObject - field :create_plant, PlantSpecies do + class CreatePlantInput < GraphQL::Schema::InputObject argument :name, String argument :family, String + argument :grows_in, [Season] + end + + field :create_plant, PlantSpecies do + argument :input, CreatePlantInput end - def self.all_create_plant(_objs, _ctx, name:,family:) + 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) + species_obj = OpenStruct.new(name: name, grows_in: grows_in ) family_obj.species << species_obj [species_obj] end @@ -198,16 +212,16 @@ def run_next(query_str, root_object: nil, variables: {}) it "runs mutations in isolation" do result = run_next <<~GRAPHQL mutation TestSequence { - p1: createPlant(name: "Eggplant", family: "Nightshades") { family { plantCount } } - p2: createPlant(name: "Ground Cherry", family: "Nightshades") { family { plantCount } } - p3: createPlant(name: "Potato", family: "Nightshades") { family { plantCount } } + p1: createPlant(input: { name: "Eggplant", family: "Nightshades", growsIn: [SUMMER] }) { growsIn family { plantCount } } + p2: createPlant(input: { name: "Ground Cherry", family: "Nightshades", growsIn: [SUMMER] }) { growsIn family { plantCount } } + p3: createPlant(input: { name: "Potato", family: "Nightshades", growsIn: [SPRING, SUMMER] }) { growsIn family { plantCount } } } GRAPHQL expected_result = { "data" => { - "p1" => { "family" => { "plantCount" => 2 }}, - "p2" => { "family" => { "plantCount" => 3 }}, - "p3" => { "family" => { "plantCount" => 4 }} + "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 From 98cd05ca18410d9a000eed2c24c96c3314a72b0e Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 07:05:53 -0500 Subject: [PATCH 13/22] Support argument default values and test Introspection Query --- lib/graphql/execution/next.rb | 57 ++++++++++++++++++++++------- spec/graphql/execution/next_spec.rb | 10 ++++- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 0767224b30..8e93ad00b8 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -5,7 +5,10 @@ module Next def self.run(schema:, query_string:, context:, variables:, root_object:) document = GraphQL.parse(query_string) - runner = Runner.new(schema, document, context, variables, root_object) + 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 @@ -118,26 +121,37 @@ def append_selection(ast_node) @ast_nodes << ast_node end - def coerce_arguments(ast_arguments) - ast_arguments.each_with_object({}) { |arg_node, obj| - arg_value = coerce_argument_value(arg_node.value) + def coerce_arguments(argument_owner, ast_arguments) + arg_defns = argument_owner.arguments(@runner.context) + args_hash = ast_arguments.each_with_object({}) { |arg_node, obj| + arg_defn = arg_defns[arg_node.name] + arg_value = coerce_argument_value(arg_defn, arg_node.value) arg_key = Schema::Member::BuildType.underscore(arg_node.name).to_sym obj[arg_key] = arg_value } + + 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_value) + def coerce_argument_value(argument_defn, arg_value) case arg_value when String, Numeric, true, false, nil arg_value when Language::Nodes::VariableIdentifier @runner.variables.fetch(arg_value.name) when Language::Nodes::InputObject - coerce_arguments(arg_value.arguments) + coerce_arguments(argument_defn.type.unwrap, arg_value.arguments) # rubocop:disable Development/ContextIsPassedCop when Language::Nodes::Enum arg_value.name when Array - arg_value.map { |v| coerce_argument_value(v) } + inner_arg_t = argument_defn.type.unwrap + arg_value.map { |v| coerce_argument_value(inner_arg_t, v) } when Language::Nodes::NullValue nil else @@ -150,8 +164,7 @@ 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(ast_node.arguments) - + arguments = coerce_arguments(field_defn, ast_node.arguments) field_results = if arguments.empty? field_defn.resolve_all(@objects, @runner.context) else @@ -187,14 +200,11 @@ def execute else next_results = {} all_next_results << next_results + all_next_objects << result end result_h[result_key] = next_results end - if !is_list && !all_next_results.empty? - all_next_objects.concat(field_results) - end - if !all_next_results.empty? if return_result_type.kind.abstract? next_objects_by_type = Hash.new { |h, obj_t| h[obj_t] = [] }.compare_by_identity @@ -253,6 +263,27 @@ def execute end end end + + + module FieldCompatibility + def resolve_all(objects, context, **kwargs) + if @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/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 6604efe265..603c0c667d 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -118,7 +118,7 @@ class Mutation < BaseObject class CreatePlantInput < GraphQL::Schema::InputObject argument :name, String argument :family, String - argument :grows_in, [Season] + argument :grows_in, [Season], default_value: ["SUMMER"] end field :create_plant, PlantSpecies do @@ -213,7 +213,7 @@ def run_next(query_str, root_object: nil, variables: {}) 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: [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 @@ -225,4 +225,10 @@ def run_next(query_str, root_object: nil, variables: {}) } } 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 end From 57c67e53bddef6e626e716e18123d528c6c24370 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 07:13:15 -0500 Subject: [PATCH 14/22] Add basic __typename support --- lib/graphql/execution/next.rb | 10 ++++++++-- spec/graphql/execution/next_spec.rb | 14 +++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 8e93ad00b8..c687eb5804 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -165,10 +165,16 @@ def execute result_key = ast_node.alias || ast_node.name arguments = coerce_arguments(field_defn, ast_node.arguments) + + 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(@objects, @runner.context) + field_defn.resolve_all(field_objs, @runner.context) else - field_defn.resolve_all(@objects, @runner.context, **arguments) + field_defn.resolve_all(field_objs, @runner.context, **arguments) end return_type = field_defn.type diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 603c0c667d..e01278ce03 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -171,7 +171,7 @@ def run_next(query_str, root_object: nil, variables: {}) c: findSpecies(name: \"Cucumber\") { name ...SpeciesInfo } x: findSpecies(name: \"Blue Rasperry\") { name } allThings { - # __typename + __typename ... on Nameable { name } ... on PlantFamily { growsIn } } @@ -197,12 +197,12 @@ def run_next(query_str, root_object: nil, variables: {}) "c" => { "name" => "Cucumber", "poisonous" => false }, "x" => nil, "allThings" => [ - {"name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"]}, - {"name" => "Nightshades", "growsIn" => ["SUMMER"]}, - {"name" => "Curcurbits", "growsIn" => ["SUMMER"]}, - {"name" => "Snow Pea"}, - {"name" => "Tomato"}, - {"name" => "Cucumber"}, + {"__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"}, ] } } From 938a36bdb769085f9e8312fb34c995f0fb16ecc7 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 07:20:41 -0500 Subject: [PATCH 15/22] Basic skip and include test --- lib/graphql/execution/next.rb | 12 ++++++++++++ spec/graphql/execution/next_spec.rb | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index c687eb5804..021339740f 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -67,6 +67,7 @@ def execute def gather_selections(type_defn, ast_selections, 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 @@ -94,6 +95,17 @@ def gather_selections(type_defn, ast_selections, into:) private + def directives_include?(ast_selection) + if ast_selection.directives.any? { |dir_node| + (dir_node.name == "skip" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == true }) || + (dir_node.name == "include" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == false }) + } + false + else + true + end + end + def type_condition_applies?(concrete_type, type_name) if type_name == concrete_type.graphql_name true diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index e01278ce03..2735117745 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -231,4 +231,21 @@ def run_next(query_str, root_object: nil, variables: {}) 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 end From ea04e2f0e0ab497370f64fa7eb80f099c9aeec6a Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 07:22:08 -0500 Subject: [PATCH 16/22] lint ignores --- lib/graphql/execution/next.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 021339740f..6890262c72 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -97,8 +97,8 @@ def gather_selections(type_defn, ast_selections, into:) def directives_include?(ast_selection) if ast_selection.directives.any? { |dir_node| - (dir_node.name == "skip" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == true }) || - (dir_node.name == "include" && dir_node.arguments.any? { |arg_node| arg_node.name == "if" && arg_node.value == false }) + (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 From 5aad9c6a50faf365a62314d828a6fd6b5bf91149 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 08:12:30 -0500 Subject: [PATCH 17/22] Add static validation --- lib/graphql/execution/next.rb | 6 ++++++ spec/graphql/execution/next_spec.rb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 6890262c72..f223c89f49 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -5,6 +5,12 @@ module Next def self.run(schema:, query_string:, context:, variables:, root_object:) document = GraphQL.parse(query_string) + validation_errors = schema.validate(document, context: context) + if !validation_errors.empty? + return { + "errors" => validation_errors.map(&:to_h) + } + end dummy_q = GraphQL::Query.new(schema, document: document, context: context, variables: variables, root_value: root_object) query_context = dummy_q.context diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index 2735117745..cf578131cb 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -160,7 +160,7 @@ def run_next(query_str, root_object: nil, variables: {}) it "runs a query" do result = run_next(" - query TestNext($name: String) { + query TestNext($name: String!) { str families { ... on Nameable { name } From a873638fb839078a02c45e9d015215f6ee645785 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 08:33:42 -0500 Subject: [PATCH 18/22] Add input coercion --- lib/graphql/execution/next.rb | 67 +++++++++++++++++++---------- spec/graphql/execution/next_spec.rb | 18 ++++++++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index f223c89f49..42aa69a7b7 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -139,14 +139,23 @@ def append_selection(ast_node) @ast_nodes << ast_node end - def coerce_arguments(argument_owner, ast_arguments) + def coerce_arguments(argument_owner, ast_arguments_or_hash) arg_defns = argument_owner.arguments(@runner.context) - args_hash = ast_arguments.each_with_object({}) { |arg_node, obj| - arg_defn = arg_defns[arg_node.name] - arg_value = coerce_argument_value(arg_defn, arg_node.value) - arg_key = Schema::Member::BuildType.underscore(arg_node.name).to_sym - obj[arg_key] = arg_value - } + 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) @@ -157,23 +166,35 @@ def coerce_arguments(argument_owner, ast_arguments) args_hash end - def coerce_argument_value(argument_defn, arg_value) - case arg_value - when String, Numeric, true, false, nil - arg_value - when Language::Nodes::VariableIdentifier - @runner.variables.fetch(arg_value.name) - when Language::Nodes::InputObject - coerce_arguments(argument_defn.type.unwrap, arg_value.arguments) # rubocop:disable Development/ContextIsPassedCop - when Language::Nodes::Enum - arg_value.name - when Array - inner_arg_t = argument_defn.type.unwrap - arg_value.map { |v| coerce_argument_value(inner_arg_t, v) } - when Language::Nodes::NullValue - nil + 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_value.class} (#{arg_value.inspect})" + raise "Unsupported argument value: #{arg_t.to_type_signature} / #{arg_value.class} (#{arg_value.inspect})" end end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index cf578131cb..e18d611469 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -248,4 +248,22 @@ def run_next(query_str, root_object: nil, variables: {}) } } assert_equal expected_result, result end + + it "does scalar coercion" do + result = run_next <<~GRAPHQL, variables: { input: { name: :Zucchini, family: "Curcurbits", grows_in: "SUMMER" }} + 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 end From 6d4d41dfb15653b05999814ab0a6dedfe9ad84e9 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 23 Jan 2026 08:44:37 -0500 Subject: [PATCH 19/22] Also test enum coercion --- lib/graphql/execution/next.rb | 4 ++++ spec/graphql/execution/next_spec.rb | 16 ++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 42aa69a7b7..8eaa2c12b3 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -280,8 +280,12 @@ def execute end end else + return_type = field_defn.type 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})") + if !result.nil? + result = return_type.coerce_result(result, @runner.context) + end result_h[result_key] = result end end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb index e18d611469..7c56b7c02a 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -32,9 +32,9 @@ module BaseInterface end CLEAN_DATA = [ - OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "SUMMER", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), - OpenStruct.new(name: "Nightshades", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Tomato")]), - OpenStruct.new(name: "Curcurbits", grows_in: ["SUMMER"], species: [OpenStruct.new(name: "Cucumber")]) + 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 = [] @@ -42,7 +42,7 @@ module BaseInterface class Season < GraphQL::Schema::Enum value "WINTER" value "SPRING" - value "SUMMER" + value "SUMMER", value: "🌻" value "FALL" end @@ -71,12 +71,12 @@ def self.all_grows_in(objects, context) class PlantFamily < BaseObject implements Nameable - field :grows_in, Season, object_method: :grows_in + 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 } + objects.map { |o| o.species.length.to_f } # let it be coerced to int end end @@ -118,7 +118,7 @@ class Mutation < BaseObject class CreatePlantInput < GraphQL::Schema::InputObject argument :name, String argument :family, String - argument :grows_in, [Season], default_value: ["SUMMER"] + argument :grows_in, [Season], default_value: ["🌻"] end field :create_plant, PlantSpecies do @@ -250,7 +250,7 @@ def run_next(query_str, root_object: nil, variables: {}) end it "does scalar coercion" do - result = run_next <<~GRAPHQL, variables: { input: { name: :Zucchini, family: "Curcurbits", grows_in: "SUMMER" }} + result = run_next <<~GRAPHQL, variables: { input: { name: :Zucchini, family: "Curcurbits", grows_in: "🌻" }} mutation TestCoerce($input: CreatePlantInput!) { createPlant(input: $input) { name From a3212cae88df398d91e8cdc961412b9f47b926ee Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 24 Jan 2026 10:33:44 -0500 Subject: [PATCH 20/22] Implement null propagation via second pass over result --- lib/graphql/execution/next.rb | 235 ++++++++++++++++++++++++---- lib/graphql/invalid_null_error.rb | 6 +- lib/graphql/schema.rb | 3 +- spec/graphql/execution/next_spec.rb | 105 +++++++++++++ 4 files changed, 315 insertions(+), 34 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 8eaa2c12b3..7419d089a5 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -2,14 +2,15 @@ module GraphQL module Execution module Next - def self.run(schema:, query_string:, context:, variables:, root_object:) - - document = GraphQL.parse(query_string) - validation_errors = schema.validate(document, context: context) - if !validation_errors.empty? - return { - "errors" => validation_errors.map(&:to_h) - } + 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 @@ -26,34 +27,40 @@ def initialize(schema, document, context, variables, root_object) @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 + attr_reader :steps_queue, :schema, :context, :variables, :runtime_types_at_result def execute - operation = @document.definitions.first # TODO select named operation - isolated_steps = case operation.operation_type + @selected_operation = @document.definitions.first # TODO select named operation + isolated_steps = case @selected_operation.operation_type when nil, "query" [ SelectionsStep.new( - parent_type: @schema.query, - selections: operation.selections, + 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, operation.selections, into: fields) + gather_selections(@schema.mutation, @selected_operation.selections, nil, into: fields) fields.each_value.map do |field_resolve_step| SelectionsStep.new( - parent_type: @schema.mutation, + parent_type: @root_type = @schema.mutation, selections: field_resolve_step.ast_nodes, objects: [@root_object], results: [@data], + path: EmptyObjects::EMPTY_ARRAY, runner: self, ) end @@ -67,17 +74,29 @@ def execute step.execute end end - - { "data" => @data } + 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, into:) + 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, ) @@ -85,13 +104,13 @@ def gather_selections(type_defn, ast_selections, into:) 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, into: into) + 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, into: into) + 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})" @@ -99,10 +118,116 @@ def gather_selections(type_defn, ast_selections, into:) 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 } @@ -123,24 +248,34 @@ def type_condition_applies?(concrete_type, type_name) end class FieldResolveStep - def initialize(parent_type:, runner:) + def initialize(parent_type:, runner:, key:, selections_step:) + @selection_step = selections_step + @key = key @parent_type = parent_type - @ast_nodes = [] + @ast_nodes = [] # TODO optimize for one node @objects = nil @results = nil @runner = runner + @path = nil end attr_writer :objects, :results attr_reader :ast_nodes + def path + @path ||= [*@selection_step.path, @key].freeze + end + def append_selection(ast_node) @ast_nodes << ast_node 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| @@ -236,14 +371,43 @@ def execute field_results.each_with_index do |result, i| result_h = @results[i] if result.nil? - result_h[result_key] = nil + if return_type.non_null? + # TODO Add error and propagate + else + result_h[result_key] = nil + end next elsif is_list - next_results = Array.new(result.length) { Hash.new } - all_next_objects.concat(result) + inner_t = return_type.non_null? ? return_type.of_type.of_type : return_type.of_type + if (inner_t_nn = inner_t.non_null?) + # TODO nested lists? + unwrapped_inner_t = inner_t.of_type + else + unwrapped_inner_t = inner_t + end + next_results = [] + next_objs = [] + result.each_with_index do |r, idx| + if r.nil? + if inner_t_nn + err = @runner.add_non_null_error(@parent_type, field_defn, @ast_nodes.first, true, [*path, idx]) + next_results << err + else + next_results << nil + end + else + next_r = {} + @runner.runtime_types_at_result[next_r] = unwrapped_inner_t + next_objs << r + next_results << next_r + end + end + + all_next_objects.concat(next_objs) all_next_results.concat(next_results) else next_results = {} + @runner.runtime_types_at_result[next_results] = return_result_type all_next_results << next_results all_next_objects << result end @@ -262,6 +426,7 @@ def execute 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, @@ -271,6 +436,7 @@ def execute 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, @@ -280,10 +446,13 @@ def execute end end else - return_type = field_defn.type 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})") - if !result.nil? + if result.nil? + if return_type.non_null? + result = @runner.add_non_null_error(@parent_type, field_defn, @ast_nodes.first, false, path) + end + else result = return_type.coerce_result(result, @runner.context) end result_h[result_key] = result @@ -293,7 +462,8 @@ def execute end class SelectionsStep - def initialize(parent_type:, selections:, objects:, results:, runner:) + def initialize(parent_type:, selections:, objects:, results:, runner:, path:) + @path = path @parent_type = parent_type @selections = selections @objects = objects @@ -301,9 +471,11 @@ def initialize(parent_type:, selections:, objects:, results:, runner:) @runner = runner end + attr_reader :path + def execute grouped_selections = {} - @runner.gather_selections(@parent_type, @selections, into: grouped_selections) + @runner.gather_selections(@parent_type, @selections, self, into: grouped_selections) grouped_selections.each_value do |frs| frs.objects = @objects frs.results = @results @@ -313,10 +485,11 @@ def execute end end - module FieldCompatibility def resolve_all(objects, context, **kwargs) - if @owner.method_defined?(@method_sym) + 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) 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 index 7c56b7c02a..2a6eb35ce6 100644 --- a/spec/graphql/execution/next_spec.rb +++ b/spec/graphql/execution/next_spec.rb @@ -71,6 +71,7 @@ def self.all_grows_in(objects, context) 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 @@ -86,6 +87,7 @@ class Thing < GraphQL::Schema::Union class Query < BaseObject field :families, [PlantFamily], value: DATA + field :nullable_families, [PlantFamily, null: true], value: DATA field :str, String @@ -266,4 +268,107 @@ def run_next(query_str, root_object: nil, variables: {}) }} } 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 From eee52023e8c44fa2222ab3a6ebd7292449d6f983 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 24 Jan 2026 11:09:17 -0500 Subject: [PATCH 21/22] Code cleanup --- lib/graphql/execution/next.rb | 142 ++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 60 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 7419d089a5..7f1f31d587 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -57,7 +57,7 @@ def execute fields.each_value.map do |field_resolve_step| SelectionsStep.new( parent_type: @root_type = @schema.mutation, - selections: field_resolve_step.ast_nodes, + selections: Array(field_resolve_step.ast_node_or_nodes), objects: [@root_object], results: [@data], path: EmptyObjects::EMPTY_ARRAY, @@ -252,7 +252,7 @@ def initialize(parent_type:, runner:, key:, selections_step:) @selection_step = selections_step @key = key @parent_type = parent_type - @ast_nodes = [] # TODO optimize for one node + @ast_node_or_nodes = nil @objects = nil @results = nil @runner = runner @@ -261,14 +261,23 @@ def initialize(parent_type:, runner:, key:, selections_step:) attr_writer :objects, :results - attr_reader :ast_nodes + attr_reader :ast_node_or_nodes def path @path ||= [*@selection_step.path, @key].freeze end def append_selection(ast_node) - @ast_nodes << ast_node + if @ast_node_or_nodes.nil? + @ast_node_or_nodes = ast_node + elsif @ast_node_or_nodes.is_a?(Array) + @ast_node_or_nodes << ast_node + else + nodes = [@ast_node_or_nodes] + nodes << ast_node + @ast_node_or_nodes = nodes + end + nil end def coerce_arguments(argument_owner, ast_arguments_or_hash) @@ -334,7 +343,13 @@ def coerce_argument_value(arg_t, arg_value) end def execute - ast_node = @ast_nodes.first + if @ast_node_or_nodes.is_a?(Array) + ast_nodes = @ast_node_or_nodes + ast_node = ast_nodes.first + else + ast_nodes = nil + ast_node = @ast_node_or_nodes + end 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 @@ -355,66 +370,23 @@ def execute return_result_type = return_type.unwrap if return_result_type.kind.composite? - if @ast_nodes.size == 1 - next_selections = @ast_nodes.first.selections - else + if ast_nodes next_selections = [] - @ast_nodes.each do |ast_node| + 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? - - field_results.each_with_index do |result, i| - result_h = @results[i] - if result.nil? - if return_type.non_null? - # TODO Add error and propagate - else - result_h[result_key] = nil - end - next - elsif is_list - inner_t = return_type.non_null? ? return_type.of_type.of_type : return_type.of_type - if (inner_t_nn = inner_t.non_null?) - # TODO nested lists? - unwrapped_inner_t = inner_t.of_type - else - unwrapped_inner_t = inner_t - end - next_results = [] - next_objs = [] - result.each_with_index do |r, idx| - if r.nil? - if inner_t_nn - err = @runner.add_non_null_error(@parent_type, field_defn, @ast_nodes.first, true, [*path, idx]) - next_results << err - else - next_results << nil - end - else - next_r = {} - @runner.runtime_types_at_result[next_r] = unwrapped_inner_t - next_objs << r - next_results << next_r - end - end - all_next_objects.concat(next_objs) - all_next_results.concat(next_results) - else - next_results = {} - @runner.runtime_types_at_result[next_results] = return_result_type - all_next_results << next_results - all_next_objects << result - end - result_h[result_key] = next_results - end + gather_next_objects(all_next_objects, all_next_results, field_defn, ast_node, result_key, field_results, return_type) 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 @@ -447,16 +419,66 @@ def execute 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})") - if result.nil? + 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? - result = @runner.add_non_null_error(@parent_type, field_defn, @ast_nodes.first, false, path) + @runner.add_non_null_error(@parent_type, field_defn, ast_node, false, path) + else + nil end else - result = return_type.coerce_result(result, @runner.context) + return_type.coerce_result(result, @runner.context) + end + end + end + end + + private + + def gather_next_objects(all_next_objects, all_next_results, field_defn, ast_node, result_key, field_results, result_type) + is_list = result_type.list? + field_results.each_with_index do |result, i| + result_h = @results[i] + if result.nil? + if result_type.non_null? + # TODO Add error and propagate + else + result_h[result_key] = nil end - result_h[result_key] = result + next + elsif is_list + inner_t = result_type.non_null? ? result_type.of_type.of_type : result_type.of_type + if (inner_t_nn = inner_t.non_null?) + # TODO nested lists? + unwrapped_inner_t = inner_t.of_type + else + unwrapped_inner_t = inner_t + end + next_results = [] + result.each_with_index do |r, idx| + if r.nil? + if inner_t_nn + err = @runner.add_non_null_error(@parent_type, field_defn, ast_node, true, [*path, idx]) + next_results << err + else + next_results << nil + end + else + next_r = {} + @runner.runtime_types_at_result[next_r] = unwrapped_inner_t + next_results << next_r + end + end + + all_next_objects.concat(result) + all_next_results.concat(next_results) + else + next_results = {} + @runner.runtime_types_at_result[next_results] = result_type.unwrap + all_next_results << next_results + all_next_objects << result end + result_h[result_key] = next_results end end end From d36c466ec12fdb4de229efccc0db6f41ed8fa18f Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 24 Jan 2026 11:39:19 -0500 Subject: [PATCH 22/22] DRY result assignment --- lib/graphql/execution/next.rb | 109 +++++++++++++--------------------- 1 file changed, 42 insertions(+), 67 deletions(-) diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb index 7f1f31d587..056260fff6 100644 --- a/lib/graphql/execution/next.rb +++ b/lib/graphql/execution/next.rb @@ -57,7 +57,7 @@ def execute fields.each_value.map do |field_resolve_step| SelectionsStep.new( parent_type: @root_type = @schema.mutation, - selections: Array(field_resolve_step.ast_node_or_nodes), + selections: field_resolve_step.ast_nodes || Array(field_resolve_step.ast_node), objects: [@root_object], results: [@data], path: EmptyObjects::EMPTY_ARRAY, @@ -252,7 +252,7 @@ def initialize(parent_type:, runner:, key:, selections_step:) @selection_step = selections_step @key = key @parent_type = parent_type - @ast_node_or_nodes = nil + @ast_node = @ast_nodes = nil @objects = nil @results = nil @runner = runner @@ -261,21 +261,19 @@ def initialize(parent_type:, runner:, key:, selections_step:) attr_writer :objects, :results - attr_reader :ast_node_or_nodes + attr_reader :ast_node, :ast_nodes def path @path ||= [*@selection_step.path, @key].freeze end def append_selection(ast_node) - if @ast_node_or_nodes.nil? - @ast_node_or_nodes = ast_node - elsif @ast_node_or_nodes.is_a?(Array) - @ast_node_or_nodes << ast_node + if @ast_node.nil? + @ast_node = ast_node + elsif @ast_nodes.nil? + @ast_nodes = [@ast_node, ast_node] else - nodes = [@ast_node_or_nodes] - nodes << ast_node - @ast_node_or_nodes = nodes + @ast_nodes << ast_node end nil end @@ -343,17 +341,10 @@ def coerce_argument_value(arg_t, arg_value) end def execute - if @ast_node_or_nodes.is_a?(Array) - ast_nodes = @ast_node_or_nodes - ast_node = ast_nodes.first - else - ast_nodes = nil - ast_node = @ast_node_or_nodes - end - 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 + 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) + 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) } @@ -370,19 +361,24 @@ def execute return_result_type = return_type.unwrap if return_result_type.kind.composite? - if ast_nodes + if @ast_nodes next_selections = [] - ast_nodes.each do |ast_node| + @ast_nodes.each do |ast_node| next_selections.concat(ast_node.selections) end else - next_selections = ast_node.selections + next_selections = @ast_node.selections end all_next_objects = [] all_next_results = [] - gather_next_objects(all_next_objects, all_next_results, field_defn, ast_node, result_key, field_results, return_type) + 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! @@ -422,7 +418,7 @@ def execute 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) + @runner.add_non_null_error(@parent_type, field_defn, @ast_node, false, path) else nil end @@ -435,50 +431,29 @@ def execute private - def gather_next_objects(all_next_objects, all_next_results, field_defn, ast_node, result_key, field_results, result_type) - is_list = result_type.list? - field_results.each_with_index do |result, i| - result_h = @results[i] - if result.nil? - if result_type.non_null? - # TODO Add error and propagate - else - result_h[result_key] = nil - end - next - elsif is_list - inner_t = result_type.non_null? ? result_type.of_type.of_type : result_type.of_type - if (inner_t_nn = inner_t.non_null?) - # TODO nested lists? - unwrapped_inner_t = inner_t.of_type - else - unwrapped_inner_t = inner_t - end - next_results = [] - result.each_with_index do |r, idx| - if r.nil? - if inner_t_nn - err = @runner.add_non_null_error(@parent_type, field_defn, ast_node, true, [*path, idx]) - next_results << err - else - next_results << nil - end - else - next_r = {} - @runner.runtime_types_at_result[next_r] = unwrapped_inner_t - next_results << next_r - end - end - - all_next_objects.concat(result) - all_next_results.concat(next_results) + 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 - next_results = {} - @runner.runtime_types_at_result[next_results] = result_type.unwrap - all_next_results << next_results - all_next_objects << result + nil + end + elsif is_list + if is_nn + return_type = return_type.of_type end - result_h[result_key] = next_results + 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