From eb9a599156f0b6d8138ea274a20d0a4a81520de8 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 6 Jun 2026 22:40:15 -0400 Subject: [PATCH 1/3] planner fixes. --- Rakefile | 12 +- benchmark/run.rb | 279 +++++++++++++++++++++++++++++++ lib/graphql/stitching/planner.rb | 38 ++--- lib/graphql/stitching/version.rb | 2 +- 4 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 benchmark/run.rb diff --git a/Rakefile b/Rakefile index 5e81be7a..1e28f6a0 100644 --- a/Rakefile +++ b/Rakefile @@ -9,4 +9,14 @@ Rake::TestTask.new(:test) do |t, args| t.test_files = FileList['test/**/*_test.rb'] end -task :default => :test \ No newline at end of file +namespace :benchmark do + desc "Benchmark planner throughput" + task :planner do + ruby "benchmark/run.rb" + end +end + +desc "Run benchmarks" +task benchmark: "benchmark:planner" + +task :default => :test diff --git a/benchmark/run.rb b/benchmark/run.rb new file mode 100644 index 00000000..26bb6246 --- /dev/null +++ b/benchmark/run.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "bundler/setup" +require "graphql/stitching" + +STITCH_DEFINITION = "directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION\n" + +def compose_definitions(locations) + locations = locations.each_with_object({}) do |(location, schema_config), memo| + schema_config = STITCH_DEFINITION + schema_config if schema_config.include?("@stitch") + memo[location.to_s] = { schema: GraphQL::Schema.from_definition(schema_config) } + end + + GraphQL::Stitching::Composer.new.perform(locations) +end + +class PlannerBenchmark + Case = Struct.new(:name, :supergraph, :document, :variables, keyword_init: true) + + MINIMUM_SECONDS = Float(ENV.fetch("BENCHMARK_SECONDS", 2.0)) + WARMUP_ITERATIONS = Integer(ENV.fetch("BENCHMARK_WARMUP", 200)) + + def initialize(cases) + @cases = cases + end + + def run + width = @cases.map { |c| c.name.length }.max + + puts "GraphQL::Stitching::Planner" + puts "ruby #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})" + puts "graphql #{GraphQL::VERSION}" + puts "minimum #{MINIMUM_SECONDS}s per case" + puts + + @cases.each do |bench_case| + WARMUP_ITERATIONS.times { plan(bench_case) } + + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + iterations = 0 + elapsed = 0.0 + + loop do + plan(bench_case) + iterations += 1 + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + break if elapsed >= MINIMUM_SECONDS + end + + ips = iterations / elapsed + microseconds = 1_000_000.0 / ips + puts "%-#{width}s %10.1f i/s %10.2f us/i" % [bench_case.name, ips, microseconds] + end + end + + private + + def plan(bench_case) + GraphQL::Stitching::Request.new( + bench_case.supergraph, + bench_case.document, + variables: bench_case.variables, + ).plan + end +end + +root_supergraph = compose_definitions({ + "widgets" => %| + input MakeWidgetInput { name: String child: MakeWidgetInput } + type Widget { id: ID! name(lang: String): String size: Int color: String } + type Query { widget(id: ID!): Widget widgets(ids: [ID!]): [Widget!]! } + type Mutation { makeWidget(input: MakeWidgetInput!): Widget } + |, + "sprockets" => %| + input MakeSprocketInput { name: String child: MakeSprocketInput } + type Sprocket { id: ID! name(lang: String): String size: Int color: String } + type Query { sprocket(id: ID!): Sprocket sprockets(ids: [ID!]): [Sprocket!]! } + type Mutation { makeSprocket(input: MakeSprocketInput!): Sprocket } + |, +}) + +delegation_supergraph = compose_definitions({ + "alpha" => %| + type Widget { id: ID! a: String! b: String! } + type Query { alpha(id: ID!): Widget @stitch(key: "id") } + |, + "bravo" => %| + type Widget { id: ID! a: String! b: String! } + type Query { bravo(id: ID!): Widget @stitch(key: "id") } + |, + "charlie" => %| + type Widget { id: ID! c: String! d: String! } + type Query { charlie(id: ID!): Widget @stitch(key: "id") } + |, + "delta" => %| + type Widget { id: ID! d: String! e: String! } + type Query { delta(id: ID!): Widget @stitch(key: "id") } + |, + "echo" => %| + type Widget { id: ID! d: String! e: String! f: String! } + type Query { echo(id: ID!): Widget @stitch(key: "id") } + |, + "foxtrot" => %| + type Widget { id: ID! d: String! f: String! } + type Query { foxtrot(id: ID!): Widget @stitch(key: "id") } + |, +}) + +resolver_supergraph = compose_definitions({ + "storefronts" => %| + type Storefront { + id: ID! + name: String! + products: [Product]! + } + type Product { + upc: ID! + } + type Query { + storefront(id: ID!): Storefront + } + |, + "products" => %| + type Product { + upc: ID! + name: String! + price: Float! + manufacturer: Manufacturer! + } + type Manufacturer { + id: ID! + name: String! + products: [Product]! + } + type Query { + product(upc: ID!): Product @stitch(key: "upc") + productsManufacturer(id: ID!): Manufacturer @stitch(key: "id") + } + |, + "manufacturers" => %| + type Manufacturer { + id: ID! + name: String! + address: String! + } + type Query { + manufacturer(id: ID!): Manufacturer @stitch(key: "id") + } + |, +}) + +abstract_supergraph = compose_definitions({ + "a" => %| + interface Buyable { + id: ID! + name: String! + price: Float! + } + type Product implements Buyable { + id: ID! + name: String! + price: Float! + } + type Query { + products(ids: [ID!]!): [Product]! @stitch(key: "id") + } + |, + "b" => %| + interface Buyable { id: ID! } + type Product implements Buyable { id: ID! } + type Bundle implements Buyable { + id: ID! + name: String! + price: Float! + products: [Product]! + } + type Query { + buyable(id: ID!): Buyable @stitch(key: "id") + } + |, +}) + +cases = [ + PlannerBenchmark::Case.new( + name: "root groups", + supergraph: root_supergraph, + document: %| + query($wid: ID!, $sid: ID!, $ids: [ID!], $lang: String) { + a: widget(id: $wid) { id name(lang: $lang) size color } + b: sprocket(id: $sid) { id name(lang: $lang) size color } + c: widgets(ids: $ids) { id name size color } + d: sprockets(ids: $ids) { id name size color } + } + |, + variables: { "wid" => "1", "sid" => "2", "ids" => ["1", "2"], "lang" => "en" }, + ), + PlannerBenchmark::Case.new( + name: "variables", + supergraph: root_supergraph, + document: %| + mutation($wname1: String!, $wname2: String!, $sname1: String!, $sname2: String!, $lang: String) { + makeWidget(input: { name: $wname1, child: { name: $wname2 } }) { id name(lang: $lang) } + makeSprocket(input: { name: $sname1, child: { name: $sname2 } }) { id name(lang: $lang) } + } + |, + variables: { + "wname1" => "a", + "wname2" => "b", + "sname1" => "c", + "sname2" => "d", + "lang" => "en", + }, + ), + PlannerBenchmark::Case.new( + name: "delegation", + supergraph: delegation_supergraph, + document: %|query { alpha(id: "1") { a b c d e f } }|, + ), + PlannerBenchmark::Case.new( + name: "nested resolvers", + supergraph: resolver_supergraph, + document: %| + query { + storefront(id: "1") { + name + products { + name + manufacturer { + address + products { + name + } + } + } + } + } + |, + ), + PlannerBenchmark::Case.new( + name: "fragments", + supergraph: resolver_supergraph, + document: %| + query { + storefront(id: "1") { + products { + ...ProductAttrs + manufacturer { + ...ManufacturerAttrs + } + } + } + } + fragment ProductAttrs on Product { name price } + fragment ManufacturerAttrs on Manufacturer { + name + address + products { ...ProductAttrs } + } + |, + ), + PlannerBenchmark::Case.new( + name: "abstracts", + supergraph: abstract_supergraph, + document: %| + query { + buyable(id: "1") { + ... { + ...BuyableAttrs + } + } + } + fragment BuyableAttrs on Buyable { id name price } + |, + ), +] + +PlannerBenchmark.new(cases).run diff --git a/lib/graphql/stitching/planner.rb b/lib/graphql/stitching/planner.rb index da1f5cb3..a6c05b21 100644 --- a/lib/graphql/stitching/planner.rb +++ b/lib/graphql/stitching/planner.rb @@ -240,7 +240,8 @@ def extract_locale_selections( end # B.3) Collect all variable definitions used within the filtered selection. - extract_node_variables(node, locale_variables) + extract_node_argument_variables(node, locale_variables) + extract_node_directive_variables(node, locale_variables) schema_fields = @supergraph.memoized_schema_fields(parent_type.graphql_name) field_type = schema_fields[node.name].type.unwrap @@ -276,7 +277,7 @@ def extract_locale_selections( extract_node_directive_variables(fragment, locale_variables) requires_typename = true fragment_type = @supergraph.memoized_schema_types[fragment.type.name] - directives = [*fragment.directives, *node.directives] + directives = fragment.directives.empty? && node.directives.empty? ? EMPTY_ARRAY : fragment.directives + node.directives is_same_scope = fragment_type == parent_type && directives.empty? selection_set = is_same_scope ? locale_selections : [] extract_locale_selections(current_location, fragment_type, parent_index, fragment.selections, path, locale_variables, selection_set) @@ -361,27 +362,24 @@ def expand_interface_selections(current_location, parent_type, input_selections) # B.3) Collect all variable definitions used within the filtered selection. # These specify which request variables to pass along with each step. - def extract_node_variables(node_with_args, variable_definitions) - node_with_args.arguments.each do |argument| - case argument.value - when GraphQL::Language::Nodes::InputObject - extract_node_variables(argument.value, variable_definitions) - when GraphQL::Language::Nodes::VariableIdentifier - variable_definitions[argument.value.name] ||= @request.variable_definitions[argument.value.name] - when Array - extract_value_variables(argument.value, variable_definitions) - end - end + def extract_node_argument_variables(node, variable_definitions) + arguments = node.arguments + return if arguments.empty? - if node_with_args.respond_to?(:directives) - extract_node_directive_variables(node_with_args, variable_definitions) - end + arguments.each { |argument| extract_value_variables(argument.value, variable_definitions) } + end + + def extract_node_directive_variables(node, variable_definitions) + directives = node.directives + return if directives.empty? + + directives.each { |directive| extract_node_argument_variables(directive, variable_definitions) } end def extract_value_variables(value, variable_definitions) case value when GraphQL::Language::Nodes::InputObject - extract_node_variables(value, variable_definitions) + extract_node_argument_variables(value, variable_definitions) when GraphQL::Language::Nodes::VariableIdentifier variable_definitions[value.name] ||= @request.variable_definitions[value.name] when Array @@ -389,12 +387,6 @@ def extract_value_variables(value, variable_definitions) end end - def extract_node_directive_variables(node_with_directives, variable_definitions) - node_with_directives.directives.each do |directive| - extract_node_variables(directive, variable_definitions) - end - end - # C) Delegate adjoining selections to new entrypoint locations. def delegate_remote_selections(parent_type, remote_selections) possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name] diff --git a/lib/graphql/stitching/version.rb b/lib/graphql/stitching/version.rb index 1e5e4d9f..81104d6c 100644 --- a/lib/graphql/stitching/version.rb +++ b/lib/graphql/stitching/version.rb @@ -2,6 +2,6 @@ module GraphQL module Stitching - VERSION = "1.7.3" + VERSION = "1.7.4" end end From 26b6ab87e8eccc26414c06c08aee042a440cf8c2 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sun, 7 Jun 2026 20:51:35 -0400 Subject: [PATCH 2/3] shaper fixes and optimizations. --- lib/graphql/stitching/executor/shaper.rb | 28 ++++---- .../executor/shaper_null_bubbling_test.rb | 67 +++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/lib/graphql/stitching/executor/shaper.rb b/lib/graphql/stitching/executor/shaper.rb index 7f27ff66..e3e80052 100644 --- a/lib/graphql/stitching/executor/shaper.rb +++ b/lib/graphql/stitching/executor/shaper.rb @@ -11,6 +11,7 @@ def initialize(request) @request = request @supergraph = request.supergraph @root_type = nil + @possible_type_names_by_type = nil end def perform!(raw) @@ -24,6 +25,8 @@ def resolve_object_scope(raw_object, parent_type, selections, typename = nil) return nil if raw_object.nil? typename ||= raw_object[TypeResolver::TYPENAME_EXPORT_NODE.alias] + typename ||= parent_type.graphql_name unless parent_type.kind.abstract? + raw_object.reject! { |key, _v| TypeResolver.export_key?(key) } selections.each do |node| @@ -84,36 +87,37 @@ def resolve_list_scope(raw_list, current_node_type, selections) next_node_type = Util.unwrap_non_null(current_node_type).of_type named_type = next_node_type.unwrap - contains_null = false + + if Util.is_leaf_type?(named_type) + return nil if next_node_type.non_null? && raw_list.include?(nil) + + return raw_list + end resolved_list = raw_list.map! do |raw_list_element| result = if next_node_type.list? resolve_list_scope(raw_list_element, next_node_type, selections) - elsif Util.is_leaf_type?(named_type) - raw_list_element else resolve_object_scope(raw_list_element, named_type, selections) end - if result.nil? - contains_null = true - return nil if current_node_type.non_null? - end + return nil if result.nil? && next_node_type.non_null? result end - return nil if contains_null && next_node_type.non_null? - resolved_list end def typename_in_type?(typename, type) return true if type.graphql_name == typename + return false unless typename && type.kind.abstract? - type.kind.abstract? && @request.query.possible_types(type).any? do |t| - t.graphql_name == typename - end + possible_type_names(type).include?(typename) + end + + def possible_type_names(type) + (@possible_type_names_by_type ||= {})[type.graphql_name] ||= @request.query.possible_types(type).map(&:graphql_name) end end end diff --git a/test/graphql/stitching/executor/shaper_null_bubbling_test.rb b/test/graphql/stitching/executor/shaper_null_bubbling_test.rb index 54a31815..8645e3da 100644 --- a/test/graphql/stitching/executor/shaper_null_bubbling_test.rb +++ b/test/graphql/stitching/executor/shaper_null_bubbling_test.rb @@ -137,6 +137,22 @@ def test_bubbles_null_for_required_lists assert_nil GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) end + def test_bubbles_null_for_required_scalar_list_elements + schema_sdl = "type Query { test: [String!] }" + request = GraphQL::Stitching::Request.new( + supergraph_from_schema(schema_sdl), + %|{ test }|, + ) + raw = { + "test" => ["yes", nil] + } + expected = { + "test" => nil + } + + assert_equal expected, GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) + end + def test_basic_nested_list_structure schema_sdl = "type Test { req: String! opt: String } type Query { test: [[Test]] }" request = GraphQL::Stitching::Request.new( @@ -159,6 +175,28 @@ def test_basic_nested_list_structure assert_equal expected, GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) end + def test_preserves_nullable_scalar_elements_in_required_nested_lists + schema_sdl = "type Query { test: [[String]!] }" + request = GraphQL::Stitching::Request.new( + supergraph_from_schema(schema_sdl), + %|{ test }|, + ) + raw = { + "test" => [ + ["yes", nil], + ["also yes"], + ] + } + expected = { + "test" => [ + ["yes", nil], + ["also yes"], + ] + } + + assert_equal expected, GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) + end + def test_bubbles_null_for_nested_list_elements schema_sdl = "type Test { req: String! opt: String } type Query { test: [[Test]] }" request = GraphQL::Stitching::Request.new( @@ -238,6 +276,35 @@ def test_bubbles_null_through_nested_required_list_scopes assert_nil GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) end + def test_bubble_through_concrete_inline_fragment_without_exported_typename + schema_sdl = "type Test { req: String! opt: String } type Query { test: Test }" + query = %| + query { + test { + ... on Test { + req + opt + } + } + } + | + request = GraphQL::Stitching::Request.new( + supergraph_from_schema(schema_sdl), + query, + ) + raw = { + "test" => { + "req" => nil, + "opt" => "yes" + } + } + expected = { + "test" => nil + } + + assert_equal expected, GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) + end + def test_bubble_through_inline_fragment schema_sdl = "type Test { req: String! opt: String } type Query { test: Test }" query = %| From 9bb59f367304eba68f5e8a56833890717b56f237 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sun, 7 Jun 2026 21:48:35 -0400 Subject: [PATCH 3/3] executor fixes. --- lib/graphql/stitching/executor.rb | 1 + lib/graphql/stitching/executor/path_access.rb | 81 +++++++++++++ lib/graphql/stitching/executor/root_source.rb | 77 ++++++++----- .../executor/type_resolver_source.rb | 108 +++++++----------- lib/graphql/stitching/version.rb | 2 +- .../executor/resolver_source_test.rb | 106 ++++++++++++++++- .../stitching/executor/root_source_test.rb | 95 +++++++++++++++ 7 files changed, 367 insertions(+), 103 deletions(-) create mode 100644 lib/graphql/stitching/executor/path_access.rb diff --git a/lib/graphql/stitching/executor.rb b/lib/graphql/stitching/executor.rb index 17f3131a..aaddf9ac 100644 --- a/lib/graphql/stitching/executor.rb +++ b/lib/graphql/stitching/executor.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "json" +require_relative "executor/path_access" require_relative "executor/root_source" require_relative "executor/type_resolver_source" require_relative "executor/shaper" diff --git a/lib/graphql/stitching/executor/path_access.rb b/lib/graphql/stitching/executor/path_access.rb new file mode 100644 index 00000000..a40e117c --- /dev/null +++ b/lib/graphql/stitching/executor/path_access.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module GraphQL::Stitching + class Executor + # Utilities for traversing aggregate executor data along planned paths. + module PathAccess + private + + def path_objects(root, path) + objects = [] + each_path_object(root, path) { |object| objects << object } + objects + end + + def each_path_object(scope, path, &block) + return enum_for(:each_path_object, scope, path) unless block + return if scope.nil? + + if path.empty? + each_leaf_object(scope, &block) + elsif scope.is_a?(Array) + scope.each { |element| each_path_object(element, path, &block) } + elsif scope.respond_to?(:[]) + path_segment = path.first + each_path_object(scope[path_segment], path[1..-1], &block) + end + end + + def each_leaf_object(scope, &block) + return if scope.nil? + + if scope.is_a?(Array) + scope.each { |element| each_leaf_object(element, &block) } + else + yield(scope) + end + end + + def path_entries(root, path) + entries = [] + each_path_entry(root, path) { |object, response_path| entries << [object, response_path] } + entries + end + + def each_path_entry(scope, path, response_path = [], &block) + return enum_for(:each_path_entry, scope, path, response_path) unless block + return if scope.nil? + + if path.empty? + each_leaf_entry(scope, response_path, &block) + elsif scope.is_a?(Array) + scope.each_with_index do |element, index| + each_path_entry(element, path, [*response_path, index], &block) + end + elsif scope.respond_to?(:[]) + path_segment = path.first + each_path_entry(scope[path_segment], path[1..-1], [*response_path, path_segment], &block) + end + end + + def each_leaf_entry(scope, response_path, &block) + return if scope.nil? + + if scope.is_a?(Array) + scope.each_with_index do |element, index| + each_leaf_entry(element, [*response_path, index], &block) + end + else + yield(scope, response_path) + end + end + + def sanitized_error(error, path: nil) + error.dup.tap do |formatted| + formatted.delete("locations") + formatted["path"] = path if path + end + end + end + end +end diff --git a/lib/graphql/stitching/executor/root_source.rb b/lib/graphql/stitching/executor/root_source.rb index 0f35ca3e..53a6e4ee 100644 --- a/lib/graphql/stitching/executor/root_source.rb +++ b/lib/graphql/stitching/executor/root_source.rb @@ -3,42 +3,49 @@ module GraphQL::Stitching class Executor class RootSource < GraphQL::Dataloader::Source + include PathAccess + def initialize(executor, location) @executor = executor @location = location end def fetch(ops) - op = ops.first # There should only ever be one per location at a time - - query_document = build_document( - op, - @executor.request.operation_name, - @executor.request.operation_directives, - ) - query_variables = @executor.request.variables.slice(*op.variables.each_key) - result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request) - @executor.query_count += 1 - - if result["data"] - unless op.path.empty? - # Nested root scopes must expand their pathed origin set - origin_set = op.path.reduce([@executor.data]) do |set, ns| - set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!) + ops.map do |op| + origin_set = op.path.empty? ? [@executor.data] : path_objects(@executor.data, op.path) + + query_document = build_document( + op, + @executor.request.operation_name, + @executor.request.operation_directives, + ) + query_variables = @executor.request.variables.slice(*op.variables.each_key) + result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request) + @executor.query_count += 1 + + errors = result["errors"] + origin_entries = nil + + if errors && !errors.empty? + origin_entries = op.path.empty? ? [[@executor.data, []]] : path_entries(@executor.data, op.path) + end + + if result["data"] + if op.path.empty? + # Actual root scopes merge directly into results data + @executor.data.merge!(result["data"]) + elsif !origin_set.empty? + # Nested root scopes merge the same root payload into each pathed origin + origin_set.each { |origin_obj| origin_obj.merge!(result["data"]) } end + end - origin_set.each { _1.merge!(result["data"]) } - else - # Actual root scopes merge directly into results data - @executor.data.merge!(result["data"]) + if errors && !errors.empty? + @executor.errors.concat(format_errors(errors, origin_entries, op.path)) end - end - if result["errors"]&.any? - @executor.errors.concat(format_errors!(result["errors"], op.path)) + op.step end - - ops.map(&:step) end # Builds root source documents @@ -69,13 +76,21 @@ def build_document(op, operation_name = nil, operation_directives = nil) end # Format response errors without a document location (because it won't match the request doc), - # and prepend any insertion path for the scope into error paths. - def format_errors!(errors, path) - errors.each do |err| - err.delete("locations") - err["path"].unshift(*path) if err["path"] && !path.empty? + # and prepend all concrete insertion paths for nested scopes into error paths. + def format_errors(errors, origin_entries, fallback_path = []) + errors.flat_map do |err| + path = err["path"] + + if path && !origin_entries.empty? + origin_entries.map do |_origin_obj, origin_path| + sanitized_error(err, path: origin_path + path) + end + elsif path && !fallback_path.empty? + sanitized_error(err, path: fallback_path + path) + else + sanitized_error(err) + end end - errors end end end diff --git a/lib/graphql/stitching/executor/type_resolver_source.rb b/lib/graphql/stitching/executor/type_resolver_source.rb index dc1498d7..7bd50604 100644 --- a/lib/graphql/stitching/executor/type_resolver_source.rb +++ b/lib/graphql/stitching/executor/type_resolver_source.rb @@ -3,40 +3,39 @@ module GraphQL::Stitching class Executor class TypeResolverSource < GraphQL::Dataloader::Source + include PathAccess + def initialize(executor, location) @executor = executor @location = location - @variables = {} end def fetch(ops) origin_sets_by_operation = ops.each_with_object({}.compare_by_identity) do |op, memo| - origin_set = op.path.reduce([@executor.data]) do |set, path_segment| - set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!) - end + origin_set = path_objects(@executor.data, op.path) if op.if_type # operations planned around unused fragment conditions should not trigger requests - origin_set.select! { _1[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type } + origin_set.select! { |origin_obj| origin_obj[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type } end memo[op] = origin_set unless origin_set.empty? end unless origin_sets_by_operation.empty? - query_document, variable_names = build_document( + query_document, variable_names, generated_variables = build_document( origin_sets_by_operation, @executor.request.operation_name, @executor.request.operation_directives, ) - variables = @variables.merge!(@executor.request.variables.slice(*variable_names)) + variables = generated_variables.merge(@executor.request.variables.slice(*variable_names)) raw_result = @executor.request.supergraph.execute_at_location(@location, query_document, variables, @executor.request) @executor.query_count += 1 merge_results!(origin_sets_by_operation, raw_result.dig("data")) errors = raw_result.dig("errors") - @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any? + @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors && !errors.empty? end ops.map { origin_sets_by_operation[_1] ? _1.step : nil } @@ -51,6 +50,7 @@ def fetch(ops) # }" def build_document(origin_sets_by_operation, operation_name = nil, operation_directives = nil) variable_defs = {} + generated_variables = {} fields_buffer = String.new origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index| @@ -65,7 +65,7 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir fields_buffer << "," unless i.zero? if arg.key? variable_name = "_#{batch_index}_key_#{i}".freeze - @variables[variable_name] = origin_set.map { arg.build(_1) } + generated_variables[variable_name] = origin_set.map { arg.build(_1) } variable_defs[variable_name] = arg.to_type_signature fields_buffer << arg.name << ":$" << variable_name else @@ -83,7 +83,7 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir fields_buffer << "," unless i.zero? if arg.key? variable_name = "_#{batch_index}_#{index}_key_#{i}".freeze - @variables[variable_name] = arg.build(origin_obj) + generated_variables[variable_name] = arg.build(origin_obj) variable_defs[variable_name] = arg.to_type_signature fields_buffer << arg.name << ":$" << variable_name else @@ -120,9 +120,11 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir doc_buffer << "{ " << fields_buffer << " }" - return doc_buffer, variable_defs.keys.tap do |names| - names.reject! { @variables.key?(_1) } + variable_names = variable_defs.keys.tap do |names| + names.reject! { generated_variables.key?(_1) } end + + return doc_buffer, variable_names, generated_variables end def merge_results!(origin_sets_by_operation, raw_result) @@ -145,85 +147,57 @@ def merge_results!(origin_sets_by_operation, raw_result) end # https://spec.graphql.org/June2018/#sec-Errors - def extract_errors!(origin_sets_by_operation, errors) + def extract_errors!(origin_sets_by_operation, errors, origin_paths_by_operation = nil) ops = origin_sets_by_operation.keys origin_sets = origin_sets_by_operation.values - pathed_errors_by_op_index_and_object_id = Hash.new { |h, k| h[k] = {} } + origin_paths_by_operation ||= origin_sets_by_operation.each_with_object({}.compare_by_identity) do |(op, origin_set), memo| + memo[op] = paths_for_origin_set(op, origin_set) + end - errors_result = errors.each_with_object([]) do |err, memo| - err.delete("locations") + errors.each_with_object([]) do |err, memo| path = err["path"] if path && path.length > 0 result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s) if result_alias - path = err["path"] = path[1..-1] + path = path[1..-1] + batch_index = result_alias[1].to_i - origin_obj = if result_alias[2] - origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i) - elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s) - origin_sets.dig(result_alias[1].to_i, path.shift.to_i) + origin_index = if result_alias[2] + result_alias[2].to_i + elsif path[0].is_a?(Integer) || /\A\d+\z/.match?(path[0].to_s) + path.shift.to_i end + origin_obj = origin_sets.dig(batch_index, origin_index) if origin_index if origin_obj - pathed_errors_by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] - by_object_id = pathed_errors_by_op_index[origin_obj.object_id] ||= [] - by_object_id << err - next + op = ops[batch_index] + object_path = origin_paths_by_operation.dig(op, origin_index) + + if object_path + memo << sanitized_error(err, path: object_path + path) + next + end end + + memo << sanitized_error(err, path: path) + next end end - memo << err - end - - unless pathed_errors_by_op_index_and_object_id.empty? - pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id| - repath_errors!(pathed_errors_by_object_id, ops[op_index].path) - errors_result.push(*pathed_errors_by_object_id.each_value) - end + memo << sanitized_error(err) end - - errors_result.tap(&:flatten!) end private - # traverse forward through origin data, expanding arrays to follow all paths - # any errors found for an origin object_id have their path prefixed by the object path - def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data) - current_path.push(forward_path.shift) - scope = root[current_path.last] - - if !forward_path.empty? && scope.is_a?(Array) - scope.each_with_index do |element, index| - inner_elements = element.is_a?(Array) ? element.flatten : [element] - inner_elements.each do |inner_element| - current_path << index - repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element) - current_path.pop - end - end - - elsif !forward_path.empty? - repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope) - - elsif scope.is_a?(Array) - scope.each_with_index do |element, index| - inner_elements = element.is_a?(Array) ? element.flatten : [element] - inner_elements.each do |inner_element| - errors = pathed_errors_by_object_id[inner_element.object_id] - errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors - end - end - - else - errors = pathed_errors_by_object_id[scope.object_id] - errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors + def paths_for_origin_set(op, origin_set) + paths_by_object_id = path_entries(@executor.data, op.path).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(object, path), memo| + memo[object.object_id] << path end - forward_path.unshift(current_path.pop) + origin_set.map { |origin_obj| paths_by_object_id[origin_obj.object_id].shift } end end end diff --git a/lib/graphql/stitching/version.rb b/lib/graphql/stitching/version.rb index 81104d6c..fced2abe 100644 --- a/lib/graphql/stitching/version.rb +++ b/lib/graphql/stitching/version.rb @@ -2,6 +2,6 @@ module GraphQL module Stitching - VERSION = "1.7.4" + VERSION = "1.8.0" end end diff --git a/test/graphql/stitching/executor/resolver_source_test.rb b/test/graphql/stitching/executor/resolver_source_test.rb index 96eb4f9c..4261bf1b 100644 --- a/test/graphql/stitching/executor/resolver_source_test.rb +++ b/test/graphql/stitching/executor/resolver_source_test.rb @@ -109,6 +109,36 @@ def test_builds_document_with_operation_directives assert_equal ["lang", "currency"], variable_names end + def test_generated_variables_do_not_leak_between_documents + query_document, variable_names, generated_variables = @source.build_document({ + @op1 => [{ "_export_id" => "7" }, { "_export_id" => "8" }], + }) + + expected_source = %| + query($lang:String!,$_0_key_0:[ID!]!){ + _0_result: storefronts(ids:$_0_key_0) { name(lang:$lang) } + } + | + + assert_equal squish_string(expected_source), query_document + assert_equal ["lang"], variable_names + assert_equal({ "_0_key_0" => ["7", "8"] }, generated_variables) + + query_document, variable_names, generated_variables = @source.build_document({ + @op2 => [{ "_export_upc" => "abc" }], + }) + + expected_source = %| + query($currency:Currency!,$_0_0_key_0:ID!){ + _0_0_result: product(upc:$_0_0_key_0) { price(currency:$currency) } + } + | + + assert_equal squish_string(expected_source), query_document + assert_equal ["currency"], variable_names + assert_equal({ "_0_0_key_0" => "abc" }, generated_variables) + end + def test_merges_results_for_operation_batch @source.merge_results!(@origin_sets_by_operation, { "_0_result" => [{ "name" => "fizz" }, { "name" => "bang" }], @@ -159,10 +189,78 @@ def test_extracts_pathed_errors_for_operation_batch end end + def test_extracts_pathed_errors_with_null_siblings + data = { + "storefronts" => [ + nil, + { "_export_id" => "8", "product" => { "_export_upc" => "xyz" } }, + ] + } + + with_mock_source(data) do |source, _origin_sets_by_operation| + result = source.extract_errors!({ + @op2 => [data["storefronts"][1]["product"]], + }, [ + { "path" => ["_0_0_result"], "message" => "itemized error" }, + ]) + + expected = [ + { "path" => ["storefronts", 1, "product"], "message" => "itemized error" }, + ] + + assert_equal expected, result + end + end + + def test_builds_document_from_nested_list_origin_paths + op = GraphQL::Stitching::Plan::Op.new( + step: 4, + after: 1, + location: "products", + operation_type: "query", + path: ["storefronts"], + selections: "{ price(currency:$currency) }", + variables: { "currency" => "Currency!" }, + resolver: @resolver2.version, + ) + + supergraph = GraphQL::Stitching::Supergraph.new( + schema: Class.new(GraphQL::Schema), + resolvers: { + "Product" => [@resolver2], + }, + executables: { + "products" => -> (_request, _source, _variables) { + { + "data" => { + "_0_0_result" => { "price" => 1.99 }, + "_0_1_result" => { "price" => 10.99 }, + }, + } + }, + }, + ) + request = GraphQL::Stitching::Request.new(supergraph, "{ test }") + executor = GraphQL::Stitching::Executor.new(request) + executor.data.merge!( + "storefronts" => [ + [{ "_export_upc" => "abc" }], + nil, + [{ "_export_upc" => "xyz" }], + ], + ) + + source = GraphQL::Stitching::Executor::TypeResolverSource.new(executor, "products") + source.fetch([op]) + + assert_equal 1.99, executor.data.dig("storefronts", 0, 0, "price") + assert_equal 10.99, executor.data.dig("storefronts", 2, 0, "price") + end + private - def with_mock_source - data = { + def with_mock_source(data = nil) + data ||= { "storefronts" => [ { "_export_id" => "7", "product" => { "_export_upc" => "abc" } }, { "_export_id" => "8", "product" => { "_export_upc" => "xyz" } } @@ -178,8 +276,8 @@ def with_mock_source source = GraphQL::Stitching::Executor::TypeResolverSource.new(mock, "products") origin_sets_by_operation = { - @op1 => data["storefronts"], - @op2 => data["storefronts"].map { _1["product"] }, + @op1 => data["storefronts"].compact, + @op2 => data["storefronts"].filter_map { _1 && _1["product"] }, } yield(source, origin_sets_by_operation) diff --git a/test/graphql/stitching/executor/root_source_test.rb b/test/graphql/stitching/executor/root_source_test.rb index 1b2514b4..07b19ebe 100644 --- a/test/graphql/stitching/executor/root_source_test.rb +++ b/test/graphql/stitching/executor/root_source_test.rb @@ -54,4 +54,99 @@ def test_builds_document_with_operation_directives assert_equal squish_string(expected), source_document end + + def test_fetches_all_batched_root_operations + op1 = GraphQL::Stitching::Plan::Op.new( + step: 2, + after: 1, + location: "products", + operation_type: "query", + path: ["nodeA"], + selections: "{ title }", + variables: {}, + resolver: nil, + ) + op2 = GraphQL::Stitching::Plan::Op.new( + step: 3, + after: 1, + location: "products", + operation_type: "query", + path: ["nodeB", "nested"], + selections: "{ price }", + variables: {}, + resolver: nil, + ) + + executor, calls = executor_with_root_results([ + { "title" => "Hat" }, + { "price" => 19.99 }, + ]) + executor.data.merge!( + "nodeA" => {}, + "nodeB" => { "nested" => {} }, + ) + + source = GraphQL::Stitching::Executor::RootSource.new(executor, "products") + result = source.fetch([op1, op2]) + + assert_equal [2, 3], result + assert_equal 2, calls.length + assert_equal({ "title" => "Hat" }, executor.data["nodeA"]) + assert_equal({ "price" => 19.99 }, executor.data.dig("nodeB", "nested")) + end + + def test_repaths_nested_root_errors_for_each_list_element + op = GraphQL::Stitching::Plan::Op.new( + step: 2, + after: 1, + location: "products", + operation_type: "query", + path: ["items", "query"], + selections: "{ errorB }", + variables: {}, + resolver: nil, + ) + + executor, = executor_with_root_results([ + { "errors" => [{ "message" => "b", "path" => ["errorB"], "locations" => [{ "line" => 1 }] }] }, + ]) + executor.data.merge!( + "items" => [ + { "query" => {} }, + nil, + { "query" => {} }, + ], + ) + + source = GraphQL::Stitching::Executor::RootSource.new(executor, "products") + result = source.fetch([op]) + + expected_errors = [ + { "message" => "b", "path" => ["items", 0, "query", "errorB"] }, + { "message" => "b", "path" => ["items", 2, "query", "errorB"] }, + ] + + assert_equal [2], result + assert_equal expected_errors, executor.errors + end + + private + + def executor_with_root_results(results) + calls = [] + supergraph = GraphQL::Stitching::Supergraph.new( + schema: Class.new(GraphQL::Schema), + executables: { + "products" => -> (_request, source, variables) { + calls << { source: source, variables: variables } + result = results.shift + result.key?("data") || result.key?("errors") ? result : { "data" => result } + }, + }, + ) + request = GraphQL::Stitching::Request.new(supergraph, "{}") + executor = GraphQL::Stitching::Executor.new(request) + + [executor, calls] + end end