graphql-stitching 1.0.1 → 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +20 -0
- data/lib/graphql/stitching/boundary.rb +1 -0
- data/lib/graphql/stitching/executor/boundary_source.rb +10 -2
- data/lib/graphql/stitching/executor/root_source.rb +10 -2
- data/lib/graphql/stitching/executor.rb +11 -13
- data/lib/graphql/stitching/plan.rb +2 -0
- data/lib/graphql/stitching/planner.rb +29 -44
- data/lib/graphql/stitching/planner_step.rb +12 -4
- data/lib/graphql/stitching/request.rb +7 -0
- data/lib/graphql/stitching/selection_hint.rb +2 -0
- data/lib/graphql/stitching/shaper.rb +2 -0
- data/lib/graphql/stitching/util.rb +1 -1
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4a0b6a79a253614edadb1151490e42e32164e9f10c8e110806978160efd8b482
|
4
|
+
data.tar.gz: ff9d5a50b4bc41392a2777fc7bbdf401820a0fea899ef43736047f0efb196742
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ffb8d232df37ff5b858f95344566774c314132c89bd4353b84ad603d54f1242581df452aef50db0ec4193428a6de72179063bf1213cd74ce0038b0a5cd5aad9b
|
7
|
+
data.tar.gz: 585e94aaaf146a642f36cb184c91e69aacd8ed9988fd4c4cfc350feb83662b4955cb72d5efe2bdf7606f198a5f03b51d0f809fade8ba8e25367479d3d5902c44
|
data/README.md
CHANGED
@@ -400,6 +400,26 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
|
|
400
400
|
|
401
401
|
The `GraphQL::Stitching::HttpExecutable` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
|
402
402
|
|
403
|
+
## Batching
|
404
|
+
|
405
|
+
The stitching executor automatically batches subgraph requests so that only one request is made per location per generation of data. This is done using batched queries that combine all data access for a given a location. For example:
|
406
|
+
|
407
|
+
```graphql
|
408
|
+
query MyOperation_2 {
|
409
|
+
_0_result: widgets(ids:["a","b","c"]) { ... } # << 3 Widget
|
410
|
+
_1_0_result: sprocket(id:"x") { ... } # << 1 Sprocket
|
411
|
+
_1_1_result: sprocket(id:"y") { ... } # << 1 Sprocket
|
412
|
+
_1_2_result: sprocket(id:"z") { ... } # << 1 Sprocket
|
413
|
+
}
|
414
|
+
```
|
415
|
+
|
416
|
+
Tips:
|
417
|
+
|
418
|
+
* List queries (like the `widgets` selection above) are more compact for accessing multiple records, and are therefore preferable as stitching accessors.
|
419
|
+
* Assure that root field resolvers across your subgraph implement batching to anticipate cases like the three `sprocket` selections above.
|
420
|
+
|
421
|
+
Otherwise, there's no developer intervention necessary (or generally possible) to improve upon data access. Note that multiple generations of data may still force the executor to return to a previous location for more data.
|
422
|
+
|
403
423
|
## Concurrency
|
404
424
|
|
405
425
|
The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based implementation of `GraphQL::Dataloader`. Non-blocking concurrency requires setting a fiber scheduler via `Fiber.set_scheduler`, see [graphql-ruby docs](https://graphql-ruby.org/dataloader/nonblocking.html). You may also need to build your own remote clients using corresponding HTTP libraries.
|
@@ -23,7 +23,11 @@ module GraphQL
|
|
23
23
|
end
|
24
24
|
|
25
25
|
if origin_sets_by_operation.any?
|
26
|
-
query_document, variable_names = build_document(
|
26
|
+
query_document, variable_names = build_document(
|
27
|
+
origin_sets_by_operation,
|
28
|
+
@executor.request.operation_name,
|
29
|
+
@executor.request.operation_directives,
|
30
|
+
)
|
27
31
|
variables = @executor.request.variables.slice(*variable_names)
|
28
32
|
raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
|
29
33
|
@executor.query_count += 1
|
@@ -44,7 +48,7 @@ module GraphQL
|
|
44
48
|
# _1_1_result: item(key:"y") { boundarySelections... }
|
45
49
|
# _1_2_result: item(key:"z") { boundarySelections... }
|
46
50
|
# }"
|
47
|
-
def build_document(origin_sets_by_operation, operation_name = nil)
|
51
|
+
def build_document(origin_sets_by_operation, operation_name = nil, operation_directives = nil)
|
48
52
|
variable_defs = {}
|
49
53
|
query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
|
50
54
|
variable_defs.merge!(op.variables)
|
@@ -80,6 +84,10 @@ module GraphQL
|
|
80
84
|
doc << "(#{variable_str})"
|
81
85
|
end
|
82
86
|
|
87
|
+
if operation_directives
|
88
|
+
doc << " #{operation_directives} "
|
89
|
+
end
|
90
|
+
|
83
91
|
doc << "{ #{query_fields.join(" ")} }"
|
84
92
|
|
85
93
|
return doc, variable_defs.keys
|
@@ -11,7 +11,11 @@ module GraphQL
|
|
11
11
|
def fetch(ops)
|
12
12
|
op = ops.first # There should only ever be one per location at a time
|
13
13
|
|
14
|
-
query_document = build_document(
|
14
|
+
query_document = build_document(
|
15
|
+
op,
|
16
|
+
@executor.request.operation_name,
|
17
|
+
@executor.request.operation_directives,
|
18
|
+
)
|
15
19
|
query_variables = @executor.request.variables.slice(*op.variables.keys)
|
16
20
|
result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request.context)
|
17
21
|
@executor.query_count += 1
|
@@ -27,7 +31,7 @@ module GraphQL
|
|
27
31
|
|
28
32
|
# Builds root source documents
|
29
33
|
# "query MyOperation_1($var:VarType) { rootSelections ... }"
|
30
|
-
def build_document(op, operation_name = nil)
|
34
|
+
def build_document(op, operation_name = nil, operation_directives = nil)
|
31
35
|
doc = String.new
|
32
36
|
doc << op.operation_type
|
33
37
|
|
@@ -40,6 +44,10 @@ module GraphQL
|
|
40
44
|
doc << "(#{variable_defs})"
|
41
45
|
end
|
42
46
|
|
47
|
+
if operation_directives
|
48
|
+
doc << " #{operation_directives} "
|
49
|
+
end
|
50
|
+
|
43
51
|
doc << op.selections
|
44
52
|
doc
|
45
53
|
end
|
@@ -5,13 +5,13 @@ require "json"
|
|
5
5
|
module GraphQL
|
6
6
|
module Stitching
|
7
7
|
class Executor
|
8
|
-
attr_reader :supergraph, :request, :data, :errors
|
8
|
+
attr_reader :supergraph, :request, :plan, :data, :errors
|
9
9
|
attr_accessor :query_count
|
10
10
|
|
11
11
|
def initialize(supergraph:, request:, plan:, nonblocking: false)
|
12
12
|
@supergraph = supergraph
|
13
13
|
@request = request
|
14
|
-
@
|
14
|
+
@plan = plan
|
15
15
|
@data = {}
|
16
16
|
@errors = []
|
17
17
|
@query_count = 0
|
@@ -39,22 +39,20 @@ module GraphQL
|
|
39
39
|
|
40
40
|
private
|
41
41
|
|
42
|
-
def exec!(
|
43
|
-
if @exec_cycles > @
|
42
|
+
def exec!(next_steps = [0])
|
43
|
+
if @exec_cycles > @plan.ops.length
|
44
44
|
# sanity check... if we've exceeded queue size, then something went wrong.
|
45
45
|
raise StitchingError, "Too many execution requests attempted."
|
46
46
|
end
|
47
47
|
|
48
48
|
@dataloader.append_job do
|
49
|
-
tasks = @
|
50
|
-
.
|
49
|
+
tasks = @plan
|
50
|
+
.ops
|
51
|
+
.select { next_steps.include?(_1.after) }
|
51
52
|
.group_by { [_1.location, _1.boundary.nil?] }
|
52
53
|
.map do |(location, root_source), ops|
|
53
|
-
|
54
|
-
|
55
|
-
else
|
56
|
-
@dataloader.with(BoundarySource, self, location).request_all(ops)
|
57
|
-
end
|
54
|
+
source_type = root_source ? RootSource : BoundarySource
|
55
|
+
@dataloader.with(source_type, self, location).request_all(ops)
|
58
56
|
end
|
59
57
|
|
60
58
|
tasks.each(&method(:exec_task))
|
@@ -65,8 +63,8 @@ module GraphQL
|
|
65
63
|
end
|
66
64
|
|
67
65
|
def exec_task(task)
|
68
|
-
|
69
|
-
exec!(
|
66
|
+
next_steps = task.load.tap(&:compact!)
|
67
|
+
exec!(next_steps) if next_steps.any?
|
70
68
|
end
|
71
69
|
end
|
72
70
|
end
|
@@ -4,6 +4,7 @@ module GraphQL
|
|
4
4
|
module Stitching
|
5
5
|
class Planner
|
6
6
|
SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
|
7
|
+
TYPENAME = "__typename"
|
7
8
|
QUERY_OP = "query"
|
8
9
|
MUTATION_OP = "mutation"
|
9
10
|
ROOT_INDEX = 0
|
@@ -35,18 +36,14 @@ module GraphQL
|
|
35
36
|
# A.2) Partition mutation fields by consecutive location for serial execution.
|
36
37
|
#
|
37
38
|
# B) Extract contiguous selections for each entrypoint location.
|
38
|
-
#
|
39
39
|
# B.1) Selections on interface types that do not belong to the interface at the
|
40
|
-
#
|
41
|
-
#
|
40
|
+
# entrypoint location are expanded into concrete type fragments prior to extraction.
|
42
41
|
# B.2) Filter the selection tree down to just fields of the entrypoint location.
|
43
|
-
#
|
44
|
-
#
|
42
|
+
# Adjoining selections not available here get split off into new entrypoints (C).
|
45
43
|
# B.3) Collect all variable definitions used within the filtered selection.
|
46
|
-
#
|
47
|
-
#
|
48
|
-
#
|
49
|
-
# fragments. This provides resolved type information used during execution.
|
44
|
+
# These specify which request variables to pass along with each step.
|
45
|
+
# B.4) Add a `__typename` hint to abstracts and types that implement fragments.
|
46
|
+
# This provides resolved type information used during execution.
|
50
47
|
#
|
51
48
|
# C) Delegate adjoining selections to new entrypoint locations.
|
52
49
|
# C.1) Distribute unique fields among their required locations.
|
@@ -60,7 +57,7 @@ module GraphQL
|
|
60
57
|
#
|
61
58
|
# E) Translate boundary pathways into new entrypoints.
|
62
59
|
# E.1) Add the key of each boundary query into the prior location's selection set.
|
63
|
-
# E.2) Add a planner
|
60
|
+
# E.2) Add a planner step for each new entrypoint location, then extract it (B).
|
64
61
|
#
|
65
62
|
# F) Wrap concrete selections targeting abstract boundaries in typed fragments.
|
66
63
|
# **
|
@@ -77,8 +74,7 @@ module GraphQL
|
|
77
74
|
boundary: nil
|
78
75
|
)
|
79
76
|
# coalesce repeat parameters into a single entrypoint
|
80
|
-
|
81
|
-
entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{boundary_key}")
|
77
|
+
entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{boundary&.key}")
|
82
78
|
path.each { entrypoint << "/#{_1}" }
|
83
79
|
|
84
80
|
step = @steps_by_entrypoint[entrypoint]
|
@@ -89,11 +85,6 @@ module GraphQL
|
|
89
85
|
end
|
90
86
|
|
91
87
|
if step.nil?
|
92
|
-
# concrete types that are not root Query/Mutation report themselves as a type condition
|
93
|
-
# executor must check the __typename of loaded objects to see if they match subsequent operations
|
94
|
-
# this prevents the executor from taking action on unused fragment selections
|
95
|
-
conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.root_type_for_operation(operation_type)
|
96
|
-
|
97
88
|
@steps_by_entrypoint[entrypoint] = PlannerStep.new(
|
98
89
|
index: next_index,
|
99
90
|
after: parent_index,
|
@@ -103,7 +94,6 @@ module GraphQL
|
|
103
94
|
selections: selections,
|
104
95
|
variables: variables,
|
105
96
|
path: path,
|
106
|
-
if_type: conditional ? parent_type.graphql_name : nil,
|
107
97
|
boundary: boundary,
|
108
98
|
)
|
109
99
|
else
|
@@ -209,7 +199,7 @@ module GraphQL
|
|
209
199
|
input_selections.each do |node|
|
210
200
|
case node
|
211
201
|
when GraphQL::Language::Nodes::Field
|
212
|
-
if node.name ==
|
202
|
+
if node.name == TYPENAME
|
213
203
|
locale_selections << node
|
214
204
|
next
|
215
205
|
end
|
@@ -267,7 +257,7 @@ module GraphQL
|
|
267
257
|
end
|
268
258
|
end
|
269
259
|
|
270
|
-
# B.4) Add a `__typename`
|
260
|
+
# B.4) Add a `__typename` hint to abstracts and types that implement
|
271
261
|
# fragments so that resolved type information is available during execution.
|
272
262
|
if requires_typename
|
273
263
|
locale_selections << SelectionHint.typename_node
|
@@ -297,13 +287,12 @@ module GraphQL
|
|
297
287
|
parent_selections << SelectionHint.key_node(boundary.key) unless has_key
|
298
288
|
parent_selections << SelectionHint.typename_node unless has_typename
|
299
289
|
|
300
|
-
# E.2) Add a planner
|
301
|
-
location = boundary.location
|
290
|
+
# E.2) Add a planner step for each new entrypoint location.
|
302
291
|
add_step(
|
303
|
-
location: location,
|
292
|
+
location: boundary.location,
|
304
293
|
parent_index: parent_index,
|
305
294
|
parent_type: parent_type,
|
306
|
-
selections: remote_selections_by_location[location] || [],
|
295
|
+
selections: remote_selections_by_location[boundary.location] || [],
|
307
296
|
path: path.dup,
|
308
297
|
boundary: boundary,
|
309
298
|
).selections
|
@@ -323,7 +312,7 @@ module GraphQL
|
|
323
312
|
|
324
313
|
expanded_selections = nil
|
325
314
|
input_selections = input_selections.filter_map do |node|
|
326
|
-
if node.is_a?(GraphQL::Language::Nodes::Field) && node.name !=
|
315
|
+
if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != TYPENAME && !local_interface_fields.include?(node.name)
|
327
316
|
expanded_selections ||= []
|
328
317
|
expanded_selections << node
|
329
318
|
nil
|
@@ -345,7 +334,7 @@ module GraphQL
|
|
345
334
|
end
|
346
335
|
|
347
336
|
# B.3) Collect all variable definitions used within the filtered selection.
|
348
|
-
# These specify which request variables to pass along with
|
337
|
+
# These specify which request variables to pass along with each step.
|
349
338
|
def extract_node_variables(node_with_args, variable_definitions)
|
350
339
|
node_with_args.arguments.each do |argument|
|
351
340
|
case argument.value
|
@@ -391,15 +380,11 @@ module GraphQL
|
|
391
380
|
|
392
381
|
# C.3) Distribute remaining fields among locations weighted by greatest availability.
|
393
382
|
if remote_selections.any?
|
394
|
-
field_count_by_location =
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
memo[location] += 1
|
399
|
-
end
|
383
|
+
field_count_by_location = remote_selections.each_with_object({}) do |node, memo|
|
384
|
+
possible_locations_by_field[node.name].each do |location|
|
385
|
+
memo[location] ||= 0
|
386
|
+
memo[location] += 1
|
400
387
|
end
|
401
|
-
else
|
402
|
-
GraphQL::Stitching::EMPTY_OBJECT
|
403
388
|
end
|
404
389
|
|
405
390
|
remote_selections.each do |node|
|
@@ -407,11 +392,11 @@ module GraphQL
|
|
407
392
|
preferred_location = possible_locations.first
|
408
393
|
|
409
394
|
possible_locations.reduce(0) do |max_availability, possible_location|
|
410
|
-
|
395
|
+
availability = field_count_by_location.fetch(possible_location, 0)
|
411
396
|
|
412
|
-
if
|
397
|
+
if availability > max_availability
|
413
398
|
preferred_location = possible_location
|
414
|
-
|
399
|
+
availability
|
415
400
|
else
|
416
401
|
max_availability
|
417
402
|
end
|
@@ -427,15 +412,15 @@ module GraphQL
|
|
427
412
|
|
428
413
|
# F) Wrap concrete selections targeting abstract boundaries in typed fragments.
|
429
414
|
def expand_abstract_boundaries
|
430
|
-
@steps_by_entrypoint.each_value do |
|
431
|
-
next unless
|
415
|
+
@steps_by_entrypoint.each_value do |step|
|
416
|
+
next unless step.boundary
|
432
417
|
|
433
|
-
boundary_type = @supergraph.memoized_schema_types[
|
418
|
+
boundary_type = @supergraph.memoized_schema_types[step.boundary.type_name]
|
434
419
|
next unless boundary_type.kind.abstract?
|
435
|
-
next if boundary_type ==
|
420
|
+
next if boundary_type == step.parent_type
|
436
421
|
|
437
422
|
expanded_selections = nil
|
438
|
-
|
423
|
+
step.selections.reject! do |node|
|
439
424
|
if node.is_a?(GraphQL::Language::Nodes::Field)
|
440
425
|
expanded_selections ||= []
|
441
426
|
expanded_selections << node
|
@@ -444,8 +429,8 @@ module GraphQL
|
|
444
429
|
end
|
445
430
|
|
446
431
|
if expanded_selections
|
447
|
-
type_name = GraphQL::Language::Nodes::TypeName.new(name:
|
448
|
-
|
432
|
+
type_name = GraphQL::Language::Nodes::TypeName.new(name: step.parent_type.graphql_name)
|
433
|
+
step.selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
449
434
|
end
|
450
435
|
end
|
451
436
|
end
|
@@ -2,10 +2,13 @@
|
|
2
2
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
|
+
# A planned step in the sequence of stitching entrypoints together.
|
6
|
+
# This is a mutable object that may change throughout the planning process.
|
7
|
+
# It ultimately builds an immutable Plan::Op at the end of planning.
|
5
8
|
class PlannerStep
|
6
9
|
GRAPHQL_PRINTER = GraphQL::Language::Printer.new
|
7
10
|
|
8
|
-
attr_reader :index, :location, :parent_type, :
|
11
|
+
attr_reader :index, :location, :parent_type, :operation_type, :path
|
9
12
|
attr_accessor :after, :selections, :variables, :boundary
|
10
13
|
|
11
14
|
def initialize(
|
@@ -17,7 +20,6 @@ module GraphQL
|
|
17
20
|
selections: [],
|
18
21
|
variables: {},
|
19
22
|
path: [],
|
20
|
-
if_type: nil,
|
21
23
|
boundary: nil
|
22
24
|
)
|
23
25
|
@location = location
|
@@ -28,7 +30,6 @@ module GraphQL
|
|
28
30
|
@selections = selections
|
29
31
|
@variables = variables
|
30
32
|
@path = path
|
31
|
-
@if_type = if_type
|
32
33
|
@boundary = boundary
|
33
34
|
end
|
34
35
|
|
@@ -41,13 +42,20 @@ module GraphQL
|
|
41
42
|
selections: rendered_selections,
|
42
43
|
variables: rendered_variables,
|
43
44
|
path: @path,
|
44
|
-
if_type:
|
45
|
+
if_type: type_condition,
|
45
46
|
boundary: @boundary,
|
46
47
|
)
|
47
48
|
end
|
48
49
|
|
49
50
|
private
|
50
51
|
|
52
|
+
# Concrete types going to a boundary report themselves as a type condition.
|
53
|
+
# This is used by the executor to evalute which planned fragment selections
|
54
|
+
# actually apply to the resolved object types.
|
55
|
+
def type_condition
|
56
|
+
@parent_type.graphql_name if @boundary && !parent_type.kind.abstract?
|
57
|
+
end
|
58
|
+
|
51
59
|
def rendered_selections
|
52
60
|
op = GraphQL::Language::Nodes::OperationDefinition.new(operation_type: "", selections: @selections)
|
53
61
|
GRAPHQL_PRINTER.print(op).gsub!(/\s+/, " ").strip!
|
@@ -48,6 +48,13 @@ module GraphQL
|
|
48
48
|
end
|
49
49
|
end
|
50
50
|
|
51
|
+
def operation_directives
|
52
|
+
@operation_directives ||= if operation.directives.any?
|
53
|
+
printer = GraphQL::Language::Printer.new
|
54
|
+
operation.directives.map { printer.print(_1) }.join(" ")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
51
58
|
def variable_definitions
|
52
59
|
@variable_definitions ||= operation.variables.each_with_object({}) do |v, memo|
|
53
60
|
memo[v.name] = v.type
|
@@ -3,6 +3,8 @@
|
|
3
3
|
|
4
4
|
module GraphQL
|
5
5
|
module Stitching
|
6
|
+
# Shapes the final results payload to the request selection and schema definition.
|
7
|
+
# This eliminates unrequested selection hints and applies null bubbling.
|
6
8
|
class Shaper
|
7
9
|
def initialize(supergraph:, request:)
|
8
10
|
@supergraph = supergraph
|
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.0.
|
4
|
+
version: 1.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-10-
|
11
|
+
date: 2023-10-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|