graphql-stitching 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65951348a6c34feff379cdbfc7483380dce295dbd86cc24870eee264de079a46
4
- data.tar.gz: c16673030f3be9728c986b8e4fe5e8084ca7a4e0240ac7e495da26261f1f0a3a
3
+ metadata.gz: 66715354401a5907a302fcecc6e5a285344e31357060f093f5804e6c27dab1d2
4
+ data.tar.gz: c51d4046ad4c6101edec196d97b7e44f083640ad739c81f9f27612067646c6fb
5
5
  SHA512:
6
- metadata.gz: 255495cde4ac822edc3b93ac67af6eb3b49ab657adedebd2870058468d6b49d241fca5e01c494fd3f345e134b21755e1a3f3ce2d2d43bad6e078b1833f47768c
7
- data.tar.gz: ac375cfe4ac73d9b1ce732180efada3d56aab8fa6e5cca75b40e0a6a8d133a45e67637090b9e49a76f9b2b20a81de9014bde03f88f03cdf277f85b5aa435504a
6
+ metadata.gz: 641b5c2903c1d07b00d794ac445aadc96bb7be640ef0e5b2607842e8c5d296eebfd39ddde3e75a97978e2533994a0c9d0b587c68cfd76c8d2c5cde4fb0dc302a
7
+ data.tar.gz: 916a94f76a1d453cf24ea7e382fe3b1b41184390720dd55c5b0ef1ffe32306f40ee33de73574e7672a0a895461e049b9204ef3fbd1e907a2570f29e463b1c1c1
data/Rakefile CHANGED
@@ -9,4 +9,14 @@ Rake::TestTask.new(:test) do |t, args|
9
9
  t.test_files = FileList['test/**/*_test.rb']
10
10
  end
11
11
 
12
- task :default => :test
12
+ namespace :benchmark do
13
+ desc "Benchmark planner throughput"
14
+ task :planner do
15
+ ruby "benchmark/run.rb"
16
+ end
17
+ end
18
+
19
+ desc "Run benchmarks"
20
+ task benchmark: "benchmark:planner"
21
+
22
+ task :default => :test
data/benchmark/run.rb ADDED
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "bundler/setup"
6
+ require "graphql/stitching"
7
+
8
+ STITCH_DEFINITION = "directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION\n"
9
+
10
+ def compose_definitions(locations)
11
+ locations = locations.each_with_object({}) do |(location, schema_config), memo|
12
+ schema_config = STITCH_DEFINITION + schema_config if schema_config.include?("@stitch")
13
+ memo[location.to_s] = { schema: GraphQL::Schema.from_definition(schema_config) }
14
+ end
15
+
16
+ GraphQL::Stitching::Composer.new.perform(locations)
17
+ end
18
+
19
+ class PlannerBenchmark
20
+ Case = Struct.new(:name, :supergraph, :document, :variables, keyword_init: true)
21
+
22
+ MINIMUM_SECONDS = Float(ENV.fetch("BENCHMARK_SECONDS", 2.0))
23
+ WARMUP_ITERATIONS = Integer(ENV.fetch("BENCHMARK_WARMUP", 200))
24
+
25
+ def initialize(cases)
26
+ @cases = cases
27
+ end
28
+
29
+ def run
30
+ width = @cases.map { |c| c.name.length }.max
31
+
32
+ puts "GraphQL::Stitching::Planner"
33
+ puts "ruby #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})"
34
+ puts "graphql #{GraphQL::VERSION}"
35
+ puts "minimum #{MINIMUM_SECONDS}s per case"
36
+ puts
37
+
38
+ @cases.each do |bench_case|
39
+ WARMUP_ITERATIONS.times { plan(bench_case) }
40
+
41
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ iterations = 0
43
+ elapsed = 0.0
44
+
45
+ loop do
46
+ plan(bench_case)
47
+ iterations += 1
48
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
49
+ break if elapsed >= MINIMUM_SECONDS
50
+ end
51
+
52
+ ips = iterations / elapsed
53
+ microseconds = 1_000_000.0 / ips
54
+ puts "%-#{width}s %10.1f i/s %10.2f us/i" % [bench_case.name, ips, microseconds]
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def plan(bench_case)
61
+ GraphQL::Stitching::Request.new(
62
+ bench_case.supergraph,
63
+ bench_case.document,
64
+ variables: bench_case.variables,
65
+ ).plan
66
+ end
67
+ end
68
+
69
+ root_supergraph = compose_definitions({
70
+ "widgets" => %|
71
+ input MakeWidgetInput { name: String child: MakeWidgetInput }
72
+ type Widget { id: ID! name(lang: String): String size: Int color: String }
73
+ type Query { widget(id: ID!): Widget widgets(ids: [ID!]): [Widget!]! }
74
+ type Mutation { makeWidget(input: MakeWidgetInput!): Widget }
75
+ |,
76
+ "sprockets" => %|
77
+ input MakeSprocketInput { name: String child: MakeSprocketInput }
78
+ type Sprocket { id: ID! name(lang: String): String size: Int color: String }
79
+ type Query { sprocket(id: ID!): Sprocket sprockets(ids: [ID!]): [Sprocket!]! }
80
+ type Mutation { makeSprocket(input: MakeSprocketInput!): Sprocket }
81
+ |,
82
+ })
83
+
84
+ delegation_supergraph = compose_definitions({
85
+ "alpha" => %|
86
+ type Widget { id: ID! a: String! b: String! }
87
+ type Query { alpha(id: ID!): Widget @stitch(key: "id") }
88
+ |,
89
+ "bravo" => %|
90
+ type Widget { id: ID! a: String! b: String! }
91
+ type Query { bravo(id: ID!): Widget @stitch(key: "id") }
92
+ |,
93
+ "charlie" => %|
94
+ type Widget { id: ID! c: String! d: String! }
95
+ type Query { charlie(id: ID!): Widget @stitch(key: "id") }
96
+ |,
97
+ "delta" => %|
98
+ type Widget { id: ID! d: String! e: String! }
99
+ type Query { delta(id: ID!): Widget @stitch(key: "id") }
100
+ |,
101
+ "echo" => %|
102
+ type Widget { id: ID! d: String! e: String! f: String! }
103
+ type Query { echo(id: ID!): Widget @stitch(key: "id") }
104
+ |,
105
+ "foxtrot" => %|
106
+ type Widget { id: ID! d: String! f: String! }
107
+ type Query { foxtrot(id: ID!): Widget @stitch(key: "id") }
108
+ |,
109
+ })
110
+
111
+ resolver_supergraph = compose_definitions({
112
+ "storefronts" => %|
113
+ type Storefront {
114
+ id: ID!
115
+ name: String!
116
+ products: [Product]!
117
+ }
118
+ type Product {
119
+ upc: ID!
120
+ }
121
+ type Query {
122
+ storefront(id: ID!): Storefront
123
+ }
124
+ |,
125
+ "products" => %|
126
+ type Product {
127
+ upc: ID!
128
+ name: String!
129
+ price: Float!
130
+ manufacturer: Manufacturer!
131
+ }
132
+ type Manufacturer {
133
+ id: ID!
134
+ name: String!
135
+ products: [Product]!
136
+ }
137
+ type Query {
138
+ product(upc: ID!): Product @stitch(key: "upc")
139
+ productsManufacturer(id: ID!): Manufacturer @stitch(key: "id")
140
+ }
141
+ |,
142
+ "manufacturers" => %|
143
+ type Manufacturer {
144
+ id: ID!
145
+ name: String!
146
+ address: String!
147
+ }
148
+ type Query {
149
+ manufacturer(id: ID!): Manufacturer @stitch(key: "id")
150
+ }
151
+ |,
152
+ })
153
+
154
+ abstract_supergraph = compose_definitions({
155
+ "a" => %|
156
+ interface Buyable {
157
+ id: ID!
158
+ name: String!
159
+ price: Float!
160
+ }
161
+ type Product implements Buyable {
162
+ id: ID!
163
+ name: String!
164
+ price: Float!
165
+ }
166
+ type Query {
167
+ products(ids: [ID!]!): [Product]! @stitch(key: "id")
168
+ }
169
+ |,
170
+ "b" => %|
171
+ interface Buyable { id: ID! }
172
+ type Product implements Buyable { id: ID! }
173
+ type Bundle implements Buyable {
174
+ id: ID!
175
+ name: String!
176
+ price: Float!
177
+ products: [Product]!
178
+ }
179
+ type Query {
180
+ buyable(id: ID!): Buyable @stitch(key: "id")
181
+ }
182
+ |,
183
+ })
184
+
185
+ cases = [
186
+ PlannerBenchmark::Case.new(
187
+ name: "root groups",
188
+ supergraph: root_supergraph,
189
+ document: %|
190
+ query($wid: ID!, $sid: ID!, $ids: [ID!], $lang: String) {
191
+ a: widget(id: $wid) { id name(lang: $lang) size color }
192
+ b: sprocket(id: $sid) { id name(lang: $lang) size color }
193
+ c: widgets(ids: $ids) { id name size color }
194
+ d: sprockets(ids: $ids) { id name size color }
195
+ }
196
+ |,
197
+ variables: { "wid" => "1", "sid" => "2", "ids" => ["1", "2"], "lang" => "en" },
198
+ ),
199
+ PlannerBenchmark::Case.new(
200
+ name: "variables",
201
+ supergraph: root_supergraph,
202
+ document: %|
203
+ mutation($wname1: String!, $wname2: String!, $sname1: String!, $sname2: String!, $lang: String) {
204
+ makeWidget(input: { name: $wname1, child: { name: $wname2 } }) { id name(lang: $lang) }
205
+ makeSprocket(input: { name: $sname1, child: { name: $sname2 } }) { id name(lang: $lang) }
206
+ }
207
+ |,
208
+ variables: {
209
+ "wname1" => "a",
210
+ "wname2" => "b",
211
+ "sname1" => "c",
212
+ "sname2" => "d",
213
+ "lang" => "en",
214
+ },
215
+ ),
216
+ PlannerBenchmark::Case.new(
217
+ name: "delegation",
218
+ supergraph: delegation_supergraph,
219
+ document: %|query { alpha(id: "1") { a b c d e f } }|,
220
+ ),
221
+ PlannerBenchmark::Case.new(
222
+ name: "nested resolvers",
223
+ supergraph: resolver_supergraph,
224
+ document: %|
225
+ query {
226
+ storefront(id: "1") {
227
+ name
228
+ products {
229
+ name
230
+ manufacturer {
231
+ address
232
+ products {
233
+ name
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ |,
240
+ ),
241
+ PlannerBenchmark::Case.new(
242
+ name: "fragments",
243
+ supergraph: resolver_supergraph,
244
+ document: %|
245
+ query {
246
+ storefront(id: "1") {
247
+ products {
248
+ ...ProductAttrs
249
+ manufacturer {
250
+ ...ManufacturerAttrs
251
+ }
252
+ }
253
+ }
254
+ }
255
+ fragment ProductAttrs on Product { name price }
256
+ fragment ManufacturerAttrs on Manufacturer {
257
+ name
258
+ address
259
+ products { ...ProductAttrs }
260
+ }
261
+ |,
262
+ ),
263
+ PlannerBenchmark::Case.new(
264
+ name: "abstracts",
265
+ supergraph: abstract_supergraph,
266
+ document: %|
267
+ query {
268
+ buyable(id: "1") {
269
+ ... {
270
+ ...BuyableAttrs
271
+ }
272
+ }
273
+ }
274
+ fragment BuyableAttrs on Buyable { id name price }
275
+ |,
276
+ ),
277
+ ]
278
+
279
+ PlannerBenchmark.new(cases).run
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.add_runtime_dependency 'graphql', '>= 2.0'
30
30
 
31
- spec.add_development_dependency 'bundler', '~> 2.0'
32
- spec.add_development_dependency 'rake', '~> 12.0'
33
- spec.add_development_dependency 'minitest', '~> 5.12'
31
+ spec.add_development_dependency 'bundler', '>= 2.0'
32
+ spec.add_development_dependency 'rake', '>= 12.0'
33
+ spec.add_development_dependency 'minitest', '>= 5.12'
34
34
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Executor
5
+ # Utilities for traversing aggregate executor data along planned paths.
6
+ module PathAccess
7
+ private
8
+
9
+ def path_objects(root, path)
10
+ objects = []
11
+ each_path_object(root, path) { |object| objects << object }
12
+ objects
13
+ end
14
+
15
+ def each_path_object(scope, path, &block)
16
+ return enum_for(:each_path_object, scope, path) unless block
17
+ return if scope.nil?
18
+
19
+ if path.empty?
20
+ each_leaf_object(scope, &block)
21
+ elsif scope.is_a?(Array)
22
+ scope.each { |element| each_path_object(element, path, &block) }
23
+ elsif scope.respond_to?(:[])
24
+ path_segment = path.first
25
+ each_path_object(scope[path_segment], path[1..-1], &block)
26
+ end
27
+ end
28
+
29
+ def each_leaf_object(scope, &block)
30
+ return if scope.nil?
31
+
32
+ if scope.is_a?(Array)
33
+ scope.each { |element| each_leaf_object(element, &block) }
34
+ else
35
+ yield(scope)
36
+ end
37
+ end
38
+
39
+ def path_entries(root, path)
40
+ entries = []
41
+ each_path_entry(root, path) { |object, response_path| entries << [object, response_path] }
42
+ entries
43
+ end
44
+
45
+ def each_path_entry(scope, path, response_path = [], &block)
46
+ return enum_for(:each_path_entry, scope, path, response_path) unless block
47
+ return if scope.nil?
48
+
49
+ if path.empty?
50
+ each_leaf_entry(scope, response_path, &block)
51
+ elsif scope.is_a?(Array)
52
+ scope.each_with_index do |element, index|
53
+ each_path_entry(element, path, [*response_path, index], &block)
54
+ end
55
+ elsif scope.respond_to?(:[])
56
+ path_segment = path.first
57
+ each_path_entry(scope[path_segment], path[1..-1], [*response_path, path_segment], &block)
58
+ end
59
+ end
60
+
61
+ def each_leaf_entry(scope, response_path, &block)
62
+ return if scope.nil?
63
+
64
+ if scope.is_a?(Array)
65
+ scope.each_with_index do |element, index|
66
+ each_leaf_entry(element, [*response_path, index], &block)
67
+ end
68
+ else
69
+ yield(scope, response_path)
70
+ end
71
+ end
72
+
73
+ def sanitized_error(error, path: nil)
74
+ error.dup.tap do |formatted|
75
+ formatted.delete("locations")
76
+ formatted["path"] = path if path
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,42 +3,49 @@
3
3
  module GraphQL::Stitching
4
4
  class Executor
5
5
  class RootSource < GraphQL::Dataloader::Source
6
+ include PathAccess
7
+
6
8
  def initialize(executor, location)
7
9
  @executor = executor
8
10
  @location = location
9
11
  end
10
12
 
11
13
  def fetch(ops)
12
- op = ops.first # There should only ever be one per location at a time
13
-
14
- query_document = build_document(
15
- op,
16
- @executor.request.operation_name,
17
- @executor.request.operation_directives,
18
- )
19
- query_variables = @executor.request.variables.slice(*op.variables.each_key)
20
- result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21
- @executor.query_count += 1
22
-
23
- if result["data"]
24
- unless op.path.empty?
25
- # Nested root scopes must expand their pathed origin set
26
- origin_set = op.path.reduce([@executor.data]) do |set, ns|
27
- set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!)
14
+ ops.map do |op|
15
+ origin_set = op.path.empty? ? [@executor.data] : path_objects(@executor.data, op.path)
16
+
17
+ query_document = build_document(
18
+ op,
19
+ @executor.request.operation_name,
20
+ @executor.request.operation_directives,
21
+ )
22
+ query_variables = @executor.request.variables.slice(*op.variables.each_key)
23
+ result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
24
+ @executor.query_count += 1
25
+
26
+ errors = result["errors"]
27
+ origin_entries = nil
28
+
29
+ if errors && !errors.empty?
30
+ origin_entries = op.path.empty? ? [[@executor.data, []]] : path_entries(@executor.data, op.path)
31
+ end
32
+
33
+ if result["data"]
34
+ if op.path.empty?
35
+ # Actual root scopes merge directly into results data
36
+ @executor.data.merge!(result["data"])
37
+ elsif !origin_set.empty?
38
+ # Nested root scopes merge the same root payload into each pathed origin
39
+ origin_set.each { |origin_obj| origin_obj.merge!(result["data"]) }
28
40
  end
41
+ end
29
42
 
30
- origin_set.each { _1.merge!(result["data"]) }
31
- else
32
- # Actual root scopes merge directly into results data
33
- @executor.data.merge!(result["data"])
43
+ if errors && !errors.empty?
44
+ @executor.errors.concat(format_errors(errors, origin_entries, op.path))
34
45
  end
35
- end
36
46
 
37
- if result["errors"]&.any?
38
- @executor.errors.concat(format_errors!(result["errors"], op.path))
47
+ op.step
39
48
  end
40
-
41
- ops.map(&:step)
42
49
  end
43
50
 
44
51
  # Builds root source documents
@@ -69,13 +76,21 @@ module GraphQL::Stitching
69
76
  end
70
77
 
71
78
  # Format response errors without a document location (because it won't match the request doc),
72
- # and prepend any insertion path for the scope into error paths.
73
- def format_errors!(errors, path)
74
- errors.each do |err|
75
- err.delete("locations")
76
- err["path"].unshift(*path) if err["path"] && !path.empty?
79
+ # and prepend all concrete insertion paths for nested scopes into error paths.
80
+ def format_errors(errors, origin_entries, fallback_path = [])
81
+ errors.flat_map do |err|
82
+ path = err["path"]
83
+
84
+ if path && !origin_entries.empty?
85
+ origin_entries.map do |_origin_obj, origin_path|
86
+ sanitized_error(err, path: origin_path + path)
87
+ end
88
+ elsif path && !fallback_path.empty?
89
+ sanitized_error(err, path: fallback_path + path)
90
+ else
91
+ sanitized_error(err)
92
+ end
77
93
  end
78
- errors
79
94
  end
80
95
  end
81
96
  end
@@ -11,6 +11,7 @@ module GraphQL::Stitching
11
11
  @request = request
12
12
  @supergraph = request.supergraph
13
13
  @root_type = nil
14
+ @possible_type_names_by_type = nil
14
15
  end
15
16
 
16
17
  def perform!(raw)
@@ -24,6 +25,8 @@ module GraphQL::Stitching
24
25
  return nil if raw_object.nil?
25
26
 
26
27
  typename ||= raw_object[TypeResolver::TYPENAME_EXPORT_NODE.alias]
28
+ typename ||= parent_type.graphql_name unless parent_type.kind.abstract?
29
+
27
30
  raw_object.reject! { |key, _v| TypeResolver.export_key?(key) }
28
31
 
29
32
  selections.each do |node|
@@ -84,36 +87,37 @@ module GraphQL::Stitching
84
87
 
85
88
  next_node_type = Util.unwrap_non_null(current_node_type).of_type
86
89
  named_type = next_node_type.unwrap
87
- contains_null = false
90
+
91
+ if Util.is_leaf_type?(named_type)
92
+ return nil if next_node_type.non_null? && raw_list.include?(nil)
93
+
94
+ return raw_list
95
+ end
88
96
 
89
97
  resolved_list = raw_list.map! do |raw_list_element|
90
98
  result = if next_node_type.list?
91
99
  resolve_list_scope(raw_list_element, next_node_type, selections)
92
- elsif Util.is_leaf_type?(named_type)
93
- raw_list_element
94
100
  else
95
101
  resolve_object_scope(raw_list_element, named_type, selections)
96
102
  end
97
103
 
98
- if result.nil?
99
- contains_null = true
100
- return nil if current_node_type.non_null?
101
- end
104
+ return nil if result.nil? && next_node_type.non_null?
102
105
 
103
106
  result
104
107
  end
105
108
 
106
- return nil if contains_null && next_node_type.non_null?
107
-
108
109
  resolved_list
109
110
  end
110
111
 
111
112
  def typename_in_type?(typename, type)
112
113
  return true if type.graphql_name == typename
114
+ return false unless typename && type.kind.abstract?
113
115
 
114
- type.kind.abstract? && @request.query.possible_types(type).any? do |t|
115
- t.graphql_name == typename
116
- end
116
+ possible_type_names(type).include?(typename)
117
+ end
118
+
119
+ def possible_type_names(type)
120
+ (@possible_type_names_by_type ||= {})[type.graphql_name] ||= @request.query.possible_types(type).map(&:graphql_name)
117
121
  end
118
122
  end
119
123
  end
@@ -3,40 +3,39 @@
3
3
  module GraphQL::Stitching
4
4
  class Executor
5
5
  class TypeResolverSource < GraphQL::Dataloader::Source
6
+ include PathAccess
7
+
6
8
  def initialize(executor, location)
7
9
  @executor = executor
8
10
  @location = location
9
- @variables = {}
10
11
  end
11
12
 
12
13
  def fetch(ops)
13
14
  origin_sets_by_operation = ops.each_with_object({}.compare_by_identity) do |op, memo|
14
- origin_set = op.path.reduce([@executor.data]) do |set, path_segment|
15
- set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
16
- end
15
+ origin_set = path_objects(@executor.data, op.path)
17
16
 
18
17
  if op.if_type
19
18
  # operations planned around unused fragment conditions should not trigger requests
20
- origin_set.select! { _1[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
19
+ origin_set.select! { |origin_obj| origin_obj[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
21
20
  end
22
21
 
23
22
  memo[op] = origin_set unless origin_set.empty?
24
23
  end
25
24
 
26
25
  unless origin_sets_by_operation.empty?
27
- query_document, variable_names = build_document(
26
+ query_document, variable_names, generated_variables = build_document(
28
27
  origin_sets_by_operation,
29
28
  @executor.request.operation_name,
30
29
  @executor.request.operation_directives,
31
30
  )
32
- variables = @variables.merge!(@executor.request.variables.slice(*variable_names))
31
+ variables = generated_variables.merge(@executor.request.variables.slice(*variable_names))
33
32
  raw_result = @executor.request.supergraph.execute_at_location(@location, query_document, variables, @executor.request)
34
33
  @executor.query_count += 1
35
34
 
36
35
  merge_results!(origin_sets_by_operation, raw_result.dig("data"))
37
36
 
38
37
  errors = raw_result.dig("errors")
39
- @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
38
+ @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors && !errors.empty?
40
39
  end
41
40
 
42
41
  ops.map { origin_sets_by_operation[_1] ? _1.step : nil }
@@ -51,6 +50,7 @@ module GraphQL::Stitching
51
50
  # }"
52
51
  def build_document(origin_sets_by_operation, operation_name = nil, operation_directives = nil)
53
52
  variable_defs = {}
53
+ generated_variables = {}
54
54
  fields_buffer = String.new
55
55
 
56
56
  origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
@@ -65,7 +65,7 @@ module GraphQL::Stitching
65
65
  fields_buffer << "," unless i.zero?
66
66
  if arg.key?
67
67
  variable_name = "_#{batch_index}_key_#{i}".freeze
68
- @variables[variable_name] = origin_set.map { arg.build(_1) }
68
+ generated_variables[variable_name] = origin_set.map { arg.build(_1) }
69
69
  variable_defs[variable_name] = arg.to_type_signature
70
70
  fields_buffer << arg.name << ":$" << variable_name
71
71
  else
@@ -83,7 +83,7 @@ module GraphQL::Stitching
83
83
  fields_buffer << "," unless i.zero?
84
84
  if arg.key?
85
85
  variable_name = "_#{batch_index}_#{index}_key_#{i}".freeze
86
- @variables[variable_name] = arg.build(origin_obj)
86
+ generated_variables[variable_name] = arg.build(origin_obj)
87
87
  variable_defs[variable_name] = arg.to_type_signature
88
88
  fields_buffer << arg.name << ":$" << variable_name
89
89
  else
@@ -120,9 +120,11 @@ module GraphQL::Stitching
120
120
 
121
121
  doc_buffer << "{ " << fields_buffer << " }"
122
122
 
123
- return doc_buffer, variable_defs.keys.tap do |names|
124
- names.reject! { @variables.key?(_1) }
123
+ variable_names = variable_defs.keys.tap do |names|
124
+ names.reject! { generated_variables.key?(_1) }
125
125
  end
126
+
127
+ return doc_buffer, variable_names, generated_variables
126
128
  end
127
129
 
128
130
  def merge_results!(origin_sets_by_operation, raw_result)
@@ -145,85 +147,57 @@ module GraphQL::Stitching
145
147
  end
146
148
 
147
149
  # https://spec.graphql.org/June2018/#sec-Errors
148
- def extract_errors!(origin_sets_by_operation, errors)
150
+ def extract_errors!(origin_sets_by_operation, errors, origin_paths_by_operation = nil)
149
151
  ops = origin_sets_by_operation.keys
150
152
  origin_sets = origin_sets_by_operation.values
151
- pathed_errors_by_op_index_and_object_id = Hash.new { |h, k| h[k] = {} }
153
+ origin_paths_by_operation ||= origin_sets_by_operation.each_with_object({}.compare_by_identity) do |(op, origin_set), memo|
154
+ memo[op] = paths_for_origin_set(op, origin_set)
155
+ end
152
156
 
153
- errors_result = errors.each_with_object([]) do |err, memo|
154
- err.delete("locations")
157
+ errors.each_with_object([]) do |err, memo|
155
158
  path = err["path"]
156
159
 
157
160
  if path && path.length > 0
158
161
  result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
159
162
 
160
163
  if result_alias
161
- path = err["path"] = path[1..-1]
164
+ path = path[1..-1]
165
+ batch_index = result_alias[1].to_i
162
166
 
163
- origin_obj = if result_alias[2]
164
- origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
165
- elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
166
- origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
167
+ origin_index = if result_alias[2]
168
+ result_alias[2].to_i
169
+ elsif path[0].is_a?(Integer) || /\A\d+\z/.match?(path[0].to_s)
170
+ path.shift.to_i
167
171
  end
172
+ origin_obj = origin_sets.dig(batch_index, origin_index) if origin_index
168
173
 
169
174
  if origin_obj
170
- pathed_errors_by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i]
171
- by_object_id = pathed_errors_by_op_index[origin_obj.object_id] ||= []
172
- by_object_id << err
173
- next
175
+ op = ops[batch_index]
176
+ object_path = origin_paths_by_operation.dig(op, origin_index)
177
+
178
+ if object_path
179
+ memo << sanitized_error(err, path: object_path + path)
180
+ next
181
+ end
174
182
  end
183
+
184
+ memo << sanitized_error(err, path: path)
185
+ next
175
186
  end
176
187
  end
177
188
 
178
- memo << err
179
- end
180
-
181
- unless pathed_errors_by_op_index_and_object_id.empty?
182
- pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
183
- repath_errors!(pathed_errors_by_object_id, ops[op_index].path)
184
- errors_result.push(*pathed_errors_by_object_id.each_value)
185
- end
189
+ memo << sanitized_error(err)
186
190
  end
187
-
188
- errors_result.tap(&:flatten!)
189
191
  end
190
192
 
191
193
  private
192
194
 
193
- # traverse forward through origin data, expanding arrays to follow all paths
194
- # any errors found for an origin object_id have their path prefixed by the object path
195
- def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
196
- current_path.push(forward_path.shift)
197
- scope = root[current_path.last]
198
-
199
- if !forward_path.empty? && scope.is_a?(Array)
200
- scope.each_with_index do |element, index|
201
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
202
- inner_elements.each do |inner_element|
203
- current_path << index
204
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element)
205
- current_path.pop
206
- end
207
- end
208
-
209
- elsif !forward_path.empty?
210
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
211
-
212
- elsif scope.is_a?(Array)
213
- scope.each_with_index do |element, index|
214
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
215
- inner_elements.each do |inner_element|
216
- errors = pathed_errors_by_object_id[inner_element.object_id]
217
- errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
218
- end
219
- end
220
-
221
- else
222
- errors = pathed_errors_by_object_id[scope.object_id]
223
- errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
195
+ def paths_for_origin_set(op, origin_set)
196
+ paths_by_object_id = path_entries(@executor.data, op.path).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(object, path), memo|
197
+ memo[object.object_id] << path
224
198
  end
225
199
 
226
- forward_path.unshift(current_path.pop)
200
+ origin_set.map { |origin_obj| paths_by_object_id[origin_obj.object_id].shift }
227
201
  end
228
202
  end
229
203
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "executor/path_access"
4
5
  require_relative "executor/root_source"
5
6
  require_relative "executor/type_resolver_source"
6
7
  require_relative "executor/shaper"
@@ -91,7 +91,7 @@ module GraphQL
91
91
  path.each { entrypoint << "/" << _1 }
92
92
 
93
93
  step = @steps_by_entrypoint[entrypoint]
94
- next_index = step ? parent_index : @planning_index += 1
94
+ next_index = step ? step.index : @planning_index += 1
95
95
 
96
96
  unless selections.empty?
97
97
  selections = extract_locale_selections(location, parent_type, next_index, selections, path, variables)
@@ -110,6 +110,7 @@ module GraphQL
110
110
  resolver: resolver,
111
111
  )
112
112
  else
113
+ step.variables.merge!(variables)
113
114
  step.selections.concat(selections)
114
115
  step
115
116
  end
@@ -142,7 +143,8 @@ module GraphQL
142
143
  # A.2) Partition mutation fields by consecutive location for serial execution.
143
144
  partitions = []
144
145
  each_field_in_scope(parent_type, @request.operation.selections) do |node|
145
- next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
146
+ locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
147
+ next_location = locations.first
146
148
 
147
149
  if partitions.none? || partitions.last.location != next_location
148
150
  partitions << ScopePartition.new(location: next_location, selections: [])
@@ -238,7 +240,8 @@ module GraphQL
238
240
  end
239
241
 
240
242
  # B.3) Collect all variable definitions used within the filtered selection.
241
- extract_node_variables(node, locale_variables)
243
+ extract_node_argument_variables(node, locale_variables)
244
+ extract_node_directive_variables(node, locale_variables)
242
245
  schema_fields = @supergraph.memoized_schema_fields(parent_type.graphql_name)
243
246
  field_type = schema_fields[node.name].type.unwrap
244
247
 
@@ -256,7 +259,8 @@ module GraphQL
256
259
  fragment_type = node.type ? @supergraph.memoized_schema_types[node.type.name] : parent_type
257
260
  next unless @supergraph.locations_by_type[fragment_type.graphql_name].include?(current_location)
258
261
 
259
- is_same_scope = fragment_type == parent_type
262
+ extract_node_directive_variables(node, locale_variables)
263
+ is_same_scope = fragment_type == parent_type && node.directives.empty?
260
264
  selection_set = is_same_scope ? locale_selections : []
261
265
  extract_locale_selections(current_location, fragment_type, parent_index, node.selections, path, locale_variables, selection_set)
262
266
 
@@ -269,14 +273,17 @@ module GraphQL
269
273
  fragment = @request.fragment_definitions[node.name]
270
274
  next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
271
275
 
276
+ extract_node_directive_variables(node, locale_variables)
277
+ extract_node_directive_variables(fragment, locale_variables)
272
278
  requires_typename = true
273
279
  fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
274
- is_same_scope = fragment_type == parent_type
280
+ directives = fragment.directives.empty? && node.directives.empty? ? EMPTY_ARRAY : fragment.directives + node.directives
281
+ is_same_scope = fragment_type == parent_type && directives.empty?
275
282
  selection_set = is_same_scope ? locale_selections : []
276
283
  extract_locale_selections(current_location, fragment_type, parent_index, fragment.selections, path, locale_variables, selection_set)
277
284
 
278
285
  unless is_same_scope
279
- locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
286
+ locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, directives: directives, selections: selection_set)
280
287
  end
281
288
 
282
289
  else
@@ -355,20 +362,28 @@ module GraphQL
355
362
 
356
363
  # B.3) Collect all variable definitions used within the filtered selection.
357
364
  # These specify which request variables to pass along with each step.
358
- def extract_node_variables(node_with_args, variable_definitions)
359
- node_with_args.arguments.each do |argument|
360
- case argument.value
361
- when GraphQL::Language::Nodes::InputObject
362
- extract_node_variables(argument.value, variable_definitions)
363
- when GraphQL::Language::Nodes::VariableIdentifier
364
- variable_definitions[argument.value.name] ||= @request.variable_definitions[argument.value.name]
365
- end
366
- end
365
+ def extract_node_argument_variables(node, variable_definitions)
366
+ arguments = node.arguments
367
+ return if arguments.empty?
367
368
 
368
- if node_with_args.respond_to?(:directives)
369
- node_with_args.directives.each do |directive|
370
- extract_node_variables(directive, variable_definitions)
371
- end
369
+ arguments.each { |argument| extract_value_variables(argument.value, variable_definitions) }
370
+ end
371
+
372
+ def extract_node_directive_variables(node, variable_definitions)
373
+ directives = node.directives
374
+ return if directives.empty?
375
+
376
+ directives.each { |directive| extract_node_argument_variables(directive, variable_definitions) }
377
+ end
378
+
379
+ def extract_value_variables(value, variable_definitions)
380
+ case value
381
+ when GraphQL::Language::Nodes::InputObject
382
+ extract_node_argument_variables(value, variable_definitions)
383
+ when GraphQL::Language::Nodes::VariableIdentifier
384
+ variable_definitions[value.name] ||= @request.variable_definitions[value.name]
385
+ when Array
386
+ value.each { extract_value_variables(_1, variable_definitions) }
372
387
  end
373
388
  end
374
389
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.7.3"
5
+ VERSION = "1.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.3
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-06 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -28,42 +28,42 @@ dependencies:
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '2.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rake
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '12.0'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '12.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: minitest
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ">="
60
60
  - !ruby/object:Gem::Version
61
61
  version: '5.12'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: '5.12'
69
69
  description: Combine GraphQL services into one unified graph
@@ -79,6 +79,7 @@ files:
79
79
  - LICENSE
80
80
  - README.md
81
81
  - Rakefile
82
+ - benchmark/run.rb
82
83
  - docs/README.md
83
84
  - docs/composing_a_supergraph.md
84
85
  - docs/error_handling.md
@@ -167,6 +168,7 @@ files:
167
168
  - lib/graphql/stitching/composer/validate_type_resolvers.rb
168
169
  - lib/graphql/stitching/directives.rb
169
170
  - lib/graphql/stitching/executor.rb
171
+ - lib/graphql/stitching/executor/path_access.rb
170
172
  - lib/graphql/stitching/executor/root_source.rb
171
173
  - lib/graphql/stitching/executor/shaper.rb
172
174
  - lib/graphql/stitching/executor/type_resolver_source.rb