diff --git a/Gemfile b/Gemfile index 69aca9d35a..c9b86b9296 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,7 @@ if RUBY_VERSION >= "3.0" gem "evt" end -if RUBY_VERSION >= "3.1.1" +if RUBY_VERSION >= "3.2.0" gem "async", "~>2.0" end diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 63f911699c..ba0b94b2aa 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})" diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index bc488b0f9d..75429d5dc9 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -57,53 +57,142 @@ 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 "#<#{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" + 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 - 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 + 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" + object_proxy = root_type.wrap(object, context) + object_proxy = schema.sync_lazy(object_proxy) + if object_proxy.nil? + @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| + @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, - selection_response, - final_response, - nil, + @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" + 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 + 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 + 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" + 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] + 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 + 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) + 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) + @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 + 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 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/lib/graphql/query.rb b/lib/graphql/query.rb index bf4813a406..200c520089 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? @@ -198,19 +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 = 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 - 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 @@ -236,6 +262,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_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 @@ -278,19 +316,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 `` @@ -357,17 +382,21 @@ 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 + def root_type + root_type_for_operation(selected_operation.operation_type) + end + def types @visibility_profile || warden.visibility_profile end @@ -400,23 +429,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..fc2c4a0d62 --- /dev/null +++ b/lib/graphql/query/partial.rb @@ -0,0 +1,156 @@ +# 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:`. + # + # 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 + @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.root_type + 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 = [] + 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 = @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? + 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 + + 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/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= "3.1.1" +if RUBY_VERSION >= "3.2.0" require "async" describe GraphQL::Dataloader::AsyncDataloader do class AsyncSchema < GraphQL::Schema diff --git a/spec/graphql/query/partial_spec.rb b/spec/graphql/query/partial_spec.rb new file mode 100644 index 0000000000..348ee8b5fd --- /dev/null +++ b/spec/graphql/query/partial_spec.rb @@ -0,0 +1,446 @@ +# 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: :__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 :farm_names, [String], fallback_value: Database::FARMS.each_value.map(&:name) + + 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 + + field :current_path, [String] + def current_path + context.current_path + 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) + 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") }, + { path: ["farms", 0], object: { name: "Kestrel Hollow", products: [:__MEAT__, "EGGS"]} }, + ]) + + assert_equal [ + { "data" => { "name" => "Bellair Farm" } }, + { "data" => { "name" => "Injected Farm" } }, + {"data" => {"name" => "Kestrel Hollow", "products" => ["MEAT", "EGGS"]} }, + ], 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"=>["farm1", "error"]}], results[0]["errors"] + refute results[1].key?("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"]) + 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 "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" }, + { 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 + str = <<-GRAPHQL + { + farm(id: 5) { neighboringFarm { name } } + farm(id: 5) { neighboringFarm { name2: name } } + } + GRAPHQL + 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(id: "BLAH") { + ... 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(id: \"BLAH\") { name products } }" + results = run_partials(str, [ + { path: ["farm", "name"], object: "Polyface" }, + { path: ["farm", "products"], object: [:__MEAT__] }, + { path: ["farm", "products"], object: -> { ["EGGS"] } }, + ]) + assert_equal ["Polyface", ["MEAT"], ["EGGS"]], results.map { |r| r["data"] } + + assert results[0].partial.leaf? + assert results[1].partial.leaf? + assert results[2].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 "runs scalars on abstract types" do + str = "{ + entity { + name + __typename + } + }" + + results = run_partials(str, [ + { path: ["entity", "name"], object: "Whisper Hill" }, + { path: ["entity", "__typename"], object: "Farm" }, + { path: ["entity", "name"], object: "Crozet Farmers Market" }, + ]) + + 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 + 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 + + it "uses a full path relative to the parent query" do + str = "{ q1: query { q2: query { query { currentPath } } } }" + results = run_partials(str, [ + { path: [], object: nil }, + { path: ["q1", "q2"], object: nil }, + { path: ["q1", "q2", "query"], object: nil }, + { path: ["q1", "q2", "query", "currentPath"], object: ["injected", "path"] }, + ]) + + assert_equal({"q1" => { "q2" => { "query" => { "currentPath" => ["q1", "q2", "query", "currentPath"] } } } }, results[0]["data"]) + assert_equal [], results[0].partial.path + assert_equal({"query" => {"currentPath" => ["q1", "q2", "query", "currentPath"]}}, results[1]["data"]) + assert_equal ["q1", "q2"], results[1].partial.path + assert_equal({ "currentPath" => ["q1", "q2", "query", "currentPath"] }, results[2]["data"]) + assert_equal ["q1", "q2", "query"], results[2].partial.path + assert_equal(["injected", "path"], results[3]["data"]) + assert_equal ["q1", "q2", "query", "currentPath"], results[3].partial.path + 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: "Notta Farm" }, + ]) + + assert_equal({ "updateFarm" => { "name" => "Brawndo Acres" } }, results[0]["data"]) + 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!") }, + { path: ["entity", "name"], object: -> { GraphQL::ExecutionError.new("Blorp!") } }, + ]) + + 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]) + assert_equal({ + "errors" => [{"message" => "Blorp!", "locations" => [{"line" => 3, "column" => 9}], "path" => ["entity", "name", "name"]}], + "data" => nil + }, results[2]) + end +end