graphql-stitching 0.3.6 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,28 +4,25 @@ module GraphQL
4
4
  module Stitching
5
5
  class Planner
6
6
  SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
7
- TYPENAME_NODE = GraphQL::Language::Nodes::Field.new(alias: "_STITCH_typename", name: "__typename")
8
- ROOT_ORDER = 0
7
+ QUERY_OP = "query"
8
+ MUTATION_OP = "mutation"
9
+ ROOT_INDEX = 0
9
10
 
10
11
  def initialize(supergraph:, request:)
11
12
  @supergraph = supergraph
12
13
  @request = request
13
- @planning_order = ROOT_ORDER
14
- @operations_by_entrypoint = {}
14
+ @planning_index = ROOT_INDEX
15
+ @steps_by_entrypoint = {}
15
16
  end
16
17
 
17
18
  def perform
18
19
  build_root_entrypoints
19
20
  expand_abstract_boundaries
20
- self
21
+ Plan.new(ops: steps.map(&:to_plan_op))
21
22
  end
22
23
 
23
- def operations
24
- @operations_by_entrypoint.values.sort_by!(&:order)
25
- end
26
-
27
- def to_h
28
- { "ops" => operations.map!(&:to_h) }
24
+ def steps
25
+ @steps_by_entrypoint.values.sort_by!(&:index)
29
26
  end
30
27
 
31
28
  private
@@ -48,7 +45,7 @@ module GraphQL
48
45
  # B.3) Collect all variable definitions used within the filtered selection.
49
46
  # These specify which request variables to pass along with the selection.
50
47
  #
51
- # B.4) Add a `__typename` selection to concrete types and abstracts that implement
48
+ # B.4) Add a `__typename` selection to abstracts and concrete types that implement
52
49
  # fragments. This provides resolved type information used during execution.
53
50
  #
54
51
  # C) Delegate adjoining selections to new entrypoint locations.
@@ -57,7 +54,7 @@ module GraphQL
57
54
  # C.3) Distribute remaining fields among locations weighted by greatest availability.
58
55
  #
59
56
  # D) Create paths routing to new entrypoint locations via boundary queries.
60
- # D.1) Types joining through multiple keys route using a-star search.
57
+ # D.1) Types joining through multiple keys route using A* search.
61
58
  # D.2) Types joining through a single key route via quick location match.
62
59
  # (D.2 is an optional optimization of D.1)
63
60
  #
@@ -68,38 +65,38 @@ module GraphQL
68
65
  # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
69
66
  # **
70
67
 
71
- # adds an entrypoint for fetching and inserting data into the aggregate result.
72
- def add_entrypoint(
68
+ # adds a planning step for fetching and inserting data into the aggregate result.
69
+ def add_step(
73
70
  location:,
74
- parent_order:,
71
+ parent_index:,
75
72
  parent_type:,
76
73
  selections:,
77
74
  variables: {},
78
75
  path: [],
79
- operation_type: "query",
76
+ operation_type: QUERY_OP,
80
77
  boundary: nil
81
78
  )
82
79
  # coalesce repeat parameters into a single entrypoint
83
- boundary_key = boundary ? boundary["key"] : "_"
84
- entrypoint = String.new("#{parent_order}/#{location}/#{parent_type.graphql_name}/#{boundary_key}")
80
+ boundary_key = boundary ? boundary.key : "_"
81
+ entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{boundary_key}")
85
82
  path.each { entrypoint << "/#{_1}" }
86
83
 
87
- op = @operations_by_entrypoint[entrypoint]
88
- next_order = op ? parent_order : @planning_order += 1
84
+ step = @steps_by_entrypoint[entrypoint]
85
+ next_index = step ? parent_index : @planning_index += 1
89
86
 
90
87
  if selections.any?
91
- selections = extract_locale_selections(location, parent_type, next_order, selections, path, variables)
88
+ selections = extract_locale_selections(location, parent_type, next_index, selections, path, variables)
92
89
  end
93
90
 
94
- if op.nil?
91
+ if step.nil?
95
92
  # concrete types that are not root Query/Mutation report themselves as a type condition
96
93
  # executor must check the __typename of loaded objects to see if they match subsequent operations
97
94
  # this prevents the executor from taking action on unused fragment selections
98
95
  conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.root_type_for_operation(operation_type)
99
96
 
100
- @operations_by_entrypoint[entrypoint] = PlannerOperation.new(
101
- order: next_order,
102
- after: parent_order,
97
+ @steps_by_entrypoint[entrypoint] = PlannerStep.new(
98
+ index: next_index,
99
+ after: parent_index,
103
100
  location: location,
104
101
  parent_type: parent_type,
105
102
  operation_type: operation_type,
@@ -110,57 +107,59 @@ module GraphQL
110
107
  boundary: boundary,
111
108
  )
112
109
  else
113
- op.selections.concat(selections)
114
- op
110
+ step.selections.concat(selections)
111
+ step
115
112
  end
116
113
  end
117
114
 
115
+ ScopePartition = Struct.new(:location, :selections, keyword_init: true)
116
+
118
117
  # A) Group all root selections by their preferred entrypoint locations.
119
118
  def build_root_entrypoints
120
119
  case @request.operation.operation_type
121
- when "query"
120
+ when QUERY_OP
122
121
  # A.1) Group query fields by location for parallel execution.
123
122
  parent_type = @supergraph.schema.query
124
123
 
125
124
  selections_by_location = {}
126
- each_selection_in_type(parent_type, @request.operation.selections) do |node|
125
+ each_field_in_scope(parent_type, @request.operation.selections) do |node|
127
126
  locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
128
127
  selections_by_location[locations.first] ||= []
129
128
  selections_by_location[locations.first] << node
130
129
  end
131
130
 
132
131
  selections_by_location.each do |location, selections|
133
- add_entrypoint(
132
+ add_step(
134
133
  location: location,
135
- parent_order: ROOT_ORDER,
134
+ parent_index: ROOT_INDEX,
136
135
  parent_type: parent_type,
137
136
  selections: selections,
138
137
  )
139
138
  end
140
139
 
141
- when "mutation"
140
+ when MUTATION_OP
142
141
  # A.2) Partition mutation fields by consecutive location for serial execution.
143
142
  parent_type = @supergraph.schema.mutation
144
143
 
145
144
  partitions = []
146
- each_selection_in_type(parent_type, @request.operation.selections) do |node|
145
+ each_field_in_scope(parent_type, @request.operation.selections) do |node|
147
146
  next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
148
147
 
149
- if partitions.none? || partitions.last[:location] != next_location
150
- partitions << { location: next_location, selections: [] }
148
+ if partitions.none? || partitions.last.location != next_location
149
+ partitions << ScopePartition.new(location: next_location, selections: [])
151
150
  end
152
151
 
153
- partitions.last[:selections] << node
152
+ partitions.last.selections << node
154
153
  end
155
154
 
156
- partitions.reduce(ROOT_ORDER) do |parent_order, partition|
157
- add_entrypoint(
158
- location: partition[:location],
159
- parent_order: parent_order,
155
+ partitions.reduce(ROOT_INDEX) do |parent_index, partition|
156
+ add_step(
157
+ location: partition.location,
158
+ parent_index: parent_index,
160
159
  parent_type: parent_type,
161
- selections: partition[:selections],
162
- operation_type: "mutation",
163
- ).order
160
+ selections: partition.selections,
161
+ operation_type: MUTATION_OP,
162
+ ).index
164
163
  end
165
164
 
166
165
  else
@@ -168,7 +167,7 @@ module GraphQL
168
167
  end
169
168
  end
170
169
 
171
- def each_selection_in_type(parent_type, input_selections, &block)
170
+ def each_field_in_scope(parent_type, input_selections, &block)
172
171
  input_selections.each do |node|
173
172
  case node
174
173
  when GraphQL::Language::Nodes::Field
@@ -176,12 +175,12 @@ module GraphQL
176
175
 
177
176
  when GraphQL::Language::Nodes::InlineFragment
178
177
  next unless node.type.nil? || parent_type.graphql_name == node.type.name
179
- each_selection_in_type(parent_type, node.selections, &block)
178
+ each_field_in_scope(parent_type, node.selections, &block)
180
179
 
181
180
  when GraphQL::Language::Nodes::FragmentSpread
182
181
  fragment = @request.fragment_definitions[node.name]
183
182
  next unless parent_type.graphql_name == fragment.type.name
184
- each_selection_in_type(parent_type, fragment.selections, &block)
183
+ each_field_in_scope(parent_type, fragment.selections, &block)
185
184
 
186
185
  else
187
186
  raise "Unexpected node of type #{node.class.name} in selection set."
@@ -193,7 +192,7 @@ module GraphQL
193
192
  def extract_locale_selections(
194
193
  current_location,
195
194
  parent_type,
196
- parent_order,
195
+ parent_index,
197
196
  input_selections,
198
197
  path,
199
198
  locale_variables,
@@ -230,7 +229,7 @@ module GraphQL
230
229
  locale_selections << node
231
230
  else
232
231
  path.push(node.alias || node.name)
233
- selection_set = extract_locale_selections(current_location, field_type, parent_order, node.selections, path, locale_variables)
232
+ selection_set = extract_locale_selections(current_location, field_type, parent_index, node.selections, path, locale_variables)
234
233
  path.pop
235
234
 
236
235
  locale_selections << node.merge(selections: selection_set)
@@ -242,7 +241,7 @@ module GraphQL
242
241
 
243
242
  is_same_scope = fragment_type == parent_type
244
243
  selection_set = is_same_scope ? locale_selections : []
245
- extract_locale_selections(current_location, fragment_type, parent_order, node.selections, path, locale_variables, selection_set)
244
+ extract_locale_selections(current_location, fragment_type, parent_index, node.selections, path, locale_variables, selection_set)
246
245
 
247
246
  unless is_same_scope
248
247
  locale_selections << node.merge(selections: selection_set)
@@ -256,7 +255,7 @@ module GraphQL
256
255
  fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
257
256
  is_same_scope = fragment_type == parent_type
258
257
  selection_set = is_same_scope ? locale_selections : []
259
- extract_locale_selections(current_location, fragment_type, parent_order, fragment.selections, path, locale_variables, selection_set)
258
+ extract_locale_selections(current_location, fragment_type, parent_index, fragment.selections, path, locale_variables, selection_set)
260
259
 
261
260
  unless is_same_scope
262
261
  locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
@@ -268,10 +267,10 @@ module GraphQL
268
267
  end
269
268
  end
270
269
 
271
- # B.4) Add a `__typename` selection to concrete types and abstracts that implement
270
+ # B.4) Add a `__typename` selection to abstracts and concrete types that implement
272
271
  # fragments so that resolved type information is available during execution.
273
272
  if requires_typename
274
- locale_selections << TYPENAME_NODE
273
+ locale_selections << SelectionHint.typename_node
275
274
  end
276
275
 
277
276
  if remote_selections
@@ -285,28 +284,24 @@ module GraphQL
285
284
  routes.each_value do |route|
286
285
  route.reduce(locale_selections) do |parent_selections, boundary|
287
286
  # E.1) Add the key of each boundary query into the prior location's selection set.
288
- foreign_key = "_STITCH_#{boundary["key"]}"
287
+ foreign_key = SelectionHint.key(boundary.key)
289
288
  has_key = false
290
289
  has_typename = false
291
290
 
292
- parent_selections.each do |selection|
293
- next unless selection.is_a?(GraphQL::Language::Nodes::Field)
294
- case selection.alias
295
- when foreign_key
296
- has_key = true
297
- when TYPENAME_NODE.alias
298
- has_typename = true
299
- end
291
+ parent_selections.each do |node|
292
+ next unless node.is_a?(GraphQL::Language::Nodes::Field)
293
+ has_key ||= node.alias == foreign_key
294
+ has_typename ||= node.alias == SelectionHint.typename_node.alias
300
295
  end
301
296
 
302
- parent_selections << GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["key"]) unless has_key
303
- parent_selections << TYPENAME_NODE unless has_typename
297
+ parent_selections << SelectionHint.key_node(boundary.key) unless has_key
298
+ parent_selections << SelectionHint.typename_node unless has_typename
304
299
 
305
300
  # E.2) Add a planner operation for each new entrypoint location.
306
- location = boundary["location"]
307
- add_entrypoint(
301
+ location = boundary.location
302
+ add_step(
308
303
  location: location,
309
- parent_order: parent_order,
304
+ parent_index: parent_index,
310
305
  parent_type: parent_type,
311
306
  selections: remote_selections_by_location[location] || [],
312
307
  path: path.dup,
@@ -328,27 +323,13 @@ module GraphQL
328
323
 
329
324
  expanded_selections = nil
330
325
  input_selections = input_selections.filter_map do |node|
331
- case node
332
- when GraphQL::Language::Nodes::Field
333
- if node.name != "__typename" && !local_interface_fields.include?(node.name)
334
- expanded_selections ||= []
335
- expanded_selections << node
336
- next nil
337
- end
338
-
339
- when GraphQL::Language::Nodes::InlineFragment
340
- fragment_type = node.type ? @supergraph.memoized_schema_types[node.type.name] : parent_type
341
- selection_set = expand_interface_selections(current_location, fragment_type, node.selections)
342
- node = node.merge(selections: selection_set)
343
-
344
- when GraphQL::Language::Nodes::FragmentSpread
345
- fragment = @request.fragment_definitions[node.name]
346
- fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
347
- selection_set = expand_interface_selections(current_location, fragment_type, fragment.selections)
348
- node = GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
349
-
326
+ if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != "__typename" && !local_interface_fields.include?(node.name)
327
+ expanded_selections ||= []
328
+ expanded_selections << node
329
+ nil
330
+ else
331
+ node
350
332
  end
351
- node
352
333
  end
353
334
 
354
335
  if expanded_selections
@@ -446,10 +427,10 @@ module GraphQL
446
427
 
447
428
  # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
448
429
  def expand_abstract_boundaries
449
- @operations_by_entrypoint.each_value do |op|
430
+ @steps_by_entrypoint.each_value do |op|
450
431
  next unless op.boundary
451
432
 
452
- boundary_type = @supergraph.memoized_schema_types[op.boundary["type_name"]]
433
+ boundary_type = @supergraph.memoized_schema_types[op.boundary.type_name]
453
434
  next unless boundary_type.kind.abstract?
454
435
  next if boundary_type == op.parent_type
455
436
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class PlannerStep
6
+ GRAPHQL_PRINTER = GraphQL::Language::Printer.new
7
+
8
+ attr_reader :index, :location, :parent_type, :if_type, :operation_type, :path
9
+ attr_accessor :after, :selections, :variables, :boundary
10
+
11
+ def initialize(
12
+ location:,
13
+ parent_type:,
14
+ index:,
15
+ after: nil,
16
+ operation_type: "query",
17
+ selections: [],
18
+ variables: {},
19
+ path: [],
20
+ if_type: nil,
21
+ boundary: nil
22
+ )
23
+ @location = location
24
+ @parent_type = parent_type
25
+ @index = index
26
+ @after = after
27
+ @operation_type = operation_type
28
+ @selections = selections
29
+ @variables = variables
30
+ @path = path
31
+ @if_type = if_type
32
+ @boundary = boundary
33
+ end
34
+
35
+ def to_plan_op
36
+ GraphQL::Stitching::Plan::Op.new(
37
+ step: @index,
38
+ after: @after,
39
+ location: @location,
40
+ operation_type: @operation_type,
41
+ selections: rendered_selections,
42
+ variables: rendered_variables,
43
+ path: @path,
44
+ if_type: @if_type,
45
+ boundary: @boundary,
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ def rendered_selections
52
+ op = GraphQL::Language::Nodes::OperationDefinition.new(operation_type: "", selections: @selections)
53
+ GRAPHQL_PRINTER.print(op).gsub!(/\s+/, " ").strip!
54
+ end
55
+
56
+ def rendered_variables
57
+ @variables.each_with_object({}) do |(variable_name, value_type), memo|
58
+ memo[variable_name] = GRAPHQL_PRINTER.print(value_type)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -5,54 +5,6 @@ module GraphQL
5
5
  class Request
6
6
  SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
7
7
 
8
- class ApplyRuntimeDirectives < GraphQL::Language::Visitor
9
- def initialize(document, variables)
10
- @changed = false
11
- @variables = variables
12
- super(document)
13
- end
14
-
15
- def changed?
16
- @changed
17
- end
18
-
19
- def on_field(node, parent)
20
- delete_node = false
21
- filtered_directives = if node.directives.any?
22
- node.directives.select do |directive|
23
- if directive.name == "skip"
24
- delete_node = assess_argument_value(directive.arguments.first)
25
- false
26
- elsif directive.name == "include"
27
- delete_node = !assess_argument_value(directive.arguments.first)
28
- false
29
- else
30
- true
31
- end
32
- end
33
- end
34
-
35
- if delete_node
36
- @changed = true
37
- super(DELETE_NODE, parent)
38
- elsif filtered_directives && filtered_directives.length != node.directives.length
39
- @changed = true
40
- super(node.merge(directives: filtered_directives), parent)
41
- else
42
- super
43
- end
44
- end
45
-
46
- private
47
-
48
- def assess_argument_value(arg)
49
- if arg.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
50
- return @variables[arg.value.name]
51
- end
52
- arg.value
53
- end
54
- end
55
-
56
8
  attr_reader :document, :variables, :operation_name, :context
57
9
 
58
10
  def initialize(document, operation_name: nil, variables: nil, context: nil)
@@ -114,10 +66,9 @@ module GraphQL
114
66
  end
115
67
 
116
68
  if @may_contain_runtime_directives
117
- visitor = ApplyRuntimeDirectives.new(@document, @variables)
118
- @document = visitor.visit
69
+ @document, modified = SkipInclude.render(@document, @variables)
119
70
 
120
- if visitor.changed?
71
+ if modified
121
72
  @string = nil
122
73
  @digest = nil
123
74
  @operation = nil
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class SelectionHint
6
+ HINT_PREFIX = "_STITCH_"
7
+
8
+ class << self
9
+ def key?(name)
10
+ return false unless name
11
+
12
+ name.start_with?(HINT_PREFIX)
13
+ end
14
+
15
+ def key(name)
16
+ "#{HINT_PREFIX}#{name}"
17
+ end
18
+
19
+ def key_node(field_name)
20
+ GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
21
+ end
22
+
23
+ def typename_node
24
+ @typename_node ||= key_node("__typename")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -19,8 +19,8 @@ module GraphQL
19
19
  def resolve_object_scope(raw_object, parent_type, selections, typename = nil)
20
20
  return nil if raw_object.nil?
21
21
 
22
- typename ||= raw_object["_STITCH_typename"]
23
- raw_object.reject! { |k, _v| k.start_with?("_STITCH_") }
22
+ typename ||= raw_object[SelectionHint.typename_node.alias]
23
+ raw_object.reject! { |key, _v| SelectionHint.key?(key) }
24
24
 
25
25
  selections.each do |node|
26
26
  case node
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class SkipInclude
6
+ class << self
7
+ # Faster implementation of an AST visitor for prerendering
8
+ # @skip and @include conditional directives into a document.
9
+ # This avoids unnecessary planning steps, and prepares result shaping.
10
+ def render(document, variables)
11
+ changed = false
12
+ definitions = document.definitions.map do |original_definition|
13
+ definition = render_node(original_definition, variables)
14
+ changed ||= definition.object_id != original_definition.object_id
15
+ definition
16
+ end
17
+
18
+ return document.merge(definitions: definitions), changed
19
+ end
20
+
21
+ private
22
+
23
+ def render_node(parent_node, variables)
24
+ changed = false
25
+ filtered_selections = parent_node.selections.filter_map do |original_node|
26
+ node = prune_node(original_node, variables)
27
+ if node.nil?
28
+ changed = true
29
+ next nil
30
+ end
31
+
32
+ node = render_node(node, variables) if node.selections.any?
33
+ changed ||= node.object_id != original_node.object_id
34
+ node
35
+ end
36
+
37
+ if filtered_selections.none?
38
+ filtered_selections << SelectionHint.typename_node
39
+ end
40
+
41
+ if changed
42
+ parent_node.merge(selections: filtered_selections)
43
+ else
44
+ parent_node
45
+ end
46
+ end
47
+
48
+ def prune_node(node, variables)
49
+ return node unless node.directives.any?
50
+
51
+ delete_node = false
52
+ filtered_directives = node.directives.reject do |directive|
53
+ if directive.name == "skip"
54
+ delete_node = assess_condition(directive.arguments.first, variables)
55
+ true
56
+ elsif directive.name == "include"
57
+ delete_node = !assess_condition(directive.arguments.first, variables)
58
+ true
59
+ end
60
+ end
61
+
62
+ if delete_node
63
+ nil
64
+ elsif filtered_directives.length != node.directives.length
65
+ node.merge(directives: filtered_directives)
66
+ else
67
+ node
68
+ end
69
+ end
70
+
71
+ def assess_condition(arg, variables)
72
+ if arg.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
73
+ variables[arg.value.name] || variables[arg.value.name.to_sym]
74
+ else
75
+ arg.value
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end