graphql-stitching 1.0.0 → 1.0.1

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.
@@ -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