graphql-stitching 1.0.0 → 1.0.2

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,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