graphql-stitching 1.0.0 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ff6bbdb8e949da02a0ed58f6db5ab56a6e622d9befc436042dcaaed6556a0f0
4
- data.tar.gz: ecb682677bb576a3742e1a0ebf8eabe4d17e2dfbc214120aed4cbef09db881df
3
+ metadata.gz: 4a0b6a79a253614edadb1151490e42e32164e9f10c8e110806978160efd8b482
4
+ data.tar.gz: ff9d5a50b4bc41392a2777fc7bbdf401820a0fea899ef43736047f0efb196742
5
5
  SHA512:
6
- metadata.gz: 506a02bced23940043c43bfb59b4a3cba9cf98c7177f0f0abe58b16b0543498cb7acdeecac4d44d22d867d3dd12f3be756236b36ac534e22eb3ed2f347f16984
7
- data.tar.gz: b409cbe17f3d4792bb8aa4ae0553d8d15dcb5a5316999248a66644fabae67133036804f9a34d805b3bed58e11e40158ca36444365f80773e945c2b586588fd39
6
+ metadata.gz: ffb8d232df37ff5b858f95344566774c314132c89bd4353b84ad603d54f1242581df452aef50db0ec4193428a6de72179063bf1213cd74ce0038b0a5cd5aad9b
7
+ data.tar.gz: 585e94aaaf146a642f36cb184c91e69aacd8ed9988fd4c4cfc350feb83662b4955cb72d5efe2bdf7606f198a5f03b51d0f809fade8ba8e25367479d3d5902c44
data/README.md CHANGED
@@ -9,7 +9,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
9
9
  - Multiple keys per merged type.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
12
- - Type merging via federation `_entities` protocol.
12
+ - Type merging via arbitrary queries or federation `_entities` protocol.
13
13
 
14
14
  **NOT Supported:**
15
15
  - Computed fields (ie: federation-style `@requires`).
@@ -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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ # Defines a boundary query that provides direct access to an entity type.
6
+ Boundary = Struct.new(
7
+ :location,
8
+ :type_name,
9
+ :key,
10
+ :field,
11
+ :arg,
12
+ :list,
13
+ :federation,
14
+ keyword_init: true
15
+ ) do
16
+ def as_json
17
+ {
18
+ location: location,
19
+ type_name: type_name,
20
+ key: key,
21
+ field: field,
22
+ arg: arg,
23
+ list: list,
24
+ federation: federation,
25
+ }.tap(&:compact!)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -41,7 +41,7 @@ module GraphQL
41
41
  GraphQL::Stitching::Planner.new(
42
42
  supergraph: @supergraph,
43
43
  request: request,
44
- ).perform.to_h
44
+ ).perform
45
45
  end
46
46
 
47
47
  GraphQL::Stitching::Executor.new(
@@ -76,16 +76,16 @@ module GraphQL
76
76
  def fetch_plan(request)
77
77
  if @on_cache_read
78
78
  cached_plan = @on_cache_read.call(request.digest, request.context)
79
- return JSON.parse(cached_plan) if cached_plan
79
+ return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
80
80
  end
81
81
 
82
- plan_json = yield
82
+ plan = yield
83
83
 
84
84
  if @on_cache_write
85
- @on_cache_write.call(request.digest, JSON.generate(plan_json), request.context)
85
+ @on_cache_write.call(request.digest, JSON.generate(plan.as_json), request.context)
86
86
  end
87
87
 
88
- plan_json
88
+ plan
89
89
  end
90
90
 
91
91
  def error_result(errors)
@@ -32,16 +32,16 @@ module GraphQL
32
32
 
33
33
  # only one boundary allowed per type/location/key
34
34
  boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
35
- if memo.dig(boundary["location"], boundary["key"])
36
- raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["key"]}` "\
37
- "found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
35
+ if memo.dig(boundary.location, boundary.key)
36
+ raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary.key}` "\
37
+ "found in #{boundary.location}. Limit one boundary query per type and key in each location. "\
38
38
  "Abstract boundaries provide all possible types."
39
39
  end
40
- memo[boundary["location"]] ||= {}
41
- memo[boundary["location"]][boundary["key"]] = boundary
40
+ memo[boundary.location] ||= {}
41
+ memo[boundary.location][boundary.key] = boundary
42
42
  end
43
43
 
44
- boundary_keys = boundaries.map { _1["key"] }.uniq
44
+ boundary_keys = boundaries.map { _1.key }.uniq
45
45
  key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
46
46
  subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
47
47
  end
@@ -32,12 +32,12 @@ module GraphQL
32
32
  interface_type_structure.each_with_index do |interface_struct, index|
33
33
  possible_struct = possible_type_structure[index]
34
34
 
35
- if possible_struct[:name] != interface_struct[:name]
35
+ if possible_struct.name != interface_struct.name
36
36
  raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
37
37
  "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
38
38
  end
39
39
 
40
- if possible_struct[:null] && !interface_struct[:null]
40
+ if possible_struct.null? && interface_struct.non_null?
41
41
  raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
42
42
  "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
43
43
  end
@@ -430,21 +430,21 @@ module GraphQL
430
430
  raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
431
431
  end
432
432
 
433
- if alt_structure.last[:name] != basis_structure.last[:name]
433
+ if alt_structure.last.name != basis_structure.last.name
434
434
  raise ComposerError, "Cannot compose mixed types at `#{path}`."
435
435
  end
436
436
  end
437
437
 
438
438
  type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
439
- basis_structure.last[:name],
440
- build_type_binding(basis_structure.last[:name])
439
+ basis_structure.last.name,
440
+ build_type_binding(basis_structure.last.name)
441
441
  )
442
442
 
443
443
  basis_structure.reverse!.each_with_index do |basis, index|
444
444
  rev_index = basis_structure.length - index - 1
445
- non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
445
+ non_null = alt_structures.each_with_object([basis.non_null?]) { |s, m| m << s[rev_index].non_null? }
446
446
 
447
- type = type.to_list_type if basis[:list]
447
+ type = type.to_list_type if basis.list?
448
448
  type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
449
449
  end
450
450
 
@@ -509,15 +509,15 @@ module GraphQL
509
509
  end
510
510
 
511
511
  @boundary_map[impl_type_name] ||= []
512
- @boundary_map[impl_type_name] << {
513
- "location" => location,
514
- "type_name" => impl_type_name,
515
- "key" => key_selections[0].name,
516
- "field" => field_candidate.name,
517
- "arg" => argument_name,
518
- "list" => boundary_structure.first[:list],
519
- "federation" => kwargs[:federation],
520
- }.compact
512
+ @boundary_map[impl_type_name] << Boundary.new(
513
+ location: location,
514
+ type_name: impl_type_name,
515
+ key: key_selections[0].name,
516
+ field: field_candidate.name,
517
+ arg: argument_name,
518
+ list: boundary_structure.first.list?,
519
+ federation: kwargs[:federation],
520
+ )
521
521
  end
522
522
  end
523
523
  end
@@ -10,20 +10,24 @@ module GraphQL
10
10
 
11
11
  def fetch(ops)
12
12
  origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
13
- origin_set = op["path"].reduce([@executor.data]) do |set, path_segment|
13
+ origin_set = op.path.reduce([@executor.data]) do |set, path_segment|
14
14
  set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
15
15
  end
16
16
 
17
- if op["if_type"]
17
+ if op.if_type
18
18
  # operations planned around unused fragment conditions should not trigger requests
19
- origin_set.select! { _1["_STITCH_typename"] == op["if_type"] }
19
+ origin_set.select! { _1[SelectionHint.typename_node.alias] == op.if_type }
20
20
  end
21
21
 
22
22
  memo[op] = origin_set if origin_set.any?
23
23
  end
24
24
 
25
25
  if origin_sets_by_operation.any?
26
- query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
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
@@ -34,7 +38,7 @@ module GraphQL
34
38
  @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
35
39
  end
36
40
 
37
- ops.map { origin_sets_by_operation[_1] ? _1["order"] : nil }
41
+ ops.map { origin_sets_by_operation[_1] ? _1.step : nil }
38
42
  end
39
43
 
40
44
  # Builds batched boundary queries
@@ -44,24 +48,24 @@ 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
- variable_defs.merge!(op["variables"])
51
- boundary = op["boundary"]
54
+ variable_defs.merge!(op.variables)
55
+ boundary = op.boundary
52
56
 
53
- if boundary["list"]
57
+ if boundary.list
54
58
  input = origin_set.each_with_index.reduce(String.new) do |memo, (origin_obj, index)|
55
59
  memo << "," if index > 0
56
- memo << build_key(boundary["key"], origin_obj, federation: boundary["federation"])
60
+ memo << build_key(boundary.key, origin_obj, federation: boundary.federation)
57
61
  memo
58
62
  end
59
63
 
60
- "_#{batch_index}_result: #{boundary["field"]}(#{boundary["arg"]}:[#{input}]) #{op["selections"]}"
64
+ "_#{batch_index}_result: #{boundary.field}(#{boundary.arg}:[#{input}]) #{op.selections}"
61
65
  else
62
66
  origin_set.map.with_index do |origin_obj, index|
63
- input = build_key(boundary["key"], origin_obj, federation: boundary["federation"])
64
- "_#{batch_index}_#{index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
67
+ input = build_key(boundary.key, origin_obj, federation: boundary.federation)
68
+ "_#{batch_index}_#{index}_result: #{boundary.field}(#{boundary.arg}:#{input}) #{op.selections}"
65
69
  end
66
70
  end
67
71
  end
@@ -71,7 +75,7 @@ module GraphQL
71
75
  if operation_name
72
76
  doc << " #{operation_name}"
73
77
  origin_sets_by_operation.each_key do |op|
74
- doc << "_#{op["order"]}"
78
+ doc << "_#{op.step}"
75
79
  end
76
80
  end
77
81
 
@@ -80,15 +84,19 @@ 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
86
94
  end
87
95
 
88
96
  def build_key(key, origin_obj, federation: false)
89
- key_value = JSON.generate(origin_obj["_STITCH_#{key}"])
97
+ key_value = JSON.generate(origin_obj[SelectionHint.key(key)])
90
98
  if federation
91
- "{ __typename: \"#{origin_obj["_STITCH_typename"]}\", #{key}: #{key_value} }"
99
+ "{ __typename: \"#{origin_obj[SelectionHint.typename_node.alias]}\", #{key}: #{key_value} }"
92
100
  else
93
101
  key_value
94
102
  end
@@ -11,9 +11,13 @@ 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(op, @executor.request.operation_name)
15
- query_variables = @executor.request.variables.slice(*op["variables"].keys)
16
- result = @executor.supergraph.execute_at_location(op["location"], query_document, query_variables, @executor.request.context)
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.keys)
20
+ result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request.context)
17
21
  @executor.query_count += 1
18
22
 
19
23
  @executor.data.merge!(result["data"]) if result["data"]
@@ -22,25 +26,29 @@ module GraphQL
22
26
  @executor.errors.concat(result["errors"])
23
27
  end
24
28
 
25
- ops.map { op["order"] }
29
+ ops.map(&:step)
26
30
  end
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
- doc << op["operation_type"]
36
+ doc << op.operation_type
33
37
 
34
38
  if operation_name
35
- doc << " #{operation_name}_#{op["order"]}"
39
+ doc << " #{operation_name}_#{op.step}"
36
40
  end
37
41
 
38
- if op["variables"].any?
39
- variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
42
+ if op.variables.any?
43
+ variable_defs = op.variables.map { |k, v| "$#{k}:#{v}" }.join(",")
40
44
  doc << "(#{variable_defs})"
41
45
  end
42
46
 
43
- doc << op["selections"]
47
+ if operation_directives
48
+ doc << " #{operation_directives} "
49
+ end
50
+
51
+ doc << op.selections
44
52
  doc
45
53
  end
46
54
  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
- @queue = plan["ops"]
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!(next_ordinals = [0])
43
- if @exec_cycles > @queue.length
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 = @queue
50
- .select { next_ordinals.include?(_1["after"]) }
51
- .group_by { [_1["location"], _1["boundary"].nil?] }
49
+ tasks = @plan
50
+ .ops
51
+ .select { next_steps.include?(_1.after) }
52
+ .group_by { [_1.location, _1.boundary.nil?] }
52
53
  .map do |(location, root_source), ops|
53
- if root_source
54
- @dataloader.with(RootSource, self, location).request_all(ops)
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
- next_ordinals = task.load.tap(&:compact!)
69
- exec!(next_ordinals) if next_ordinals.any?
66
+ next_steps = task.load.tap(&:compact!)
67
+ exec!(next_steps) if next_steps.any?
70
68
  end
71
69
  end
72
70
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ # Immutable structures representing a query plan.
6
+ # May serialize to/from JSON.
7
+ class Plan
8
+ Op = Struct.new(
9
+ :step,
10
+ :after,
11
+ :location,
12
+ :operation_type,
13
+ :selections,
14
+ :variables,
15
+ :path,
16
+ :if_type,
17
+ :boundary,
18
+ keyword_init: true
19
+ ) do
20
+ def as_json
21
+ {
22
+ step: step,
23
+ after: after,
24
+ location: location,
25
+ operation_type: operation_type,
26
+ selections: selections,
27
+ variables: variables,
28
+ path: path,
29
+ if_type: if_type,
30
+ boundary: boundary&.as_json
31
+ }.tap(&:compact!)
32
+ end
33
+ end
34
+
35
+ class << self
36
+ def from_json(json)
37
+ ops = json["ops"]
38
+ ops = ops.map do |op|
39
+ boundary = op["boundary"]
40
+ Op.new(
41
+ step: op["step"],
42
+ after: op["after"],
43
+ location: op["location"],
44
+ operation_type: op["operation_type"],
45
+ selections: op["selections"],
46
+ variables: op["variables"],
47
+ path: op["path"],
48
+ if_type: op["if_type"],
49
+ boundary: boundary ? GraphQL::Stitching::Boundary.new(**boundary) : nil,
50
+ )
51
+ end
52
+ new(ops: ops)
53
+ end
54
+ end
55
+
56
+ attr_reader :ops
57
+
58
+ def initialize(ops: [])
59
+ @ops = ops
60
+ end
61
+
62
+ def as_json
63
+ { ops: @ops.map(&:as_json) }
64
+ end
65
+ end
66
+ end
67
+ end