From 236cbe9fe23b005c9e17e60be9215a648dd4ef5e Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 3 Dec 2024 14:35:38 -0500 Subject: [PATCH 01/27] Add GraphQL::Query::Partial --- lib/graphql/execution/interpreter.rb | 2 +- lib/graphql/execution/interpreter/runtime.rb | 9 +- lib/graphql/query.rb | 13 +++ lib/graphql/query/context.rb | 3 - lib/graphql/query/partial.rb | 90 ++++++++++++++++++++ spec/graphql/query/partial_spec.rb | 71 +++++++++++++++ 6 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 lib/graphql/query/partial.rb create mode 100644 spec/graphql/query/partial_spec.rb diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 0d3c7cdb0a..afe878e9e4 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -26,7 +26,7 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl case opts when Hash schema.query_class.new(schema, nil, **opts) - when GraphQL::Query + when GraphQL::Query, GraphQL::Query::Partial opts else raise "Expected Hash or GraphQL::Query, not #{opts.class} (#{opts.inspect})" diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 5a3c0ce76c..c8621ed982 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -69,8 +69,13 @@ def inspect # @return [void] def run_eager root_operation = query.selected_operation - root_op_type = root_operation.operation_type || "query" - root_type = schema.root_type_for_operation(root_op_type) + # TODO duck-type #root_type + root_type = if query.is_a?(GraphQL::Query::Partial) + query.root_type + else + root_op_type = root_operation.operation_type || "query" + schema.root_type_for_operation(root_op_type) + end runtime_object = root_type.wrap(query.root_value, context) runtime_object = schema.sync_lazy(runtime_object) is_eager = root_op_type == "mutation" diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index eb253ec242..860c0cdf4d 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -10,6 +10,7 @@ class Query autoload :Context, "graphql/query/context" autoload :Fingerprint, "graphql/query/fingerprint" autoload :NullContext, "graphql/query/null_context" + autoload :Partial, "graphql/query/partial" autoload :Result, "graphql/query/result" autoload :Variables, "graphql/query/variables" autoload :InputValidationResult, "graphql/query/input_validation_result" @@ -248,6 +249,18 @@ def operations with_prepared_ast { @operations } end + # Run subtree partials of this query and return their results. + # Each partial is identified with a `path => object` pair + # where the path references a field in the AST and the object will be treated + # as the return value from that field. Subfields of the field named by `path` + # will be executed with `object` as the starting point + # @param partials_hash [Hash => Object>] `path => object` pairs + # @return [Array] + def run_partials(partials_hash) + partials = partials_hash.map { |path, obj| Partial.new(path: path, object: obj, query: self) } + Execution::Interpreter.run_all(@schema, partials, context: @context) + end + # Get the result for this query, executing it once # @return [GraphQL::Query::Result] A Hash-like GraphQL response, with `"data"` and/or `"errors"` keys def result diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 538a03adaa..bbac819800 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -53,9 +53,6 @@ def initialize(query:, schema: query.schema, values:) @storage = Hash.new { |h, k| h[k] = {} } @storage[nil] = @provided_values @errors = [] - @path = [] - @value = nil - @context = self # for SharedMethods TODO delete sharedmethods @scoped_context = ScopedContext.new(self) end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb new file mode 100644 index 0000000000..3e8e3b33b3 --- /dev/null +++ b/lib/graphql/query/partial.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true +module GraphQL + class Query + # This class is _like_ a {GraphQL::Query}, except + # @see Query#run_partials + class Partial + def initialize(path:, object:, query:) + @path = path + @object = object + @query = query + @context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: @query.context.to_h) + @multiplex = nil + @result_values = nil + @result = nil + end + + attr_reader :context + + attr_accessor :multiplex, :result_values + + def result + @result ||= GraphQL::Query::Result.new(query: self, values: result_values) + end + + def valid? + true + end + + def analyzers + EmptyObjects::EMPTY_ARRAY + end + + def current_trace + @query.current_trace + end + + def analysis_errors=(_errs) + end + + def subscription? + false + end + + def selected_operation + selection = @query.selected_operation + @path.each do |name_in_doc| + selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } + end + selection + end + + def schema + @query.schema + end + + def types + @query.types + end + + def root_value + @object + end + + def root_type + # Eventually do the traversal upstream of here, processing the group of partials together. + selection = @query.selected_operation + type = @query.schema.query # TODO could be other? + @path.each do |name_in_doc| + selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } + field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") + type = field_defn.type.unwrap + end + type + end + + # TODO dry with query + def after_lazy(value, &block) + if !defined?(@runtime_instance) + @runtime_instance = context.namespace(:interpreter_runtime)[:runtime] + end + + if @runtime_instance + @runtime_instance.minimal_after_lazy(value, &block) + else + @schema.after_lazy(value, &block) + end + end + end + end +end diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb new file mode 100644 index 0000000000..e026048faf --- /dev/null +++ b/spec/graphql/query/partial_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Query::Partial do + class PartialSchema < GraphQL::Schema + FARMS = { + "1" => OpenStruct.new(name: "Bellair Farm", products: ["VEGETABLES", "MEAT", "EGGS"]), + "2" => OpenStruct.new(name: "Henley's Orchard", products: ["FRUIT", "MEAT", "EGGS"]), + "3" => OpenStruct.new(name: "Wenger Grapes", products: ["FRUIT"]), + } + + class FarmProduct < GraphQL::Schema::Enum + value :FRUIT + value :VEGETABLES + value :MEAT + value :EGGS + value :DAIRY + end + + class Farm < GraphQL::Schema::Object + field :name, String + field :products, [FarmProduct] + end + + class Query < GraphQL::Schema::Object + field :farms, [Farm], fallback_value: FARMS.values + + field :farm, Farm do + argument :id, ID, loads: Farm, as: :farm + end + + def farm(farm:) + farm + end + end + + query(Query) + + def self.object_from_id(id, ctx) + FARMS[id] + end + + def self.resolve_type(abs_type, object, ctx) + Farm + end + end + + focus + it "returns results for the named part" do + str = "{ + farms { name, products } + farm1: farm(id: \"1\") { name } + farm2: farm(id: \"2\") { name } + }" + query = GraphQL::Query.new(PartialSchema, str) + results = query.run_partials( + ["farm1"] => PartialSchema::FARMS["1"], + ["farm2"] => OpenStruct.new(name: "Injected Farm"), + ) + + assert_equal [ + { "data" => { "name" => "Bellair Farm" } }, + { "data" => { "name" => "Injected Farm" } }, + ], results + end + + it "returns errors if they occur" + it "raises errors when given bad paths" + it "runs multiple partials concurrently" + it "returns multiple errors concurrently" +end From 5b73d6e6594d23cd8c26211fa7a800ea2c829532 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 6 Dec 2024 17:30:19 -0500 Subject: [PATCH 02/27] Add tests with args, dataloader, errorsg --- lib/graphql/query.rb | 8 +- lib/graphql/query/partial.rb | 49 +++++++----- spec/graphql/query/partial_spec.rb | 118 +++++++++++++++++++++++++---- 3 files changed, 134 insertions(+), 41 deletions(-) diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 860c0cdf4d..aba9dc1e38 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -250,14 +250,14 @@ def operations end # Run subtree partials of this query and return their results. - # Each partial is identified with a `path => object` pair + # Each partial is identified with a `path:` and `object:` # where the path references a field in the AST and the object will be treated # as the return value from that field. Subfields of the field named by `path` # will be executed with `object` as the starting point - # @param partials_hash [Hash => Object>] `path => object` pairs + # @param partials_hashes [Array Object}>] Hashes with `path:` and `object:` keys # @return [Array] - def run_partials(partials_hash) - partials = partials_hash.map { |path, obj| Partial.new(path: path, object: obj, query: self) } + def run_partials(partials_hashes) + partials = partials_hashes.map { |partial_options| Partial.new(query: self, **partial_options) } Execution::Interpreter.run_all(@schema, partials, context: @context) end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 3e8e3b33b3..03323bb086 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -12,9 +12,21 @@ def initialize(path:, object:, query:) @multiplex = nil @result_values = nil @result = nil + selection = @query.selected_operation + type = @query.schema.query # TODO could be other? + @path.each do |name_in_doc| + selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } + if !selection + raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + end + field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") + type = field_defn.type.unwrap + end + @selected_operation = selection + @root_type = type end - attr_reader :context + attr_reader :context, :selected_operation, :root_type attr_accessor :multiplex, :result_values @@ -41,14 +53,6 @@ def subscription? false end - def selected_operation - selection = @query.selected_operation - @path.each do |name_in_doc| - selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } - end - selection - end - def schema @query.schema end @@ -61,18 +65,6 @@ def root_value @object end - def root_type - # Eventually do the traversal upstream of here, processing the group of partials together. - selection = @query.selected_operation - type = @query.schema.query # TODO could be other? - @path.each do |name_in_doc| - selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } - field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") - type = field_defn.type.unwrap - end - type - end - # TODO dry with query def after_lazy(value, &block) if !defined?(@runtime_instance) @@ -85,6 +77,21 @@ def after_lazy(value, &block) @schema.after_lazy(value, &block) end end + + # TODO dry with query + def arguments_for(ast_node, definition, parent_object: nil) + arguments_cache.fetch(ast_node, definition, parent_object) + end + + # TODO dry with query + def arguments_cache + @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) + end + + # TODO dry + def handle_or_reraise(err) + @query.schema.handle_or_reraise(context, err) + end end end end diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index e026048faf..8fc73cb834 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -3,11 +3,37 @@ describe GraphQL::Query::Partial do class PartialSchema < GraphQL::Schema - FARMS = { - "1" => OpenStruct.new(name: "Bellair Farm", products: ["VEGETABLES", "MEAT", "EGGS"]), - "2" => OpenStruct.new(name: "Henley's Orchard", products: ["FRUIT", "MEAT", "EGGS"]), - "3" => OpenStruct.new(name: "Wenger Grapes", products: ["FRUIT"]), - } + module Database + FARMS = { + "1" => OpenStruct.new(name: "Bellair Farm", products: ["VEGETABLES", "MEAT", "EGGS"], neighboring_farm_id: "2"), + "2" => OpenStruct.new(name: "Henley's Orchard", products: ["FRUIT", "MEAT", "EGGS"], neighboring_farm_id: "3"), + "3" => OpenStruct.new(name: "Wenger Grapes", products: ["FRUIT"], neighboring_farm_id: "1"), + } + + class << self + def get(id) + @log << [:get, id] + FARMS[id] + end + + def mget(ids) + @log << [:mget, ids] + ids.map { |id| FARMS[id] } + end + + attr_reader :log + + def clear + @log = [] + end + end + end + + class FarmSource < GraphQL::Dataloader::Source + def fetch(farm_ids) + Database.mget(farm_ids) + end + end class FarmProduct < GraphQL::Schema::Enum value :FRUIT @@ -20,10 +46,21 @@ class FarmProduct < GraphQL::Schema::Enum class Farm < GraphQL::Schema::Object field :name, String field :products, [FarmProduct] + field :error, Int + + def error + raise GraphQL::ExecutionError, "This is a field error" + end + + field :neighboring_farm, Farm + + def neighboring_farm + dataloader.with(FarmSource).load(object.neighboring_farm_id) + end end class Query < GraphQL::Schema::Object - field :farms, [Farm], fallback_value: FARMS.values + field :farms, [Farm], fallback_value: Database::FARMS.values field :farm, Farm do argument :id, ID, loads: Farm, as: :farm @@ -32,31 +69,38 @@ class Query < GraphQL::Schema::Object def farm(farm:) farm end + + field :query, Query, fallback_value: true end query(Query) def self.object_from_id(id, ctx) - FARMS[id] + ctx.dataloader.with(FarmSource).load(id) end def self.resolve_type(abs_type, object, ctx) Farm end + + use GraphQL::Dataloader + end + + before do + PartialSchema::Database.clear end - focus - it "returns results for the named part" do + it "returns results for the named parts" do str = "{ farms { name, products } farm1: farm(id: \"1\") { name } farm2: farm(id: \"2\") { name } }" query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials( - ["farm1"] => PartialSchema::FARMS["1"], - ["farm2"] => OpenStruct.new(name: "Injected Farm"), - ) + results = query.run_partials([ + { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, + { path: ["farm2"], object: OpenStruct.new(name: "Injected Farm") } + ]) assert_equal [ { "data" => { "name" => "Bellair Farm" } }, @@ -64,8 +108,50 @@ def self.resolve_type(abs_type, object, ctx) ], results end - it "returns errors if they occur" - it "raises errors when given bad paths" - it "runs multiple partials concurrently" + it "returns errors if they occur" do + str = "{ farm1: farm(id: \"1\") { error } }" + query = GraphQL::Query.new(PartialSchema, str) + results = query.run_partials([{ path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }]) + assert_nil results.first.fetch("data").fetch("error") + assert_equal [["This is a field error"]], results.map { |r| r["errors"].map { |err| err["message"] } } + assert_equal [[["error"]]], results.map { |r| r["errors"].map { |err| err["path"] } } + end + + it "raises errors when given nonexistent paths" do + str = "{ farm1: farm(id: \"1\") { error neighboringFarm { name } } }" + query = GraphQL::Query.new(PartialSchema, str) + err = assert_raises ArgumentError do + query.run_partials([{ path: ["farm500"], object: PartialSchema::Database::FARMS["1"] }]) + end + assert_equal "Path `[\"farm500\"]` is not present in this query. `\"farm500\"` was not found. Try a different path or rewrite the query to include it.", err.message + + err = assert_raises ArgumentError do + query.run_partials([{ path: ["farm1", "neighboringFarm", "blah"], object: PartialSchema::Database::FARMS["1"] }]) + end + assert_equal "Path `[\"farm1\", \"neighboringFarm\", \"blah\"]` is not present in this query. `\"blah\"` was not found. Try a different path or rewrite the query to include it.", err.message + end + + it "raises an error when given a duplicate path" + it "raises an error when given overlapping paths" + + it "runs multiple partials concurrently" do + str = <<~GRAPHQL + query { + query1: query { farm(id: "1") { name neighboringFarm { name } } } + query2: query { farm(id: "2") { name neighboringFarm { name } } } + } + GRAPHQL + + query = GraphQL::Query.new(PartialSchema, str) + results = query.run_partials([{ path: ["query1"], object: true }, { path: ["query2"], object: true }]) + + assert_equal "Henley's Orchard", results.first["data"]["farm"]["neighboringFarm"]["name"] + assert_equal "Wenger Grapes", results.last["data"]["farm"]["neighboringFarm"]["name"] + + assert_equal [[:mget, ["1", "2"]], [:mget, ["3"]]], PartialSchema::Database.log + end + it "returns multiple errors concurrently" + + it "returns useful metadata in the result" end From 20fd74453403069e1d1111947fbc1f522feec149 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 6 Dec 2024 17:41:57 -0500 Subject: [PATCH 03/27] Add test for running on Arrays --- spec/graphql/query/partial_spec.rb | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 8fc73cb834..e19bb9a39e 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -131,8 +131,21 @@ def self.resolve_type(abs_type, object, ctx) assert_equal "Path `[\"farm1\", \"neighboringFarm\", \"blah\"]` is not present in this query. `\"blah\"` was not found. Try a different path or rewrite the query to include it.", err.message end - it "raises an error when given a duplicate path" - it "raises an error when given overlapping paths" + it "can run partials with the same path" do + str = "{ + farm(id: \"1\") { name } + }" + query = GraphQL::Query.new(PartialSchema, str) + results = query.run_partials([ + { path: ["farm"], object: PartialSchema::Database::FARMS["1"] }, + { path: ["farm"], object: OpenStruct.new(name: "Injected Farm") } + ]) + + assert_equal [ + { "data" => { "name" => "Bellair Farm" } }, + { "data" => { "name" => "Injected Farm" } }, + ], results + end it "runs multiple partials concurrently" do str = <<~GRAPHQL @@ -153,5 +166,14 @@ def self.resolve_type(abs_type, object, ctx) it "returns multiple errors concurrently" - it "returns useful metadata in the result" + it "runs arrays and returns useful metadata in the result" do + str = "{ farms { name } }" + query = GraphQL::Query.new(PartialSchema, str) + results = query.run_partials([{ path: ["farms"], object: [{ name: "Twenty Paces" }, { name: "Spring Creek Blooms" }]}]) + result = results.first + assert_equal [{ "name" => "Twenty Paces" }, { "name" => "Spring Creek Blooms" }], result["data"] + assert_equal ["farms"], result.path + assert_instance_of GraphQL::Query::Context, result.context + refute_equal query.context, result.context + end end From 38e19d7235c9af142c035216a112b3e610f578ba Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 11:29:36 -0500 Subject: [PATCH 04/27] Add dedicated run_partials method --- lib/graphql/execution/interpreter.rb | 60 ++++++++++++++++++++++++++-- lib/graphql/query.rb | 2 +- lib/graphql/query/partial.rb | 15 ------- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index afe878e9e4..bd716f3fbc 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -84,13 +84,11 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl # Then, work through lazy results in a breadth-first way multiplex.dataloader.append_job { query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil - queries = multiplex ? multiplex.queries : [query] - final_values = queries.map do |query| + queries.each do |query| runtime = query.context.namespace(:interpreter_runtime)[:runtime] # it might not be present if the query has an error runtime ? runtime.final_result : nil end - final_values.compact! multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: query) do Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) end @@ -147,6 +145,62 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl end end end + + def run_partials(schema, partials, context:) + multiplex = Execution::Multiplex.new(schema: schema, queries: partials, context: context, max_complexity: nil) + dataloader = multiplex.dataloader + lazies_at_depth = Hash.new { |h, k| h[k] = [] } + + partials.each do |partial| + dataloader.append_job { + runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth) + partial.context.namespace(:interpreter_runtime)[:runtime] = runtime + # TODO tracing? + runtime.run_eager + } + end + + dataloader.run + + dataloader.append_job { + partials.each do |partial| + runtime = partial.context.namespace(:interpreter_runtime)[:runtime] + runtime.final_result + end + # TODO tracing? + Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) + } + + dataloader.run + + partials.map do |partial| + # Assign the result so that it can be accessed in instrumentation + data_result = partial.context.namespace(:interpreter_runtime)[:runtime].final_result + partial.result_values = if data_result.equal?(NO_OPERATION) + if !partial.context.errors.empty? + { "errors" => partial.context.errors.map(&:to_h) } + else + data_result + end + else + result = {} + + if !partial.context.errors.empty? + error_result = partial.context.errors.map(&:to_h) + result["errors"] = error_result + end + + result["data"] = data_result + + result + end + if partial.context.namespace?(:__query_result_extensions__) + partial.result_values["extensions"] = partial.context.namespace(:__query_result_extensions__) + end + # Partial::Result + partial.result + end + end end class ListResultFailedError < GraphQL::Error diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index aba9dc1e38..8c475ee29c 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -258,7 +258,7 @@ def operations # @return [Array] def run_partials(partials_hashes) partials = partials_hashes.map { |partial_options| Partial.new(query: self, **partial_options) } - Execution::Interpreter.run_all(@schema, partials, context: @context) + Execution::Interpreter.run_partials(@schema, partials, context: @context) end # Get the result for this query, executing it once diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 03323bb086..b1ff03998f 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -34,25 +34,10 @@ def result @result ||= GraphQL::Query::Result.new(query: self, values: result_values) end - def valid? - true - end - - def analyzers - EmptyObjects::EMPTY_ARRAY - end - def current_trace @query.current_trace end - def analysis_errors=(_errs) - end - - def subscription? - false - end - def schema @query.schema end From 40902914e07272d03a416cb078a92e22546e6102 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 12:46:04 -0500 Subject: [PATCH 05/27] Start on run_partial_eager --- lib/graphql/execution/interpreter.rb | 2 +- lib/graphql/execution/interpreter/runtime.rb | 62 +++++++++++++++++--- lib/graphql/query/partial.rb | 6 +- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index bd716f3fbc..56483de240 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -156,7 +156,7 @@ def run_partials(schema, partials, context:) runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth) partial.context.namespace(:interpreter_runtime)[:runtime] = runtime # TODO tracing? - runtime.run_eager + runtime.run_partial_eager } end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index c8621ed982..01c52f64cd 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -69,13 +69,8 @@ def inspect # @return [void] def run_eager root_operation = query.selected_operation - # TODO duck-type #root_type - root_type = if query.is_a?(GraphQL::Query::Partial) - query.root_type - else - root_op_type = root_operation.operation_type || "query" - schema.root_type_for_operation(root_op_type) - end + root_op_type = root_operation.operation_type || "query" + schema.root_type_for_operation(root_op_type) runtime_object = root_type.wrap(query.root_value, context) runtime_object = schema.sync_lazy(runtime_object) is_eager = root_op_type == "mutation" @@ -111,6 +106,59 @@ def run_eager nil end + # @return [void] + def run_partial_eager + # `query` is actually a GraphQL::Query::Partial + continue_field( + query.object, # value + nil, # owner_type + query.selected_operation, # field + query.root_type, # current_type + ast_node, + next_selections, + is_non_null, + owner_object, + arguments, + result_name, + selection_result, + false, # was_scoped + get_current_runtime_state, # runtime_state + ) + # runtime_object = root_type.wrap(query.root_value, context) + # runtime_object = schema.sync_lazy(runtime_object) + # is_eager = root_op_type == "mutation" + # @response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, root_operation.selections, is_eager) + # st = get_current_runtime_state + # st.current_result = @response + + # if runtime_object.nil? + # # Root .authorized? returned false. + # @response = nil + # else + # call_method_on_directives(:resolve, runtime_object, root_operation.directives) do # execute query level directives + # each_gathered_selections(@response) do |selections, is_selection_array| + # if is_selection_array + # selection_response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, selections, is_eager) + # final_response = @response + # else + # selection_response = @response + # final_response = nil + # end + + # @dataloader.append_job { + # evaluate_selections( + # selections, + # selection_response, + # final_response, + # nil, + # ) + # } + # end + # end + # end + nil + end + def each_gathered_selections(response_hash) gathered_selections = gather_selections(response_hash.graphql_application_value, response_hash.graphql_result_type, response_hash.graphql_selections) if gathered_selections.is_a?(Array) diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index b1ff03998f..1593d78503 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -26,7 +26,7 @@ def initialize(path:, object:, query:) @root_type = type end - attr_reader :context, :selected_operation, :root_type + attr_reader :context, :selected_operation, :root_type, :object attr_accessor :multiplex, :result_values @@ -46,10 +46,6 @@ def types @query.types end - def root_value - @object - end - # TODO dry with query def after_lazy(value, &block) if !defined?(@runtime_instance) From 93952e708ab1bb8bc237ef27514437edac05e906 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 14:22:12 -0500 Subject: [PATCH 06/27] Build out partial execution with list types --- lib/graphql/execution/interpreter.rb | 1 + lib/graphql/execution/interpreter/runtime.rb | 72 +++++++------------- lib/graphql/query/partial.rb | 35 +++++++--- spec/graphql/query/partial_spec.rb | 34 +++++---- 4 files changed, 74 insertions(+), 68 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 56483de240..433c8db12b 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -176,6 +176,7 @@ def run_partials(schema, partials, context:) partials.map do |partial| # Assign the result so that it can be accessed in instrumentation data_result = partial.context.namespace(:interpreter_runtime)[:runtime].final_result + data_result = data_result[partial.path.last] partial.result_values = if data_result.equal?(NO_OPERATION) if !partial.context.errors.empty? { "errors" => partial.context.errors.map(&:to_h) } diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 01c52f64cd..7d2d104eaf 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -109,53 +109,31 @@ def run_eager # @return [void] def run_partial_eager # `query` is actually a GraphQL::Query::Partial - continue_field( - query.object, # value - nil, # owner_type - query.selected_operation, # field - query.root_type, # current_type - ast_node, - next_selections, - is_non_null, - owner_object, - arguments, - result_name, - selection_result, - false, # was_scoped - get_current_runtime_state, # runtime_state - ) - # runtime_object = root_type.wrap(query.root_value, context) - # runtime_object = schema.sync_lazy(runtime_object) - # is_eager = root_op_type == "mutation" - # @response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, root_operation.selections, is_eager) - # st = get_current_runtime_state - # st.current_result = @response - - # if runtime_object.nil? - # # Root .authorized? returned false. - # @response = nil - # else - # call_method_on_directives(:resolve, runtime_object, root_operation.directives) do # execute query level directives - # each_gathered_selections(@response) do |selections, is_selection_array| - # if is_selection_array - # selection_response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, selections, is_eager) - # final_response = @response - # else - # selection_response = @response - # final_response = nil - # end - - # @dataloader.append_job { - # evaluate_selections( - # selections, - # selection_response, - # final_response, - # nil, - # ) - # } - # end - # end - # end + partial = query + root_type = partial.root_type + object = partial.object + ast_node = partial.ast_nodes.first + selections = partial.ast_nodes.map(&:selections).inject(&:+) + next_selections = selections.map(&:selections).inject(&:+) + field = partial.field_definition + @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false) + @dataloader.append_job { + continue_field( + object, # value + nil, # owner_type + field, # field + root_type, # current_type + ast_node, # ast_node + selections, # next_selections + false, # is_non_null, + nil, # owner_object, + nil, # arguments, + ast_node.alias || ast_node.name, # result_name, + @response, # selection_result + false, # was_scoped + get_current_runtime_state, # runtime_state + ) + } nil end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 1593d78503..1455f3bf38 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -12,26 +12,43 @@ def initialize(path:, object:, query:) @multiplex = nil @result_values = nil @result = nil - selection = @query.selected_operation + selections = [@query.selected_operation] type = @query.schema.query # TODO could be other? + field_defn = nil @path.each do |name_in_doc| - selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } - if !selection - raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + next_selections = [] + selections.each do |selection| + selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } + if !selection + raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + end + # TODO Don't repeat this for each of `selections` + field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") + type = field_defn.type + if type.non_null? + type = type.of_type + end + next_selections << selection end - field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") - type = field_defn.type.unwrap + selections = next_selections end - @selected_operation = selection + @ast_nodes = selections @root_type = type + @field_definition = field_defn end - attr_reader :context, :selected_operation, :root_type, :object + attr_reader :context, :ast_nodes, :root_type, :object, :field_definition, :path attr_accessor :multiplex, :result_values + class Result < GraphQL::Query::Result + def path + @query.path + end + end + def result - @result ||= GraphQL::Query::Result.new(query: self, values: result_values) + @result ||= Result.new(query: self, values: result_values) end def current_trace diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index e19bb9a39e..f34b1ac864 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -90,14 +90,18 @@ def self.resolve_type(abs_type, object, ctx) PartialSchema::Database.clear end + def run_partials(string, partial_configs) + query = GraphQL::Query.new(PartialSchema, string) + query.run_partials(partial_configs) + end + it "returns results for the named parts" do str = "{ farms { name, products } farm1: farm(id: \"1\") { name } farm2: farm(id: \"2\") { name } }" - query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials([ + results = run_partials(str, [ { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, { path: ["farm2"], object: OpenStruct.new(name: "Injected Farm") } ]) @@ -110,8 +114,7 @@ def self.resolve_type(abs_type, object, ctx) it "returns errors if they occur" do str = "{ farm1: farm(id: \"1\") { error } }" - query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials([{ path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }]) + results = run_partials(str, [{ path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }]) assert_nil results.first.fetch("data").fetch("error") assert_equal [["This is a field error"]], results.map { |r| r["errors"].map { |err| err["message"] } } assert_equal [[["error"]]], results.map { |r| r["errors"].map { |err| err["path"] } } @@ -135,8 +138,7 @@ def self.resolve_type(abs_type, object, ctx) str = "{ farm(id: \"1\") { name } }" - query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials([ + results = run_partials(str, [ { path: ["farm"], object: PartialSchema::Database::FARMS["1"] }, { path: ["farm"], object: OpenStruct.new(name: "Injected Farm") } ]) @@ -155,9 +157,7 @@ def self.resolve_type(abs_type, object, ctx) } GRAPHQL - query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials([{ path: ["query1"], object: true }, { path: ["query2"], object: true }]) - + results = run_partials(str, [{ path: ["query1"], object: true }, { path: ["query2"], object: true }]) assert_equal "Henley's Orchard", results.first["data"]["farm"]["neighboringFarm"]["name"] assert_equal "Wenger Grapes", results.last["data"]["farm"]["neighboringFarm"]["name"] @@ -168,12 +168,22 @@ def self.resolve_type(abs_type, object, ctx) it "runs arrays and returns useful metadata in the result" do str = "{ farms { name } }" - query = GraphQL::Query.new(PartialSchema, str) - results = query.run_partials([{ path: ["farms"], object: [{ name: "Twenty Paces" }, { name: "Spring Creek Blooms" }]}]) + results = run_partials(str, [{ path: ["farms"], object: [{ name: "Twenty Paces" }, { name: "Spring Creek Blooms" }]}]) result = results.first assert_equal [{ "name" => "Twenty Paces" }, { "name" => "Spring Creek Blooms" }], result["data"] assert_equal ["farms"], result.path assert_instance_of GraphQL::Query::Context, result.context - refute_equal query.context, result.context + assert_instance_of GraphQL::Query::Partial, result.context.query + end + + it "merges selections when path steps are duplicated" do + str = <<-GRAPHQL + { + f1: farm { neighboringFarm { name } } + f1: farm { neighboringFarm { name2: name } } + } + GRAPHQL + results = run_partials(str, [{ path: ["f1", "neighboringFarm"], object: OpenStruct.new(name: "Dawnbreak") }]) + assert_equal({"name" => "Dawnbreak", "name2" => "Dawnbreak" }, results.first["data"]) end end From 87c244ea23a28d605b7466ea1d2a3d701c4aafd5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 14:35:11 -0500 Subject: [PATCH 07/27] Fix typos --- lib/graphql/execution/interpreter/runtime.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 7d2d104eaf..3d2cfed22e 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -70,7 +70,7 @@ def inspect def run_eager root_operation = query.selected_operation root_op_type = root_operation.operation_type || "query" - schema.root_type_for_operation(root_op_type) + root_type = schema.root_type_for_operation(root_op_type) runtime_object = root_type.wrap(query.root_value, context) runtime_object = schema.sync_lazy(runtime_object) is_eager = root_op_type == "mutation" @@ -114,7 +114,6 @@ def run_partial_eager object = partial.object ast_node = partial.ast_nodes.first selections = partial.ast_nodes.map(&:selections).inject(&:+) - next_selections = selections.map(&:selections).inject(&:+) field = partial.field_definition @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false) @dataloader.append_job { From babf68f18b5ff135602df82e426f003a88ecacde Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 14:54:11 -0500 Subject: [PATCH 08/27] Implement partial execution for list types --- lib/graphql/execution/interpreter.rb | 1 - lib/graphql/execution/interpreter/runtime.rb | 60 +++++++++++++------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 433c8db12b..56483de240 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -176,7 +176,6 @@ def run_partials(schema, partials, context:) partials.map do |partial| # Assign the result so that it can be accessed in instrumentation data_result = partial.context.namespace(:interpreter_runtime)[:runtime].final_result - data_result = data_result[partial.path.last] partial.result_values = if data_result.equal?(NO_OPERATION) if !partial.context.errors.empty? { "errors" => partial.context.errors.map(&:to_h) } diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 3d2cfed22e..3cc1b3d945 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -112,27 +112,47 @@ def run_partial_eager partial = query root_type = partial.root_type object = partial.object - ast_node = partial.ast_nodes.first selections = partial.ast_nodes.map(&:selections).inject(&:+) - field = partial.field_definition - @response = GraphQLResultHash.new(nil, root_type, object, nil, false, selections, false) - @dataloader.append_job { - continue_field( - object, # value - nil, # owner_type - field, # field - root_type, # current_type - ast_node, # ast_node - selections, # next_selections - false, # is_non_null, - nil, # owner_object, - nil, # arguments, - ast_node.alias || ast_node.name, # result_name, - @response, # selection_result - false, # was_scoped - get_current_runtime_state, # runtime_state - ) - } + runtime_state = get_current_runtime_state + case root_type.kind.name + when "OBJECT" + object_proxy = root_type.wrap(object, context) + object_proxy = schema.sync_lazy(object_proxy) + @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false) + each_gathered_selections(@response) do |selections, is_selection_array| + if is_selection_array == true + raise "This isn't supported yet" + end + + @dataloader.append_job { + evaluate_selections( + selections, + @response, + nil, + runtime_state, + ) + } + end + when "LIST" + inner_type = root_type.unwrap + @response = GraphQLResultArray.new(nil, root_type, object_proxy, nil, false, selections, false) + idx = nil + object.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + @dataloader.append_job do + runtime_state.current_result_name = this_idx + runtime_state.current_result = @response + continue_field( + inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, + nil, this_idx, @response, false, runtime_state + ) + end + end + else + raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" + end nil end From 9cc47f5a988486388c5b2d0a4f8d70d8b74d9042 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 9 Dec 2024 15:08:34 -0500 Subject: [PATCH 09/27] Support AST branches that match the same path --- lib/graphql/query/partial.rb | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 1455f3bf38..6614ed7aa6 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -18,17 +18,21 @@ def initialize(path:, object:, query:) @path.each do |name_in_doc| next_selections = [] selections.each do |selection| - selection = selection.selections.find { |sel| sel.alias == name_in_doc || sel.name == name_in_doc } - if !selection - raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + selection.selections.each do |sel| + if sel.alias == name_in_doc || sel.name == name_in_doc + next_selections << sel + end end - # TODO Don't repeat this for each of `selections` - field_defn = type.get_field(selection.name, @query.context) || raise("Invariant: no field called #{selection.name.inspect} on #{type.graphql_name}") - type = field_defn.type - if type.non_null? - type = type.of_type - end - next_selections << selection + end + + if next_selections.empty? + raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + end + field_name = next_selections.first.name + field_defn = type.get_field(field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}") + type = field_defn.type + if type.non_null? + type = type.of_type end selections = next_selections end From 385ee3bd74cae37c0d21612455b397cefd365d63 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 10 Dec 2024 09:56:34 -0500 Subject: [PATCH 10/27] Add multiple errors test --- spec/graphql/query/partial_spec.rb | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index f34b1ac864..e620b7be8c 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -113,11 +113,24 @@ def run_partials(string, partial_configs) end it "returns errors if they occur" do - str = "{ farm1: farm(id: \"1\") { error } }" - results = run_partials(str, [{ path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }]) - assert_nil results.first.fetch("data").fetch("error") - assert_equal [["This is a field error"]], results.map { |r| r["errors"].map { |err| err["message"] } } - assert_equal [[["error"]]], results.map { |r| r["errors"].map { |err| err["path"] } } + str = "{ + farm1: farm(id: \"1\") { error } + farm2: farm(id: \"1\") { name } + farm3: farm(id: \"1\") { name fieldError: error } + }" + results = run_partials(str, [ + { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, + { path: ["farm2"], object: PartialSchema::Database::FARMS["2"] }, + { path: ["farm3"], object: PartialSchema::Database::FARMS["3"] }, + ]) + + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>2, "column"=>30}], "path"=>["error"]}], results[0]["errors"] + refute results[1].key?("errors") + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>4, "column"=>35}], "path"=>["fieldError"]}], results[2]["errors"] + + assert_equal({ "error" => nil }, results[0]["data"]) + assert_equal({ "name" => "Henley's Orchard" }, results[1]["data"]) + assert_equal({ "name" => "Wenger Grapes", "fieldError" => nil }, results[2]["data"]) end it "raises errors when given nonexistent paths" do @@ -164,8 +177,6 @@ def run_partials(string, partial_configs) assert_equal [[:mget, ["1", "2"]], [:mget, ["3"]]], PartialSchema::Database.log end - it "returns multiple errors concurrently" - it "runs arrays and returns useful metadata in the result" do str = "{ farms { name } }" results = run_partials(str, [{ path: ["farms"], object: [{ name: "Twenty Paces" }, { name: "Spring Creek Blooms" }]}]) From dcd95412010208c91b9b4695b24eece2b36227c1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 16 Dec 2024 09:13:58 -0500 Subject: [PATCH 11/27] Support isolated execution of scalar fields --- lib/graphql/execution/interpreter/runtime.rb | 45 ++++++++++++++------ lib/graphql/query/partial.rb | 23 +++++++++- spec/graphql/query/partial_spec.rb | 23 ++++++++++ 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 3cc1b3d945..a3eac6e661 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -135,22 +135,43 @@ def run_partial_eager end when "LIST" inner_type = root_type.unwrap - @response = GraphQLResultArray.new(nil, root_type, object_proxy, nil, false, selections, false) - idx = nil - object.each do |inner_value| - idx ||= 0 - this_idx = idx - idx += 1 - @dataloader.append_job do - runtime_state.current_result_name = this_idx - runtime_state.current_result = @response - continue_field( - inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, - nil, this_idx, @response, false, runtime_state + case inner_type.kind.name + when "SCALAR", "ENUM" + parent_object_proxy = partial.parent_type.wrap(object, context) + parent_object_proxy = schema.sync_lazy(parent_object_proxy) + field_node = partial.ast_nodes.first + result_name = field_node.alias || field_node.name + @response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false) + evaluate_selection(result_name, partial.ast_nodes, @response) + else + @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false) + idx = nil + object.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + @dataloader.append_job do + runtime_state.current_result_name = this_idx + runtime_state.current_result = @response + continue_field( + inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, + nil, this_idx, @response, false, runtime_state ) + end end end + when "SCALAR", "ENUM" + parent_type = partial.parent_type + parent_object_proxy = parent_type.wrap(object, context) + parent_object_proxy = schema.sync_lazy(parent_object_proxy) + @response = GraphQLResultHash.new(nil, parent_type, parent_object_proxy, nil, false, selections, false) + field_node = partial.ast_nodes.first + result_name = field_node.alias || field_node.name + @dataloader.append_job do + evaluate_selection(result_name, partial.ast_nodes, @response) + end else + # TODO union, interface raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end nil diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 6614ed7aa6..0d36856af5 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -14,6 +14,7 @@ def initialize(path:, object:, query:) @result = nil selections = [@query.selected_operation] type = @query.schema.query # TODO could be other? + parent_type = nil field_defn = nil @path.each do |name_in_doc| next_selections = [] @@ -30,18 +31,25 @@ def initialize(path:, object:, query:) end field_name = next_selections.first.name field_defn = type.get_field(field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}") + parent_type = type type = field_defn.type if type.non_null? type = type.of_type end selections = next_selections end + @parent_type = parent_type @ast_nodes = selections @root_type = type @field_definition = field_defn + @leaf = @root_type.unwrap.kind.leaf? end - attr_reader :context, :ast_nodes, :root_type, :object, :field_definition, :path + def leaf? + @leaf + end + + attr_reader :context, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type attr_accessor :multiplex, :result_values @@ -49,6 +57,11 @@ class Result < GraphQL::Query::Result def path @query.path end + + # @return [GraphQL::Query::Partial] + def partial + @query + end end def result @@ -94,6 +107,14 @@ def arguments_cache def handle_or_reraise(err) @query.schema.handle_or_reraise(context, err) end + + def resolve_type(...) + @query.resolve_type(...) + end + + def variables + @query.variables + end end end end diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index e620b7be8c..0caccd3b84 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -117,20 +117,29 @@ def run_partials(string, partial_configs) farm1: farm(id: \"1\") { error } farm2: farm(id: \"1\") { name } farm3: farm(id: \"1\") { name fieldError: error } + farm4: farm(id: \"1\") { + neighboringFarm { + error + } + } }" results = run_partials(str, [ { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, { path: ["farm2"], object: PartialSchema::Database::FARMS["2"] }, { path: ["farm3"], object: PartialSchema::Database::FARMS["3"] }, + { path: ["farm4"], object: PartialSchema::Database::FARMS["3"] }, ]) + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>2, "column"=>30}], "path"=>["error"]}], results[0]["errors"] refute results[1].key?("errors") assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>4, "column"=>35}], "path"=>["fieldError"]}], results[2]["errors"] + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>7, "column"=>11}], "path"=>["neighboringFarm", "error"]}], results[3]["errors"] assert_equal({ "error" => nil }, results[0]["data"]) assert_equal({ "name" => "Henley's Orchard" }, results[1]["data"]) assert_equal({ "name" => "Wenger Grapes", "fieldError" => nil }, results[2]["data"]) + assert_equal({ "neighboringFarm" => { "error" => nil } }, results[3]["data"]) end it "raises errors when given nonexistent paths" do @@ -184,7 +193,9 @@ def run_partials(string, partial_configs) assert_equal [{ "name" => "Twenty Paces" }, { "name" => "Spring Creek Blooms" }], result["data"] assert_equal ["farms"], result.path assert_instance_of GraphQL::Query::Context, result.context + assert_instance_of GraphQL::Query::Partial, result.partial assert_instance_of GraphQL::Query::Partial, result.context.query + refute result.partial.leaf? end it "merges selections when path steps are duplicated" do @@ -197,4 +208,16 @@ def run_partials(string, partial_configs) results = run_partials(str, [{ path: ["f1", "neighboringFarm"], object: OpenStruct.new(name: "Dawnbreak") }]) assert_equal({"name" => "Dawnbreak", "name2" => "Dawnbreak" }, results.first["data"]) end + + it "runs partials on scalars" do + str = "{ farm { name products } }" + results = run_partials(str, [ + { path: ["farm", "name"], object: { name: "Polyface" } }, + { path: ["farm", "products"], object: { products: ["MEAT"] } }, + ]) + assert_equal [{"name" => "Polyface"}, { "products" => ["MEAT"] }], results.map { |r| r["data"] } + + assert results[0].partial.leaf? + assert results[1].partial.leaf? + end end From 2f9b1dbdff45bbabaa2c8fe30ed2fad64b2aa966 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 16 Dec 2024 11:19:54 -0500 Subject: [PATCH 12/27] Add some resolve_type --- lib/graphql/execution/interpreter/runtime.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index a3eac6e661..3336104d2e 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -162,9 +162,11 @@ def run_partial_eager end when "SCALAR", "ENUM" parent_type = partial.parent_type - parent_object_proxy = parent_type.wrap(object, context) + # TODO what if not object type? Maybe returns a lazy here. + parent_object_type, object = resolve_type(parent_type, object) + parent_object_proxy = parent_object_type.wrap(object, context) parent_object_proxy = schema.sync_lazy(parent_object_proxy) - @response = GraphQLResultHash.new(nil, parent_type, parent_object_proxy, nil, false, selections, false) + @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false) field_node = partial.ast_nodes.first result_name = field_node.alias || field_node.name @dataloader.append_job do From 6f81134c010438f5736d614a1d505f8c7a070339 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 9 Jan 2025 15:40:44 -0500 Subject: [PATCH 13/27] Support inline fragments and fragment spreads --- lib/graphql/query/partial.rb | 24 ++++++++++++++++++++---- spec/graphql/query/partial_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 0d36856af5..59f522d956 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -19,9 +19,21 @@ def initialize(path:, object:, query:) @path.each do |name_in_doc| next_selections = [] selections.each do |selection| - selection.selections.each do |sel| - if sel.alias == name_in_doc || sel.name == name_in_doc - next_selections << sel + selections_to_check = [] + selections_to_check.concat(selection.selections) + while (sel = selections_to_check.shift) + case sel + when GraphQL::Language::Nodes::InlineFragment + selections_to_check.concat(sel.selections) + when GraphQL::Language::Nodes::FragmentSpread + fragment = @query.fragments[sel.name] + selections_to_check.concat(fragment.selections) + when GraphQL::Language::Nodes::Field + if sel.alias == name_in_doc || sel.name == name_in_doc + next_selections << sel + end + else + raise "Unexpected selection in partial path: #{sel.class}, #{sel.inspect}" end end end @@ -49,7 +61,7 @@ def leaf? @leaf end - attr_reader :context, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type + attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type attr_accessor :multiplex, :result_values @@ -115,6 +127,10 @@ def resolve_type(...) def variables @query.variables end + + def fragments + @query.fragments + end end end end diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 0caccd3b84..f59fb5b773 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -209,6 +209,33 @@ def run_partials(string, partial_configs) assert_equal({"name" => "Dawnbreak", "name2" => "Dawnbreak" }, results.first["data"]) end + it "works when there are inline fragments in the path" do + str = <<-GRAPHQL + { + farm { + ... on Farm { + neighboringFarm { + name + } + } + neighboringFarm { + __typename + } + ...FarmFields + } + } + + fragment FarmFields on Farm { + neighboringFarm { + n2: name + } + } + GRAPHQL + + results = run_partials(str, [{ path: ["farm", "neighboringFarm"], object: OpenStruct.new(name: "Dawnbreak") }]) + assert_equal({"name" => "Dawnbreak", "__typename" => "Farm", "n2" => "Dawnbreak"}, results.first["data"]) + end + it "runs partials on scalars" do str = "{ farm { name products } }" results = run_partials(str, [ From 34870eac52ee58b5cc6f91eb5cd65f50a9b171e5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 2 Apr 2025 06:27:32 -0400 Subject: [PATCH 14/27] Update tests --- lib/graphql/execution/interpreter/runtime.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 0f3363f96a..e6904281ea 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -118,7 +118,7 @@ def run_partial_eager when "OBJECT" object_proxy = root_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) - @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false) + @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil) each_gathered_selections(@response) do |selections, is_selection_array| if is_selection_array == true raise "This isn't supported yet" @@ -141,10 +141,10 @@ def run_partial_eager parent_object_proxy = schema.sync_lazy(parent_object_proxy) field_node = partial.ast_nodes.first result_name = field_node.alias || field_node.name - @response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false) + @response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil) evaluate_selection(result_name, partial.ast_nodes, @response) else - @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false) + @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, field_node, nil, nil) idx = nil object.each do |inner_value| idx ||= 0 @@ -166,9 +166,9 @@ def run_partial_eager parent_object_type, object = resolve_type(parent_type, object) parent_object_proxy = parent_object_type.wrap(object, context) parent_object_proxy = schema.sync_lazy(parent_object_proxy) - @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false) field_node = partial.ast_nodes.first result_name = field_node.alias || field_node.name + @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false, field_node, nil, nil) @dataloader.append_job do evaluate_selection(result_name, partial.ast_nodes, @response) end From 2d38a4db2d97ded3178c5f20fe4f46bbbf103175 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 21 Apr 2025 11:31:25 -0400 Subject: [PATCH 15/27] Update for ordered result keys --- lib/graphql/execution/interpreter/runtime.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 3e6914b1f6..86d93b3027 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -121,7 +121,8 @@ def run_partial_eager object_proxy = root_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil) - each_gathered_selections(@response) do |selections, is_selection_array| + each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys if is_selection_array == true raise "This isn't supported yet" end @@ -144,6 +145,7 @@ def run_partial_eager field_node = partial.ast_nodes.first result_name = field_node.alias || field_node.name @response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil) + @response.ordered_result_keys = [result_name] evaluate_selection(result_name, partial.ast_nodes, @response) else @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, field_node, nil, nil) @@ -171,6 +173,7 @@ def run_partial_eager field_node = partial.ast_nodes.first result_name = field_node.alias || field_node.name @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false, field_node, nil, nil) + @response.ordered_result_keys = [result_name] @dataloader.append_job do evaluate_selection(result_name, partial.ast_nodes, @response) end From 94d9b74b4a6f8b175aacc99a7ba85807422fbeb1 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 21 Apr 2025 12:06:08 -0400 Subject: [PATCH 16/27] Add basic Union and Interface support --- lib/graphql/execution/interpreter/runtime.rb | 21 ++++++- spec/graphql/query/partial_spec.rb | 63 +++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 86d93b3027..8107947182 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -177,8 +177,27 @@ def run_partial_eager @dataloader.append_job do evaluate_selection(result_name, partial.ast_nodes, @response) end + when "UNION", "INTERFACE" + resolved_type, _resolved_obj = resolve_type(root_type, object) # TODO lazy, errors + object_proxy = resolved_type.wrap(object, context) + object_proxy = schema.sync_lazy(object_proxy) + @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil) + each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys + if is_selection_array == true + raise "This isn't supported yet" + end + + @dataloader.append_job { + evaluate_selections( + selections, + @response, + nil, + runtime_state, + ) + } + end else - # TODO union, interface raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end nil diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index f59fb5b773..626e3eae6c 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -43,7 +43,13 @@ class FarmProduct < GraphQL::Schema::Enum value :DAIRY end + module Entity + include GraphQL::Schema::Interface + field :name, String + end + class Farm < GraphQL::Schema::Object + implements Entity field :name, String field :products, [FarmProduct] field :error, Int @@ -59,6 +65,15 @@ def neighboring_farm end end + class Market < GraphQL::Schema::Object + implements Entity + field :is_year_round, Boolean + end + + class Thing < GraphQL::Schema::Union + possible_types(Farm, Market) + end + class Query < GraphQL::Schema::Object field :farms, [Farm], fallback_value: Database::FARMS.values @@ -71,6 +86,15 @@ def farm(farm:) end field :query, Query, fallback_value: true + + field :thing, Thing + + def thing + Database.get("1") + end + + field :entity, Entity + def entity; Database.get("1"); end end query(Query) @@ -80,7 +104,7 @@ def self.object_from_id(id, ctx) end def self.resolve_type(abs_type, object, ctx) - Farm + object[:is_market] ? Market : Farm end use GraphQL::Dataloader @@ -236,7 +260,7 @@ def run_partials(string, partial_configs) assert_equal({"name" => "Dawnbreak", "__typename" => "Farm", "n2" => "Dawnbreak"}, results.first["data"]) end - it "runs partials on scalars" do + it "runs partials on scalars and enums" do str = "{ farm { name products } }" results = run_partials(str, [ { path: ["farm", "name"], object: { name: "Polyface" } }, @@ -247,4 +271,39 @@ def run_partials(string, partial_configs) assert results[0].partial.leaf? assert results[1].partial.leaf? end + + + it "runs on union selections" do + str = "{ + thing { + ...on Farm { name } + ...on Market { name isYearRound } + } + }" + + results = run_partials(str, [ + { path: ["thing"], object: { name: "Whisper Hill" } }, + { path: ["thing"], object: { is_market: true, name: "Crozet Farmers Market", is_year_round: false } }, + ]) + + assert_equal({ "name" => "Whisper Hill" }, results[0]["data"]) + assert_equal({ "name" => "Crozet Farmers Market", "isYearRound" => false }, results[1]["data"]) + end + + it "runs on interface selections" do + str = "{ + entity { + name + __typename + } + }" + + results = run_partials(str, [ + { path: ["entity"], object: { name: "Whisper Hill" } }, + { path: ["entity"], object: { is_market: true, name: "Crozet Farmers Market" } }, + ]) + + assert_equal({ "name" => "Whisper Hill", "__typename" => "Farm" }, results[0]["data"]) + assert_equal({ "name" => "Crozet Farmers Market", "__typename" => "Market" }, results[1]["data"]) + end end From c37a6700ecd67e9186c5a8325dd70f07f20dd385 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 29 Apr 2025 10:46:48 -0400 Subject: [PATCH 17/27] Add lazy resolve test and custom context support --- lib/graphql/query/partial.rb | 8 ++++++-- spec/graphql/query/partial_spec.rb | 25 +++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 59f522d956..5e8371f2aa 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -4,11 +4,15 @@ class Query # This class is _like_ a {GraphQL::Query}, except # @see Query#run_partials class Partial - def initialize(path:, object:, query:) + def initialize(path:, object:, query:, context: nil) @path = path @object = object @query = query - @context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: @query.context.to_h) + context_vals = @query.context.to_h + if context + context_vals = context_vals.merge(context) + end + @context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: context_vals) @multiplex = nil @result_values = nil @result = nil diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 626e3eae6c..6f26b6d668 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -95,6 +95,14 @@ def thing field :entity, Entity def entity; Database.get("1"); end + + field :read_context, String do + argument :key, String + end + + def read_context(key:) + -> { context[key].to_s } + end end query(Query) @@ -108,14 +116,15 @@ def self.resolve_type(abs_type, object, ctx) end use GraphQL::Dataloader + lazy_resolve Proc, :call end before do PartialSchema::Database.clear end - def run_partials(string, partial_configs) - query = GraphQL::Query.new(PartialSchema, string) + def run_partials(string, partial_configs, **query_kwargs) + query = GraphQL::Query.new(PartialSchema, string, **query_kwargs) query.run_partials(partial_configs) end @@ -306,4 +315,16 @@ def run_partials(string, partial_configs) assert_equal({ "name" => "Whisper Hill", "__typename" => "Farm" }, results[0]["data"]) assert_equal({ "name" => "Crozet Farmers Market", "__typename" => "Market" }, results[1]["data"]) end + + it "accepts custom context" do + str = "{ readContext(key: \"custom\") }" + results = run_partials(str, [ + { path: [], object: nil, context: { "custom" => "one" } }, + { path: [], object: nil, context: { "custom" => "two" } }, + { path: [], object: nil }, + ], context: { "custom" => "three"} ) + assert_equal "one", results[0]["data"]["readContext"] + assert_equal "two", results[1]["data"]["readContext"] + assert_equal "three", results[2]["data"]["readContext"] + end end From 749b670288d377b3a8a91c7333143fc95c487acc Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 29 Apr 2025 10:52:42 -0400 Subject: [PATCH 18/27] Dry Partial with Query --- lib/graphql/query.rb | 64 +++++++++++++++++++----------------- lib/graphql/query/partial.rb | 30 ++--------------- 2 files changed, 36 insertions(+), 58 deletions(-) diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 8088623dd5..51d8ee1ba2 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -17,6 +17,40 @@ class Query autoload :VariableValidationError, "graphql/query/variable_validation_error" autoload :ValidationPipeline, "graphql/query/validation_pipeline" + # Code shared with {Partial} + module Runnable + def after_lazy(value, &block) + if !defined?(@runtime_instance) + @runtime_instance = context.namespace(:interpreter_runtime)[:runtime] + end + + if @runtime_instance + @runtime_instance.minimal_after_lazy(value, &block) + else + @schema.after_lazy(value, &block) + end + end + + # Node-level cache for calculating arguments. Used during execution and query analysis. + # @param ast_node [GraphQL::Language::Nodes::AbstractNode] + # @param definition [GraphQL::Schema::Field] + # @param parent_object [GraphQL::Schema::Object] + # @return [Hash{Symbol => Object}] + def arguments_for(ast_node, definition, parent_object: nil) + arguments_cache.fetch(ast_node, definition, parent_object) + end + + def arguments_cache + @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) + end + + # @api private + def handle_or_reraise(err) + @query.schema.handle_or_reraise(context, err) + end + end + + include Runnable class OperationNameMissingError < GraphQL::ExecutionError def initialize(name) msg = if name.nil? @@ -291,19 +325,6 @@ def variables end end - # Node-level cache for calculating arguments. Used during execution and query analysis. - # @param ast_node [GraphQL::Language::Nodes::AbstractNode] - # @param definition [GraphQL::Schema::Field] - # @param parent_object [GraphQL::Schema::Object] - # @return [Hash{Symbol => Object}] - def arguments_for(ast_node, definition, parent_object: nil) - arguments_cache.fetch(ast_node, definition, parent_object) - end - - def arguments_cache - @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) - end - # A version of the given query string, with: # - Variables inlined to the query # - Strings replaced with `` @@ -413,23 +434,6 @@ def subscription? with_prepared_ast { @subscription } end - # @api private - def handle_or_reraise(err) - schema.handle_or_reraise(context, err) - end - - def after_lazy(value, &block) - if !defined?(@runtime_instance) - @runtime_instance = context.namespace(:interpreter_runtime)[:runtime] - end - - if @runtime_instance - @runtime_instance.minimal_after_lazy(value, &block) - else - @schema.after_lazy(value, &block) - end - end - attr_reader :logger private diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 5e8371f2aa..5903f268de 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -4,6 +4,8 @@ class Query # This class is _like_ a {GraphQL::Query}, except # @see Query#run_partials class Partial + include Query::Runnable + def initialize(path:, object:, query:, context: nil) @path = path @object = object @@ -96,34 +98,6 @@ def types @query.types end - # TODO dry with query - def after_lazy(value, &block) - if !defined?(@runtime_instance) - @runtime_instance = context.namespace(:interpreter_runtime)[:runtime] - end - - if @runtime_instance - @runtime_instance.minimal_after_lazy(value, &block) - else - @schema.after_lazy(value, &block) - end - end - - # TODO dry with query - def arguments_for(ast_node, definition, parent_object: nil) - arguments_cache.fetch(ast_node, definition, parent_object) - end - - # TODO dry with query - def arguments_cache - @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) - end - - # TODO dry - def handle_or_reraise(err) - @query.schema.handle_or_reraise(context, err) - end - def resolve_type(...) @query.resolve_type(...) end From c93c2155be25db51cb7ee988174d200b74d25837 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 29 Apr 2025 11:01:32 -0400 Subject: [PATCH 19/27] Add partial tracing --- lib/graphql/execution/interpreter.rb | 11 +++++++---- lib/graphql/query/partial.rb | 9 +++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 729cffedcd..0ea39198ae 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -159,8 +159,9 @@ def run_partials(schema, partials, context:) dataloader.append_job { runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth) partial.context.namespace(:interpreter_runtime)[:runtime] = runtime - # TODO tracing? - runtime.run_partial_eager + partial.current_trace.execute_query(query: partial) do + runtime.run_partial_eager + end } end @@ -171,8 +172,10 @@ def run_partials(schema, partials, context:) runtime = partial.context.namespace(:interpreter_runtime)[:runtime] runtime.final_result end - # TODO tracing? - Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) + partial = partials.length == 1 ? partials.first : nil + multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: partial) do + Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) + end } dataloader.run diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 5903f268de..190dc1b934 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true module GraphQL class Query - # This class is _like_ a {GraphQL::Query}, except - # @see Query#run_partials + # This class is _like_ a {GraphQL::Query}, except it can run on an arbitrary path within a query string. + # + # It depends on a "parent" {Query}. + # + # During execution, it calls query-related tracing hooks but passes itself as `query:`. + # + # @see Query#run_partials Run via {Query#run_partials} class Partial include Query::Runnable From 3e8c71850eef404cc343494729391874e94ebf26 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Tue, 29 Apr 2025 11:08:13 -0400 Subject: [PATCH 20/27] fix handle_or_reraise --- lib/graphql/query.rb | 2 +- lib/graphql/query/partial.rb | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 51d8ee1ba2..b4fe6e2a9b 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -46,7 +46,7 @@ def arguments_cache # @api private def handle_or_reraise(err) - @query.schema.handle_or_reraise(context, err) + @schema.handle_or_reraise(context, err) end end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 190dc1b934..8ddfda0c0f 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -15,6 +15,7 @@ def initialize(path:, object:, query:, context: nil) @path = path @object = object @query = query + @schema = query.schema context_vals = @query.context.to_h if context context_vals = context_vals.merge(context) @@ -72,7 +73,7 @@ def leaf? @leaf end - attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type + attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type, :schema attr_accessor :multiplex, :result_values @@ -95,10 +96,6 @@ def current_trace @query.current_trace end - def schema - @query.schema - end - def types @query.types end From 60a1b8e7133ea778071ab574e42a96bac9f75bb2 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 30 Apr 2025 09:37:32 -0400 Subject: [PATCH 21/27] Improve error message, handle partials on list items --- lib/graphql/query/partial.rb | 16 ++++++++++++++++ lib/graphql/schema.rb | 2 +- spec/graphql/query/partial_spec.rb | 4 +++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 8ddfda0c0f..974376a6d2 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -7,10 +7,17 @@ class Query # # During execution, it calls query-related tracing hooks but passes itself as `query:`. # + # The {Partial} will use your {Schema.resolve_type} hook to find the right GraphQL type to use for + # `object` in some cases. + # # @see Query#run_partials Run via {Query#run_partials} class Partial include Query::Runnable + # @param path [Array] A path in `query.query_string` to start executing from + # @param object [Object] A starting object for execution + # @param query [GraphQL::Query] A full query instance that this partial is based on. Caches are shared. + # @param context [Hash] Extra context values to merge into `query.context`, if provided def initialize(path:, object:, query:, context: nil) @path = path @object = object @@ -29,6 +36,15 @@ def initialize(path:, object:, query:, context: nil) parent_type = nil field_defn = nil @path.each do |name_in_doc| + if name_in_doc.is_a?(Integer) + if type.list? + type = type.unwrap + next + else + raise ArgumentError, "Received path with index `#{name_in_doc}`, but type wasn't a list. Type: #{type.to_type_signature}, path: #{@path}" + end + end + next_selections = [] selections.each do |selection| selections_to_check = [] diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index a5fcf40dba..b72b24b5af 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1189,7 +1189,7 @@ def resolve_type(type, obj, ctx) # @param context [GraphQL::Query::Context] The query context for the currently-executing query # @return [Class { "name" => "Bellair Farm" } }, { "data" => { "name" => "Injected Farm" } }, + {"data" => {"name" => "Kestrel Hollow", "products" => ["MEAT", "EGGS"]} }, ], results end From 09df6e370241c8429581fb29e4fb502d13e58a6d Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 1 May 2025 09:20:29 -0400 Subject: [PATCH 22/27] Test scalars on abstract types; support introspection fields; support any root type --- lib/graphql/query.rb | 13 ++------- lib/graphql/query/partial.rb | 4 +-- spec/graphql/query/partial_spec.rb | 46 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index b4fe6e2a9b..19fadce951 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -237,14 +237,7 @@ def lookahead if ast_node.nil? GraphQL::Execution::Lookahead::NULL_LOOKAHEAD else - root_type = case ast_node.operation_type - when nil, "query" - types.query_root # rubocop:disable Development/ContextIsPassedCop - when "mutation" - types.mutation_root # rubocop:disable Development/ContextIsPassedCop - when "subscription" - types.subscription_root # rubocop:disable Development/ContextIsPassedCop - end + root_type = root_type_for_operation(ast_node.operation_type) GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node]) end end @@ -391,14 +384,14 @@ def possible_types(type) def root_type_for_operation(op_type) case op_type - when "query" + when "query", nil types.query_root # rubocop:disable Development/ContextIsPassedCop when "mutation" types.mutation_root # rubocop:disable Development/ContextIsPassedCop when "subscription" types.subscription_root # rubocop:disable Development/ContextIsPassedCop else - raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected 'query', 'mutation', or 'subscription'" + raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected nil, 'query', 'mutation', or 'subscription'" end end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 974376a6d2..df5cdd3a7d 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -32,7 +32,7 @@ def initialize(path:, object:, query:, context: nil) @result_values = nil @result = nil selections = [@query.selected_operation] - type = @query.schema.query # TODO could be other? + type = @query.root_type_for_operation(@query.selected_operation.operation_type) parent_type = nil field_defn = nil @path.each do |name_in_doc| @@ -70,7 +70,7 @@ def initialize(path:, object:, query:, context: nil) raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." end field_name = next_selections.first.name - field_defn = type.get_field(field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}") + field_defn = @schema.get_field(type, field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}") parent_type = type type = field_defn.type if type.non_null? diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 649f13d9ab..3d48c318c5 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -105,7 +105,18 @@ def read_context(key:) end end + class Mutation < GraphQL::Schema::Object + field :update_farm, Farm do + argument :name, String + end + + def update_farm(name:) + { name: name } + end + end + query(Query) + mutation(Mutation) def self.object_from_id(id, ctx) ctx.dataloader.with(FarmSource).load(id) @@ -318,6 +329,28 @@ def run_partials(string, partial_configs, **query_kwargs) assert_equal({ "name" => "Crozet Farmers Market", "__typename" => "Market" }, results[1]["data"]) end + it "runs scalars on abstract types" do + str = "{ + entity { + name + __typename + } + }" + + results = run_partials(str, [ + { path: ["entity", "name"], object: { name: "Whisper Hill" } }, + { path: ["entity", "__typename"], object: { name: "Whisper Hill" } }, + + { path: ["entity", "name"], object: { is_market: true, name: "Crozet Farmers Market" } }, + { path: ["entity", "__typename"], object: { is_market: true, name: "Crozet Farmers Market" } }, + ]) + + assert_equal({ "name" => "Whisper Hill"}, results[0]["data"]) + assert_equal({ "__typename" => "Farm"}, results[1]["data"]) + assert_equal({ "name" => "Crozet Farmers Market" }, results[2]["data"]) + assert_equal({ "__typename" => "Market" }, results[3]["data"]) + end + it "accepts custom context" do str = "{ readContext(key: \"custom\") }" results = run_partials(str, [ @@ -329,4 +362,17 @@ def run_partials(string, partial_configs, **query_kwargs) assert_equal "two", results[1]["data"]["readContext"] assert_equal "three", results[2]["data"]["readContext"] end + + it "runs partials on mutation root" do + str = "mutation { updateFarm(name: \"Brawndo Acres\") { name } }" + results = run_partials(str, [ + { path: [], object: nil }, + { path: ["updateFarm"], object: { name: "Georgetown Farm" } }, + { path: ["updateFarm", "name"], object: { name: "Notta Farm" } }, + ]) + + assert_equal({ "updateFarm" => { "name" => "Brawndo Acres" } }, results[0]["data"]) + assert_equal({ "name" => "Georgetown Farm" }, results[1]["data"]) + assert_equal({ "name" => "Notta Farm" }, results[2]["data"]) + end end From c3e3919773839e0a1e933d61d6234cfcae3f18e3 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 1 May 2025 09:54:09 -0400 Subject: [PATCH 23/27] Merge run_eager and run_partials_eager --- lib/graphql/execution/interpreter.rb | 2 +- lib/graphql/execution/interpreter/runtime.rb | 116 ++++++++----------- lib/graphql/query.rb | 10 +- lib/graphql/query/partial.rb | 2 +- 4 files changed, 55 insertions(+), 75 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 0ea39198ae..eb43758975 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -160,7 +160,7 @@ def run_partials(schema, partials, context:) runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth) partial.context.namespace(:interpreter_runtime)[:runtime] = runtime partial.current_trace.execute_query(query: partial) do - runtime.run_partial_eager + runtime.run_eager end } end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 8107947182..9beb7f06ef 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -64,89 +64,67 @@ def inspect "#<#{self.class.name} response=#{@response.inspect}>" end - # This _begins_ the execution. Some deferred work - # might be stored up in lazies. # @return [void] def run_eager - root_operation = query.selected_operation - root_op_type = root_operation.operation_type || "query" - root_type = schema.root_type_for_operation(root_op_type) - runtime_object = root_type.wrap(query.root_value, context) - runtime_object = schema.sync_lazy(runtime_object) - is_eager = root_op_type == "mutation" - @response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, root_operation.selections, is_eager, root_operation, nil, nil) - st = get_current_runtime_state - st.current_result = @response - - if runtime_object.nil? - # Root .authorized? returned false. - @response = nil + root_type = query.root_type + case query + when GraphQL::Query + ast_node = query.selected_operation + selections = ast_node.selections + object = query.root_value + is_eager = ast_node.operation_type == "mutation" + when GraphQL::Query::Partial + ast_node = query.ast_nodes.first + selections = query.ast_nodes.map(&:selections).inject(&:+) + object = partial.object + is_eager = false else - call_method_on_directives(:resolve, runtime_object, root_operation.directives) do # execute query level directives - each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - @response.ordered_result_keys ||= ordered_result_keys - if is_selection_array - selection_response = GraphQLResultHash.new(nil, root_type, runtime_object, nil, false, selections, is_eager, root_operation, nil, nil) - selection_response.ordered_result_keys = ordered_result_keys - final_response = @response - else - selection_response = @response - final_response = nil - end - - @dataloader.append_job { - evaluate_selections( - selections, - selection_response, - final_response, - nil, - ) - } - end - end + raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})" end - nil - end - - # @return [void] - def run_partial_eager - # `query` is actually a GraphQL::Query::Partial - partial = query - root_type = partial.root_type - object = partial.object - selections = partial.ast_nodes.map(&:selections).inject(&:+) runtime_state = get_current_runtime_state case root_type.kind.name when "OBJECT" object_proxy = root_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) - @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil) - each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| - @response.ordered_result_keys ||= ordered_result_keys - if is_selection_array == true - raise "This isn't supported yet" - end + if object_proxy.nil? + @response = nil + else + @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + runtime_state.current_result = @response + call_method_on_directives(:resolve, object, ast_node.directives) do + each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys + if is_selection_array + selection_response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + selection_response.ordered_result_keys = ordered_result_keys + final_response = @response + else + selection_response = @response + final_response = nil + end - @dataloader.append_job { - evaluate_selections( - selections, - @response, - nil, - runtime_state, - ) - } + @dataloader.append_job { + evaluate_selections( + selections, + selection_response, + final_response, + nil, + ) + } + end + end end when "LIST" inner_type = root_type.unwrap case inner_type.kind.name when "SCALAR", "ENUM" - parent_object_proxy = partial.parent_type.wrap(object, context) + parent_object_proxy = query.parent_type.wrap(object, context) parent_object_proxy = schema.sync_lazy(parent_object_proxy) - field_node = partial.ast_nodes.first + field_node = query.ast_nodes.first result_name = field_node.alias || field_node.name - @response = GraphQLResultHash.new(nil, partial.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil) + @response = GraphQLResultHash.new(nil, query.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil) @response.ordered_result_keys = [result_name] - evaluate_selection(result_name, partial.ast_nodes, @response) + evaluate_selection(result_name, query.ast_nodes, @response) else @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, field_node, nil, nil) idx = nil @@ -165,23 +143,23 @@ def run_partial_eager end end when "SCALAR", "ENUM" - parent_type = partial.parent_type + parent_type = query.parent_type # TODO what if not object type? Maybe returns a lazy here. parent_object_type, object = resolve_type(parent_type, object) parent_object_proxy = parent_object_type.wrap(object, context) parent_object_proxy = schema.sync_lazy(parent_object_proxy) - field_node = partial.ast_nodes.first + field_node = query.ast_nodes.first result_name = field_node.alias || field_node.name @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false, field_node, nil, nil) @response.ordered_result_keys = [result_name] @dataloader.append_job do - evaluate_selection(result_name, partial.ast_nodes, @response) + evaluate_selection(result_name, query.ast_nodes, @response) end when "UNION", "INTERFACE" resolved_type, _resolved_obj = resolve_type(root_type, object) # TODO lazy, errors object_proxy = resolved_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) - @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, partial.ast_nodes.first, nil, nil) + @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil) each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| @response.ordered_result_keys ||= ordered_result_keys if is_selection_array == true diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 19fadce951..e045411983 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -233,12 +233,10 @@ def subscription_update? # @return [GraphQL::Execution::Lookahead] def lookahead @lookahead ||= begin - ast_node = selected_operation - if ast_node.nil? + if selected_operation.nil? GraphQL::Execution::Lookahead::NULL_LOOKAHEAD else - root_type = root_type_for_operation(ast_node.operation_type) - GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node]) + GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [selected_operation]) end end end @@ -395,6 +393,10 @@ def root_type_for_operation(op_type) end end + def root_type + root_type_for_operation(selected_operation.operation_type) + end + def types @visibility_profile || warden.visibility_profile end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index df5cdd3a7d..7baf3b2d9b 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -32,7 +32,7 @@ def initialize(path:, object:, query:, context: nil) @result_values = nil @result = nil selections = [@query.selected_operation] - type = @query.root_type_for_operation(@query.selected_operation.operation_type) + type = @query.root_type parent_type = nil field_defn = nil @path.each do |name_in_doc| From 1a5d6502e68bcc0aaedbefbe916c42d2729a93ad Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 1 May 2025 10:03:47 -0400 Subject: [PATCH 24/27] merge run_partials into run_all --- lib/graphql/execution/interpreter.rb | 59 -------------------- lib/graphql/execution/interpreter/runtime.rb | 2 +- lib/graphql/query.rb | 2 +- lib/graphql/query/partial.rb | 24 ++++++++ spec/graphql/query/partial_spec.rb | 11 ++-- 5 files changed, 32 insertions(+), 66 deletions(-) diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index eb43758975..ba0b94b2aa 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -149,65 +149,6 @@ def run_all(schema, query_options, context: {}, max_complexity: schema.max_compl end end end - - def run_partials(schema, partials, context:) - multiplex = Execution::Multiplex.new(schema: schema, queries: partials, context: context, max_complexity: nil) - dataloader = multiplex.dataloader - lazies_at_depth = Hash.new { |h, k| h[k] = [] } - - partials.each do |partial| - dataloader.append_job { - runtime = Runtime.new(query: partial, lazies_at_depth: lazies_at_depth) - partial.context.namespace(:interpreter_runtime)[:runtime] = runtime - partial.current_trace.execute_query(query: partial) do - runtime.run_eager - end - } - end - - dataloader.run - - dataloader.append_job { - partials.each do |partial| - runtime = partial.context.namespace(:interpreter_runtime)[:runtime] - runtime.final_result - end - partial = partials.length == 1 ? partials.first : nil - multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: partial) do - Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) - end - } - - dataloader.run - - partials.map do |partial| - # Assign the result so that it can be accessed in instrumentation - data_result = partial.context.namespace(:interpreter_runtime)[:runtime].final_result - partial.result_values = if data_result.equal?(NO_OPERATION) - if !partial.context.errors.empty? - { "errors" => partial.context.errors.map(&:to_h) } - else - data_result - end - else - result = {} - - if !partial.context.errors.empty? - error_result = partial.context.errors.map(&:to_h) - result["errors"] = error_result - end - - result["data"] = data_result - - result - end - if partial.context.namespace?(:__query_result_extensions__) - partial.result_values["extensions"] = partial.context.namespace(:__query_result_extensions__) - end - # Partial::Result - partial.result - end - end end class ListResultFailedError < GraphQL::Error diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 9beb7f06ef..24fc748f6a 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -76,7 +76,7 @@ def run_eager when GraphQL::Query::Partial ast_node = query.ast_nodes.first selections = query.ast_nodes.map(&:selections).inject(&:+) - object = partial.object + object = query.object is_eager = false else raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})" diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index e045411983..200c520089 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -271,7 +271,7 @@ def operations # @return [Array] def run_partials(partials_hashes) partials = partials_hashes.map { |partial_options| Partial.new(query: self, **partial_options) } - Execution::Interpreter.run_partials(@schema, partials, context: @context) + Execution::Interpreter.run_all(@schema, partials, context: @context) end # Get the result for this query, executing it once diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb index 7baf3b2d9b..fc2c4a0d62 100644 --- a/lib/graphql/query/partial.rb +++ b/lib/graphql/query/partial.rb @@ -127,6 +127,30 @@ def variables def fragments @query.fragments end + + def valid? + @query.valid? + end + + def analyzers + EmptyObjects::EMPTY_ARRAY + end + + def analysis_errors=(_ignored) + # pass + end + + def subscription? + @query.subscription? + end + + def selected_operation + ast_nodes.first + end + + def static_errors + @query.static_errors + end end end end diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 3d48c318c5..ec46940893 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -247,18 +247,19 @@ def run_partials(string, partial_configs, **query_kwargs) it "merges selections when path steps are duplicated" do str = <<-GRAPHQL { - f1: farm { neighboringFarm { name } } - f1: farm { neighboringFarm { name2: name } } + farm(id: 5) { neighboringFarm { name } } + farm(id: 5) { neighboringFarm { name2: name } } } GRAPHQL - results = run_partials(str, [{ path: ["f1", "neighboringFarm"], object: OpenStruct.new(name: "Dawnbreak") }]) + results = run_partials(str, [{ path: ["farm", "neighboringFarm"], object: OpenStruct.new(name: "Dawnbreak") }]) + assert_equal({"name" => "Dawnbreak", "name2" => "Dawnbreak" }, results.first["data"]) end it "works when there are inline fragments in the path" do str = <<-GRAPHQL { - farm { + farm(id: "BLAH") { ... on Farm { neighboringFarm { name @@ -283,7 +284,7 @@ def run_partials(string, partial_configs, **query_kwargs) end it "runs partials on scalars and enums" do - str = "{ farm { name products } }" + str = "{ farm(id: \"BLAH\") { name products } }" results = run_partials(str, [ { path: ["farm", "name"], object: { name: "Polyface" } }, { path: ["farm", "products"], object: { products: ["MEAT"] } }, From 73e62d9f0fdef2ffabc1b821b01675a5d711f684 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 1 May 2025 10:46:57 -0400 Subject: [PATCH 25/27] Resolve lazy resolved type if necessary --- lib/graphql/execution/interpreter/runtime.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 24fc748f6a..f7e28385ca 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -144,8 +144,8 @@ def run_eager end when "SCALAR", "ENUM" parent_type = query.parent_type - # TODO what if not object type? Maybe returns a lazy here. parent_object_type, object = resolve_type(parent_type, object) + parent_object_type = schema.sync_lazy(parent_object_type) parent_object_proxy = parent_object_type.wrap(object, context) parent_object_proxy = schema.sync_lazy(parent_object_proxy) field_node = query.ast_nodes.first @@ -156,7 +156,8 @@ def run_eager evaluate_selection(result_name, query.ast_nodes, @response) end when "UNION", "INTERFACE" - resolved_type, _resolved_obj = resolve_type(root_type, object) # TODO lazy, errors + resolved_type, _resolved_obj = resolve_type(root_type, object) + resolved_type = schema.sync_lazy(resolved_type) object_proxy = resolved_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil) From 23fdcd8a0706bd67891905c1abe74f650755902b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 2 May 2025 06:44:49 -0400 Subject: [PATCH 26/27] Make list items run as items, make scalars return scalars directly --- lib/graphql/execution/interpreter/runtime.rb | 43 ++++++++++---------- spec/graphql/query/partial_spec.rb | 41 +++++++++++-------- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index f7e28385ca..33911c5c7e 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -57,7 +57,7 @@ def initialize(query:, lazies_at_depth:) end def final_result - @response && @response.graphql_result_data + @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end def inspect @@ -81,6 +81,7 @@ def run_eager else raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})" end + object = schema.sync_lazy(object) # TODO test query partial with lazy root object runtime_state = get_current_runtime_state case root_type.kind.name when "OBJECT" @@ -118,15 +119,17 @@ def run_eager inner_type = root_type.unwrap case inner_type.kind.name when "SCALAR", "ENUM" - parent_object_proxy = query.parent_type.wrap(object, context) - parent_object_proxy = schema.sync_lazy(parent_object_proxy) - field_node = query.ast_nodes.first - result_name = field_node.alias || field_node.name - @response = GraphQLResultHash.new(nil, query.parent_type, parent_object_proxy, nil, false, nil, false, field_node, nil, nil) - @response.ordered_result_keys = [result_name] - evaluate_selection(result_name, query.ast_nodes, @response) + result_name = ast_node.alias || ast_node.name + owner_type = query.field_definition.owner + selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result.ordered_result_keys = [result_name] + runtime_state = get_current_runtime_state + runtime_state.current_result = selection_result + runtime_state.current_result_name = result_name + continue_field(object, owner_type, query.field_definition, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + @response = selection_result[result_name] else - @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, field_node, nil, nil) + @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) idx = nil object.each do |inner_value| idx ||= 0 @@ -143,18 +146,16 @@ def run_eager end end when "SCALAR", "ENUM" - parent_type = query.parent_type - parent_object_type, object = resolve_type(parent_type, object) - parent_object_type = schema.sync_lazy(parent_object_type) - parent_object_proxy = parent_object_type.wrap(object, context) - parent_object_proxy = schema.sync_lazy(parent_object_proxy) - field_node = query.ast_nodes.first - result_name = field_node.alias || field_node.name - @response = GraphQLResultHash.new(nil, parent_object_type, parent_object_proxy, nil, false, selections, false, field_node, nil, nil) - @response.ordered_result_keys = [result_name] - @dataloader.append_job do - evaluate_selection(result_name, query.ast_nodes, @response) - end + result_name = ast_node.alias || ast_node.name + owner_type = query.field_definition.owner + selection_result = GraphQLResultHash.new(nil, query.parent_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result.ordered_result_keys = [result_name] + runtime_state = get_current_runtime_state + runtime_state.current_result = selection_result + runtime_state.current_result_name = result_name + + continue_field(object, owner_type, query.field_definition, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + @response = selection_result[result_name] when "UNION", "INTERFACE" resolved_type, _resolved_obj = resolve_type(root_type, object) resolved_type = schema.sync_lazy(resolved_type) diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index ec46940893..54476bc980 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -38,7 +38,7 @@ def fetch(farm_ids) class FarmProduct < GraphQL::Schema::Enum value :FRUIT value :VEGETABLES - value :MEAT + value :MEAT, value: :__MEAT__ value :EGGS value :DAIRY end @@ -85,6 +85,8 @@ def farm(farm:) farm end + field :farm_names, [String], fallback_value: Database::FARMS.each_value.map(&:name) + field :query, Query, fallback_value: true field :thing, Thing @@ -148,7 +150,7 @@ def run_partials(string, partial_configs, **query_kwargs) results = run_partials(str, [ { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, { path: ["farm2"], object: OpenStruct.new(name: "Injected Farm") }, - { path: ["farms", 0], object: { name: "Kestrel Hollow", products: ["MEAT", "EGGS"]} }, + { path: ["farms", 0], object: { name: "Kestrel Hollow", products: [:__MEAT__, "EGGS"]} }, ]) assert_equal [ @@ -244,6 +246,16 @@ def run_partials(string, partial_configs, **query_kwargs) refute result.partial.leaf? end + it "works on lists of scalars" do + str = "{ query { farmNames } }" + results = run_partials(str, [ + { path: ["query", "farmNames", 0], object: "Twenty Paces" }, + { path: ["query", "farmNames", 1], object: "Caromont" }, + ]) + assert_equal "Twenty Paces", results[0]["data"] + assert_equal "Caromont", results[1]["data"] + end + it "merges selections when path steps are duplicated" do str = <<-GRAPHQL { @@ -286,10 +298,10 @@ def run_partials(string, partial_configs, **query_kwargs) it "runs partials on scalars and enums" do str = "{ farm(id: \"BLAH\") { name products } }" results = run_partials(str, [ - { path: ["farm", "name"], object: { name: "Polyface" } }, - { path: ["farm", "products"], object: { products: ["MEAT"] } }, + { path: ["farm", "name"], object: "Polyface" }, + { path: ["farm", "products"], object: [:__MEAT__] }, ]) - assert_equal [{"name" => "Polyface"}, { "products" => ["MEAT"] }], results.map { |r| r["data"] } + assert_equal ["Polyface", ["MEAT"]], results.map { |r| r["data"] } assert results[0].partial.leaf? assert results[1].partial.leaf? @@ -339,17 +351,14 @@ def run_partials(string, partial_configs, **query_kwargs) }" results = run_partials(str, [ - { path: ["entity", "name"], object: { name: "Whisper Hill" } }, - { path: ["entity", "__typename"], object: { name: "Whisper Hill" } }, - - { path: ["entity", "name"], object: { is_market: true, name: "Crozet Farmers Market" } }, - { path: ["entity", "__typename"], object: { is_market: true, name: "Crozet Farmers Market" } }, + { path: ["entity", "name"], object: "Whisper Hill" }, + { path: ["entity", "__typename"], object: "Farm" }, + { path: ["entity", "name"], object: "Crozet Farmers Market" }, ]) - assert_equal({ "name" => "Whisper Hill"}, results[0]["data"]) - assert_equal({ "__typename" => "Farm"}, results[1]["data"]) - assert_equal({ "name" => "Crozet Farmers Market" }, results[2]["data"]) - assert_equal({ "__typename" => "Market" }, results[3]["data"]) + assert_equal("Whisper Hill", results[0]["data"]) + assert_equal("Farm", results[1]["data"]) + assert_equal("Crozet Farmers Market", results[2]["data"]) end it "accepts custom context" do @@ -369,11 +378,11 @@ def run_partials(string, partial_configs, **query_kwargs) results = run_partials(str, [ { path: [], object: nil }, { path: ["updateFarm"], object: { name: "Georgetown Farm" } }, - { path: ["updateFarm", "name"], object: { name: "Notta Farm" } }, + { path: ["updateFarm", "name"], object: "Notta Farm" }, ]) assert_equal({ "updateFarm" => { "name" => "Brawndo Acres" } }, results[0]["data"]) assert_equal({ "name" => "Georgetown Farm" }, results[1]["data"]) - assert_equal({ "name" => "Notta Farm" }, results[2]["data"]) + assert_equal("Notta Farm", results[2]["data"]) end end From c2388426b866e72132e6480d6412438efbe1789c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 2 May 2025 10:42:30 -0400 Subject: [PATCH 27/27] Use a full path in Partial responses, handle runtime errors in scalars --- lib/graphql/execution/interpreter/runtime.rb | 20 ++++++++-- .../interpreter/runtime/graphql_result.rb | 12 +++++- spec/graphql/query/partial_spec.rb | 37 +++++++++++++++++-- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 33911c5c7e..75429d5dc9 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -73,11 +73,13 @@ def run_eager selections = ast_node.selections object = query.root_value is_eager = ast_node.operation_type == "mutation" + base_path = nil when GraphQL::Query::Partial ast_node = query.ast_nodes.first selections = query.ast_nodes.map(&:selections).inject(&:+) object = query.object is_eager = false + base_path = query.path else raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})" end @@ -91,6 +93,7 @@ def run_eager @response = nil else @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + @response.base_path = base_path runtime_state.current_result = @response call_method_on_directives(:resolve, object, ast_node.directives) do each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| @@ -122,14 +125,20 @@ def run_eager result_name = ast_node.alias || ast_node.name owner_type = query.field_definition.owner selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result.base_path = base_path selection_result.ordered_result_keys = [result_name] runtime_state = get_current_runtime_state runtime_state.current_result = selection_result runtime_state.current_result_name = result_name - continue_field(object, owner_type, query.field_definition, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + field_defn = query.field_definition + continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) + if HALT != continue_value + continue_field(continue_value, owner_type, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + end @response = selection_result[result_name] else @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) + @response.base_path = base_path idx = nil object.each do |inner_value| idx ||= 0 @@ -150,11 +159,15 @@ def run_eager owner_type = query.field_definition.owner selection_result = GraphQLResultHash.new(nil, query.parent_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) selection_result.ordered_result_keys = [result_name] + selection_result.base_path = base_path runtime_state = get_current_runtime_state runtime_state.current_result = selection_result runtime_state.current_result_name = result_name - - continue_field(object, owner_type, query.field_definition, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + field_defn = query.field_definition + continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) + if HALT != continue_value + continue_field(continue_value, owner_type, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + end @response = selection_result[result_name] when "UNION", "INTERFACE" resolved_type, _resolved_obj = resolve_type(root_type, object) @@ -162,6 +175,7 @@ def run_eager object_proxy = resolved_type.wrap(object, context) object_proxy = schema.sync_lazy(object_proxy) @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil) + @response.base_path = base_path each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| @response.ordered_result_keys ||= ordered_result_keys if is_selection_array == true diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb index d6ce61a45c..fdba7911bb 100644 --- a/lib/graphql/execution/interpreter/runtime/graphql_result.rb +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -21,15 +21,25 @@ def initialize(result_name, result_type, application_value, parent_result, is_no @graphql_metadata = nil @graphql_selections = selections @graphql_is_eager = is_eager + @base_path = nil end + # TODO test full path in Partial + attr_writer :base_path + def path @path ||= build_path([]) end def build_path(path_array) graphql_result_name && path_array.unshift(graphql_result_name) - @graphql_parent ? @graphql_parent.build_path(path_array) : path_array + if @graphql_parent + @graphql_parent.build_path(path_array) + elsif @base_path + @base_path + path_array + else + path_array + end end attr_accessor :graphql_dead diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb index 54476bc980..6127a0b558 100644 --- a/spec/graphql/query/partial_spec.rb +++ b/spec/graphql/query/partial_spec.rb @@ -179,11 +179,10 @@ def run_partials(string, partial_configs, **query_kwargs) ]) - assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>2, "column"=>30}], "path"=>["error"]}], results[0]["errors"] + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>2, "column"=>30}], "path"=>["farm1", "error"]}], results[0]["errors"] refute results[1].key?("errors") - assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>4, "column"=>35}], "path"=>["fieldError"]}], results[2]["errors"] - assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>7, "column"=>11}], "path"=>["neighboringFarm", "error"]}], results[3]["errors"] - + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>4, "column"=>35}], "path"=>["farm3", "fieldError"]}], results[2]["errors"] + assert_equal [{"message"=>"This is a field error", "locations"=>[{"line"=>7, "column"=>11}], "path"=>["farm4", "neighboringFarm", "error"]}], results[3]["errors"] assert_equal({ "error" => nil }, results[0]["data"]) assert_equal({ "name" => "Henley's Orchard" }, results[1]["data"]) assert_equal({ "name" => "Wenger Grapes", "fieldError" => nil }, results[2]["data"]) @@ -251,9 +250,14 @@ def run_partials(string, partial_configs, **query_kwargs) results = run_partials(str, [ { path: ["query", "farmNames", 0], object: "Twenty Paces" }, { path: ["query", "farmNames", 1], object: "Caromont" }, + { path: ["query", "farmNames", 2], object: GraphQL::ExecutionError.new("Boom!") }, ]) assert_equal "Twenty Paces", results[0]["data"] assert_equal "Caromont", results[1]["data"] + assert_equal({ + "errors" => [{"message" => "Boom!", "locations" => [{"line" => 1, "column" => 11}], "path" => ["query", "farmNames", 2, "farmNames"]}], + "data" => nil + }, results[2]) end it "merges selections when path steps are duplicated" do @@ -373,6 +377,8 @@ def run_partials(string, partial_configs, **query_kwargs) assert_equal "three", results[2]["data"]["readContext"] end + it "returns a full context" + it "runs partials on mutation root" do str = "mutation { updateFarm(name: \"Brawndo Acres\") { name } }" results = run_partials(str, [ @@ -385,4 +391,27 @@ def run_partials(string, partial_configs, **query_kwargs) assert_equal({ "name" => "Georgetown Farm" }, results[1]["data"]) assert_equal("Notta Farm", results[2]["data"]) end + + it "handles errors on scalars" do + str = "{ + entity { + name + __typename + } + }" + + results = run_partials(str, [ + { path: ["entity"], object: { name: GraphQL::ExecutionError.new("Boom!") } }, + { path: ["entity", "name"], object: GraphQL::ExecutionError.new("Bang!") }, + ]) + + assert_equal({ + "errors" => [{"message" => "Boom!", "locations" => [{"line" => 3, "column" => 9}], "path" => ["entity", "name"]}], + "data" => { "name" => nil, "__typename" => "Farm" } + }, results[0]) + assert_equal({ + "errors" => [{"message" => "Bang!", "locations" => [{"line" => 3, "column" => 9}], "path" => ["entity", "name", "name"]}], + "data" => nil + }, results[1]) + end end