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.
@@ -4,28 +4,26 @@ 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
+ TYPENAME = "__typename"
8
+ QUERY_OP = "query"
9
+ MUTATION_OP = "mutation"
10
+ ROOT_INDEX = 0
9
11
 
10
12
  def initialize(supergraph:, request:)
11
13
  @supergraph = supergraph
12
14
  @request = request
13
- @planning_order = ROOT_ORDER
14
- @operations_by_entrypoint = {}
15
+ @planning_index = ROOT_INDEX
16
+ @steps_by_entrypoint = {}
15
17
  end
16
18
 
17
19
  def perform
18
20
  build_root_entrypoints
19
21
  expand_abstract_boundaries
20
- self
22
+ Plan.new(ops: steps.map(&:to_plan_op))
21
23
  end
22
24
 
23
- def operations
24
- @operations_by_entrypoint.values.sort_by!(&:order)
25
- end
26
-
27
- def to_h
28
- { "ops" => operations.map!(&:to_h) }
25
+ def steps
26
+ @steps_by_entrypoint.values.sort_by!(&:index)
29
27
  end
30
28
 
31
29
  private
@@ -38,18 +36,14 @@ module GraphQL
38
36
  # A.2) Partition mutation fields by consecutive location for serial execution.
39
37
  #
40
38
  # B) Extract contiguous selections for each entrypoint location.
41
- #
42
39
  # B.1) Selections on interface types that do not belong to the interface at the
43
- # entrypoint location are expanded into concrete type fragments prior to extraction.
44
- #
40
+ # entrypoint location are expanded into concrete type fragments prior to extraction.
45
41
  # B.2) Filter the selection tree down to just fields of the entrypoint location.
46
- # Adjoining selections not available here get split off into new entrypoints (C).
47
- #
42
+ # Adjoining selections not available here get split off into new entrypoints (C).
48
43
  # B.3) Collect all variable definitions used within the filtered selection.
49
- # These specify which request variables to pass along with the selection.
50
- #
51
- # B.4) Add a `__typename` selection to concrete types and abstracts that implement
52
- # 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.
53
47
  #
54
48
  # C) Delegate adjoining selections to new entrypoint locations.
55
49
  # C.1) Distribute unique fields among their required locations.
@@ -57,110 +51,105 @@ module GraphQL
57
51
  # C.3) Distribute remaining fields among locations weighted by greatest availability.
58
52
  #
59
53
  # D) Create paths routing to new entrypoint locations via boundary queries.
60
- # D.1) Types joining through multiple keys route using a-star search.
54
+ # D.1) Types joining through multiple keys route using A* search.
61
55
  # D.2) Types joining through a single key route via quick location match.
62
56
  # (D.2 is an optional optimization of D.1)
63
57
  #
64
58
  # E) Translate boundary pathways into new entrypoints.
65
59
  # E.1) Add the key of each boundary query into the prior location's selection set.
66
- # E.2) Add a planner operation for each new entrypoint location, then extract it (B).
60
+ # E.2) Add a planner step for each new entrypoint location, then extract it (B).
67
61
  #
68
62
  # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
69
63
  # **
70
64
 
71
- # adds an entrypoint for fetching and inserting data into the aggregate result.
72
- def add_entrypoint(
65
+ # adds a planning step for fetching and inserting data into the aggregate result.
66
+ def add_step(
73
67
  location:,
74
- parent_order:,
68
+ parent_index:,
75
69
  parent_type:,
76
70
  selections:,
77
71
  variables: {},
78
72
  path: [],
79
- operation_type: "query",
73
+ operation_type: QUERY_OP,
80
74
  boundary: nil
81
75
  )
82
76
  # 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}")
77
+ entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{boundary&.key}")
85
78
  path.each { entrypoint << "/#{_1}" }
86
79
 
87
- op = @operations_by_entrypoint[entrypoint]
88
- next_order = op ? parent_order : @planning_order += 1
80
+ step = @steps_by_entrypoint[entrypoint]
81
+ next_index = step ? parent_index : @planning_index += 1
89
82
 
90
83
  if selections.any?
91
- selections = extract_locale_selections(location, parent_type, next_order, selections, path, variables)
84
+ selections = extract_locale_selections(location, parent_type, next_index, selections, path, variables)
92
85
  end
93
86
 
94
- if op.nil?
95
- # concrete types that are not root Query/Mutation report themselves as a type condition
96
- # executor must check the __typename of loaded objects to see if they match subsequent operations
97
- # this prevents the executor from taking action on unused fragment selections
98
- conditional = !parent_type.kind.abstract? && parent_type != @supergraph.schema.root_type_for_operation(operation_type)
99
-
100
- @operations_by_entrypoint[entrypoint] = PlannerOperation.new(
101
- order: next_order,
102
- after: parent_order,
87
+ if step.nil?
88
+ @steps_by_entrypoint[entrypoint] = PlannerStep.new(
89
+ index: next_index,
90
+ after: parent_index,
103
91
  location: location,
104
92
  parent_type: parent_type,
105
93
  operation_type: operation_type,
106
94
  selections: selections,
107
95
  variables: variables,
108
96
  path: path,
109
- if_type: conditional ? parent_type.graphql_name : nil,
110
97
  boundary: boundary,
111
98
  )
112
99
  else
113
- op.selections.concat(selections)
114
- op
100
+ step.selections.concat(selections)
101
+ step
115
102
  end
116
103
  end
117
104
 
105
+ ScopePartition = Struct.new(:location, :selections, keyword_init: true)
106
+
118
107
  # A) Group all root selections by their preferred entrypoint locations.
119
108
  def build_root_entrypoints
120
109
  case @request.operation.operation_type
121
- when "query"
110
+ when QUERY_OP
122
111
  # A.1) Group query fields by location for parallel execution.
123
112
  parent_type = @supergraph.schema.query
124
113
 
125
114
  selections_by_location = {}
126
- each_selection_in_type(parent_type, @request.operation.selections) do |node|
115
+ each_field_in_scope(parent_type, @request.operation.selections) do |node|
127
116
  locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
128
117
  selections_by_location[locations.first] ||= []
129
118
  selections_by_location[locations.first] << node
130
119
  end
131
120
 
132
121
  selections_by_location.each do |location, selections|
133
- add_entrypoint(
122
+ add_step(
134
123
  location: location,
135
- parent_order: ROOT_ORDER,
124
+ parent_index: ROOT_INDEX,
136
125
  parent_type: parent_type,
137
126
  selections: selections,
138
127
  )
139
128
  end
140
129
 
141
- when "mutation"
130
+ when MUTATION_OP
142
131
  # A.2) Partition mutation fields by consecutive location for serial execution.
143
132
  parent_type = @supergraph.schema.mutation
144
133
 
145
134
  partitions = []
146
- each_selection_in_type(parent_type, @request.operation.selections) do |node|
135
+ each_field_in_scope(parent_type, @request.operation.selections) do |node|
147
136
  next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
148
137
 
149
- if partitions.none? || partitions.last[:location] != next_location
150
- partitions << { location: next_location, selections: [] }
138
+ if partitions.none? || partitions.last.location != next_location
139
+ partitions << ScopePartition.new(location: next_location, selections: [])
151
140
  end
152
141
 
153
- partitions.last[:selections] << node
142
+ partitions.last.selections << node
154
143
  end
155
144
 
156
- partitions.reduce(ROOT_ORDER) do |parent_order, partition|
157
- add_entrypoint(
158
- location: partition[:location],
159
- parent_order: parent_order,
145
+ partitions.reduce(ROOT_INDEX) do |parent_index, partition|
146
+ add_step(
147
+ location: partition.location,
148
+ parent_index: parent_index,
160
149
  parent_type: parent_type,
161
- selections: partition[:selections],
162
- operation_type: "mutation",
163
- ).order
150
+ selections: partition.selections,
151
+ operation_type: MUTATION_OP,
152
+ ).index
164
153
  end
165
154
 
166
155
  else
@@ -168,7 +157,7 @@ module GraphQL
168
157
  end
169
158
  end
170
159
 
171
- def each_selection_in_type(parent_type, input_selections, &block)
160
+ def each_field_in_scope(parent_type, input_selections, &block)
172
161
  input_selections.each do |node|
173
162
  case node
174
163
  when GraphQL::Language::Nodes::Field
@@ -176,12 +165,12 @@ module GraphQL
176
165
 
177
166
  when GraphQL::Language::Nodes::InlineFragment
178
167
  next unless node.type.nil? || parent_type.graphql_name == node.type.name
179
- each_selection_in_type(parent_type, node.selections, &block)
168
+ each_field_in_scope(parent_type, node.selections, &block)
180
169
 
181
170
  when GraphQL::Language::Nodes::FragmentSpread
182
171
  fragment = @request.fragment_definitions[node.name]
183
172
  next unless parent_type.graphql_name == fragment.type.name
184
- each_selection_in_type(parent_type, fragment.selections, &block)
173
+ each_field_in_scope(parent_type, fragment.selections, &block)
185
174
 
186
175
  else
187
176
  raise "Unexpected node of type #{node.class.name} in selection set."
@@ -193,7 +182,7 @@ module GraphQL
193
182
  def extract_locale_selections(
194
183
  current_location,
195
184
  parent_type,
196
- parent_order,
185
+ parent_index,
197
186
  input_selections,
198
187
  path,
199
188
  locale_variables,
@@ -210,7 +199,7 @@ module GraphQL
210
199
  input_selections.each do |node|
211
200
  case node
212
201
  when GraphQL::Language::Nodes::Field
213
- if node.name == "__typename"
202
+ if node.name == TYPENAME
214
203
  locale_selections << node
215
204
  next
216
205
  end
@@ -230,7 +219,7 @@ module GraphQL
230
219
  locale_selections << node
231
220
  else
232
221
  path.push(node.alias || node.name)
233
- selection_set = extract_locale_selections(current_location, field_type, parent_order, node.selections, path, locale_variables)
222
+ selection_set = extract_locale_selections(current_location, field_type, parent_index, node.selections, path, locale_variables)
234
223
  path.pop
235
224
 
236
225
  locale_selections << node.merge(selections: selection_set)
@@ -242,7 +231,7 @@ module GraphQL
242
231
 
243
232
  is_same_scope = fragment_type == parent_type
244
233
  selection_set = is_same_scope ? locale_selections : []
245
- extract_locale_selections(current_location, fragment_type, parent_order, node.selections, path, locale_variables, selection_set)
234
+ extract_locale_selections(current_location, fragment_type, parent_index, node.selections, path, locale_variables, selection_set)
246
235
 
247
236
  unless is_same_scope
248
237
  locale_selections << node.merge(selections: selection_set)
@@ -256,7 +245,7 @@ module GraphQL
256
245
  fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
257
246
  is_same_scope = fragment_type == parent_type
258
247
  selection_set = is_same_scope ? locale_selections : []
259
- extract_locale_selections(current_location, fragment_type, parent_order, fragment.selections, path, locale_variables, selection_set)
248
+ extract_locale_selections(current_location, fragment_type, parent_index, fragment.selections, path, locale_variables, selection_set)
260
249
 
261
250
  unless is_same_scope
262
251
  locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
@@ -268,10 +257,10 @@ module GraphQL
268
257
  end
269
258
  end
270
259
 
271
- # B.4) Add a `__typename` selection to concrete types and abstracts that implement
260
+ # B.4) Add a `__typename` hint to abstracts and types that implement
272
261
  # fragments so that resolved type information is available during execution.
273
262
  if requires_typename
274
- locale_selections << TYPENAME_NODE
263
+ locale_selections << SelectionHint.typename_node
275
264
  end
276
265
 
277
266
  if remote_selections
@@ -285,30 +274,25 @@ module GraphQL
285
274
  routes.each_value do |route|
286
275
  route.reduce(locale_selections) do |parent_selections, boundary|
287
276
  # E.1) Add the key of each boundary query into the prior location's selection set.
288
- foreign_key = "_STITCH_#{boundary["key"]}"
277
+ foreign_key = SelectionHint.key(boundary.key)
289
278
  has_key = false
290
279
  has_typename = false
291
280
 
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
281
+ parent_selections.each do |node|
282
+ next unless node.is_a?(GraphQL::Language::Nodes::Field)
283
+ has_key ||= node.alias == foreign_key
284
+ has_typename ||= node.alias == SelectionHint.typename_node.alias
300
285
  end
301
286
 
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
287
+ parent_selections << SelectionHint.key_node(boundary.key) unless has_key
288
+ parent_selections << SelectionHint.typename_node unless has_typename
304
289
 
305
- # E.2) Add a planner operation for each new entrypoint location.
306
- location = boundary["location"]
307
- add_entrypoint(
308
- location: location,
309
- parent_order: parent_order,
290
+ # E.2) Add a planner step for each new entrypoint location.
291
+ add_step(
292
+ location: boundary.location,
293
+ parent_index: parent_index,
310
294
  parent_type: parent_type,
311
- selections: remote_selections_by_location[location] || [],
295
+ selections: remote_selections_by_location[boundary.location] || [],
312
296
  path: path.dup,
313
297
  boundary: boundary,
314
298
  ).selections
@@ -328,27 +312,13 @@ module GraphQL
328
312
 
329
313
  expanded_selections = nil
330
314
  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
-
315
+ if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != TYPENAME && !local_interface_fields.include?(node.name)
316
+ expanded_selections ||= []
317
+ expanded_selections << node
318
+ nil
319
+ else
320
+ node
350
321
  end
351
- node
352
322
  end
353
323
 
354
324
  if expanded_selections
@@ -364,7 +334,7 @@ module GraphQL
364
334
  end
365
335
 
366
336
  # B.3) Collect all variable definitions used within the filtered selection.
367
- # These specify which request variables to pass along with the selection.
337
+ # These specify which request variables to pass along with each step.
368
338
  def extract_node_variables(node_with_args, variable_definitions)
369
339
  node_with_args.arguments.each do |argument|
370
340
  case argument.value
@@ -410,15 +380,11 @@ module GraphQL
410
380
 
411
381
  # C.3) Distribute remaining fields among locations weighted by greatest availability.
412
382
  if remote_selections.any?
413
- field_count_by_location = if remote_selections.length > 1
414
- remote_selections.each_with_object({}) do |node, memo|
415
- possible_locations_by_field[node.name].each do |location|
416
- memo[location] ||= 0
417
- memo[location] += 1
418
- 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
419
387
  end
420
- else
421
- GraphQL::Stitching::EMPTY_OBJECT
422
388
  end
423
389
 
424
390
  remote_selections.each do |node|
@@ -426,11 +392,11 @@ module GraphQL
426
392
  preferred_location = possible_locations.first
427
393
 
428
394
  possible_locations.reduce(0) do |max_availability, possible_location|
429
- available_fields = field_count_by_location.fetch(possible_location, 0)
395
+ availability = field_count_by_location.fetch(possible_location, 0)
430
396
 
431
- if available_fields > max_availability
397
+ if availability > max_availability
432
398
  preferred_location = possible_location
433
- available_fields
399
+ availability
434
400
  else
435
401
  max_availability
436
402
  end
@@ -446,15 +412,15 @@ module GraphQL
446
412
 
447
413
  # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
448
414
  def expand_abstract_boundaries
449
- @operations_by_entrypoint.each_value do |op|
450
- next unless op.boundary
415
+ @steps_by_entrypoint.each_value do |step|
416
+ next unless step.boundary
451
417
 
452
- boundary_type = @supergraph.memoized_schema_types[op.boundary["type_name"]]
418
+ boundary_type = @supergraph.memoized_schema_types[step.boundary.type_name]
453
419
  next unless boundary_type.kind.abstract?
454
- next if boundary_type == op.parent_type
420
+ next if boundary_type == step.parent_type
455
421
 
456
422
  expanded_selections = nil
457
- op.selections.reject! do |node|
423
+ step.selections.reject! do |node|
458
424
  if node.is_a?(GraphQL::Language::Nodes::Field)
459
425
  expanded_selections ||= []
460
426
  expanded_selections << node
@@ -463,8 +429,8 @@ module GraphQL
463
429
  end
464
430
 
465
431
  if expanded_selections
466
- type_name = GraphQL::Language::Nodes::TypeName.new(name: op.parent_type.graphql_name)
467
- op.selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
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)
468
434
  end
469
435
  end
470
436
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
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.
8
+ class PlannerStep
9
+ GRAPHQL_PRINTER = GraphQL::Language::Printer.new
10
+
11
+ attr_reader :index, :location, :parent_type, :operation_type, :path
12
+ attr_accessor :after, :selections, :variables, :boundary
13
+
14
+ def initialize(
15
+ location:,
16
+ parent_type:,
17
+ index:,
18
+ after: nil,
19
+ operation_type: "query",
20
+ selections: [],
21
+ variables: {},
22
+ path: [],
23
+ boundary: nil
24
+ )
25
+ @location = location
26
+ @parent_type = parent_type
27
+ @index = index
28
+ @after = after
29
+ @operation_type = operation_type
30
+ @selections = selections
31
+ @variables = variables
32
+ @path = path
33
+ @boundary = boundary
34
+ end
35
+
36
+ def to_plan_op
37
+ GraphQL::Stitching::Plan::Op.new(
38
+ step: @index,
39
+ after: @after,
40
+ location: @location,
41
+ operation_type: @operation_type,
42
+ selections: rendered_selections,
43
+ variables: rendered_variables,
44
+ path: @path,
45
+ if_type: type_condition,
46
+ boundary: @boundary,
47
+ )
48
+ end
49
+
50
+ private
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
+
59
+ def rendered_selections
60
+ op = GraphQL::Language::Nodes::OperationDefinition.new(operation_type: "", selections: @selections)
61
+ GRAPHQL_PRINTER.print(op).gsub!(/\s+/, " ").strip!
62
+ end
63
+
64
+ def rendered_variables
65
+ @variables.each_with_object({}) do |(variable_name, value_type), memo|
66
+ memo[variable_name] = GRAPHQL_PRINTER.print(value_type)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ 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)
@@ -96,6 +48,13 @@ module GraphQL
96
48
  end
97
49
  end
98
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
+
99
58
  def variable_definitions
100
59
  @variable_definitions ||= operation.variables.each_with_object({}) do |v, memo|
101
60
  memo[v.name] = v.type
@@ -114,10 +73,9 @@ module GraphQL
114
73
  end
115
74
 
116
75
  if @may_contain_runtime_directives
117
- visitor = ApplyRuntimeDirectives.new(@document, @variables)
118
- @document = visitor.visit
76
+ @document, modified = SkipInclude.render(@document, @variables)
119
77
 
120
- if visitor.changed?
78
+ if modified
121
79
  @string = nil
122
80
  @digest = nil
123
81
  @operation = nil
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ # Builds hidden selection fields added by stitiching code,
6
+ # used to request operational data about resolved objects.
7
+ class SelectionHint
8
+ HINT_PREFIX = "_STITCH_"
9
+
10
+ class << self
11
+ def key?(name)
12
+ return false unless name
13
+
14
+ name.start_with?(HINT_PREFIX)
15
+ end
16
+
17
+ def key(name)
18
+ "#{HINT_PREFIX}#{name}"
19
+ end
20
+
21
+ def key_node(field_name)
22
+ GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
23
+ end
24
+
25
+ def typename_node
26
+ @typename_node ||= key_node("__typename")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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
@@ -19,8 +21,8 @@ module GraphQL
19
21
  def resolve_object_scope(raw_object, parent_type, selections, typename = nil)
20
22
  return nil if raw_object.nil?
21
23
 
22
- typename ||= raw_object["_STITCH_typename"]
23
- raw_object.reject! { |k, _v| k.start_with?("_STITCH_") }
24
+ typename ||= raw_object[SelectionHint.typename_node.alias]
25
+ raw_object.reject! { |key, _v| SelectionHint.key?(key) }
24
26
 
25
27
  selections.each do |node|
26
28
  case node