diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 63f911699c..0ea39198ae 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 query = 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})" @@ -149,6 +149,65 @@ 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_partial_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 bc488b0f9d..8107947182 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -108,6 +108,101 @@ def run_eager 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 + + @dataloader.append_job { + evaluate_selections( + selections, + @response, + nil, + runtime_state, + ) + } + 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 = 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, 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) + 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 + # 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 + 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 + 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 + raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" + end + nil + end + def each_gathered_selections(response_hash) ordered_result_keys = [] gathered_selections = gather_selections(response_hash.graphql_application_value, response_hash.graphql_result_type, response_hash.graphql_selections, nil, {}, ordered_result_keys) diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index bf4813a406..b4fe6e2a9b 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -10,12 +10,47 @@ 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" 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) + @schema.handle_or_reraise(context, err) + end + end + + include Runnable class OperationNameMissingError < GraphQL::ExecutionError def initialize(name) msg = if name.nil? @@ -236,6 +271,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:` 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_hashes [Array Object}>] Hashes with `path:` and `object:` keys + # @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) + 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 @@ -278,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 `` @@ -400,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 new file mode 100644 index 0000000000..8ddfda0c0f --- /dev/null +++ b/lib/graphql/query/partial.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +module GraphQL + class Query + # 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 + + 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) + end + @context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: context_vals) + @multiplex = nil + @result_values = nil + @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 = [] + selections.each do |selection| + 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 + + 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}") + 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 + + def leaf? + @leaf + end + + attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :parent_type, :schema + + attr_accessor :multiplex, :result_values + + class Result < GraphQL::Query::Result + def path + @query.path + end + + # @return [GraphQL::Query::Partial] + def partial + @query + end + end + + def result + @result ||= Result.new(query: self, values: result_values) + end + + def current_trace + @query.current_trace + end + + def types + @query.types + end + + def resolve_type(...) + @query.resolve_type(...) + end + + 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 new file mode 100644 index 0000000000..6f26b6d668 --- /dev/null +++ b/spec/graphql/query/partial_spec.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Query::Partial do + class PartialSchema < GraphQL::Schema + 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 + value :VEGETABLES + value :MEAT + value :EGGS + 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 + + 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 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 + + field :farm, Farm do + argument :id, ID, loads: Farm, as: :farm + end + + def farm(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 + + field :read_context, String do + argument :key, String + end + + def read_context(key:) + -> { context[key].to_s } + end + end + + query(Query) + + def self.object_from_id(id, ctx) + ctx.dataloader.with(FarmSource).load(id) + end + + def self.resolve_type(abs_type, object, ctx) + object[:is_market] ? Market : Farm + end + + use GraphQL::Dataloader + lazy_resolve Proc, :call + end + + before do + PartialSchema::Database.clear + end + + def run_partials(string, partial_configs, **query_kwargs) + query = GraphQL::Query.new(PartialSchema, string, **query_kwargs) + 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 } + }" + results = run_partials(str, [ + { path: ["farm1"], object: PartialSchema::Database::FARMS["1"] }, + { path: ["farm2"], object: OpenStruct.new(name: "Injected Farm") } + ]) + + assert_equal [ + { "data" => { "name" => "Bellair Farm" } }, + { "data" => { "name" => "Injected Farm" } }, + ], results + end + + it "returns errors if they occur" do + str = "{ + 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 + 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 "can run partials with the same path" do + str = "{ + farm(id: \"1\") { name } + }" + results = run_partials(str, [ + { 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 + query { + query1: query { farm(id: "1") { name neighboringFarm { name } } } + query2: query { farm(id: "2") { name neighboringFarm { name } } } + } + GRAPHQL + + 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"] + + assert_equal [[:mget, ["1", "2"]], [:mget, ["3"]]], PartialSchema::Database.log + end + + 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" }]}]) + 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 + 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 + 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 + + 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 and enums" 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 + + + 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 + + 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