graphql-stitching 1.2.5 → 1.4.0

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -17
  3. data/docs/README.md +2 -1
  4. data/docs/mechanics.md +2 -1
  5. data/docs/resolver.md +101 -0
  6. data/lib/graphql/stitching/client.rb +5 -1
  7. data/lib/graphql/stitching/composer/{boundary_config.rb → resolver_config.rb} +18 -13
  8. data/lib/graphql/stitching/composer/validate_interfaces.rb +4 -4
  9. data/lib/graphql/stitching/composer/validate_resolvers.rb +97 -0
  10. data/lib/graphql/stitching/composer.rb +107 -112
  11. data/lib/graphql/stitching/executor/{boundary_source.rb → resolver_source.rb} +40 -32
  12. data/lib/graphql/stitching/executor.rb +3 -3
  13. data/lib/graphql/stitching/plan.rb +3 -4
  14. data/lib/graphql/stitching/planner.rb +30 -41
  15. data/lib/graphql/stitching/planner_step.rb +6 -6
  16. data/lib/graphql/stitching/resolver/arguments.rb +284 -0
  17. data/lib/graphql/stitching/resolver/keys.rb +206 -0
  18. data/lib/graphql/stitching/resolver.rb +70 -0
  19. data/lib/graphql/stitching/shaper.rb +3 -3
  20. data/lib/graphql/stitching/skip_include.rb +1 -1
  21. data/lib/graphql/stitching/supergraph/key_directive.rb +13 -0
  22. data/lib/graphql/stitching/supergraph/resolver_directive.rb +4 -4
  23. data/lib/graphql/stitching/supergraph/to_definition.rb +165 -0
  24. data/lib/graphql/stitching/supergraph.rb +31 -144
  25. data/lib/graphql/stitching/util.rb +28 -0
  26. data/lib/graphql/stitching/version.rb +1 -1
  27. data/lib/graphql/stitching.rb +3 -2
  28. metadata +11 -7
  29. data/lib/graphql/stitching/boundary.rb +0 -29
  30. data/lib/graphql/stitching/composer/validate_boundaries.rb +0 -96
  31. data/lib/graphql/stitching/export_selection.rb +0 -42
@@ -18,7 +18,7 @@ module GraphQL
18
18
 
19
19
  def perform
20
20
  build_root_entrypoints
21
- expand_abstract_boundaries
21
+ expand_abstract_resolvers
22
22
  Plan.new(ops: steps.map(&:to_plan_op))
23
23
  end
24
24
 
@@ -50,16 +50,16 @@ module GraphQL
50
50
  # C.2) Distribute non-unique fields among locations that were added during C.1.
51
51
  # C.3) Distribute remaining fields among locations weighted by greatest availability.
52
52
  #
53
- # D) Create paths routing to new entrypoint locations via boundary queries.
53
+ # D) Create paths routing to new entrypoint locations via resolver queries.
54
54
  # D.1) Types joining through multiple keys route using A* search.
55
55
  # D.2) Types joining through a single key route via quick location match.
56
56
  # (D.2 is an optional optimization of D.1)
57
57
  #
58
- # E) Translate boundary pathways into new entrypoints.
59
- # E.1) Add the key of each boundary query into the prior location's selection set.
58
+ # E) Translate resolver pathways into new entrypoints.
59
+ # E.1) Add the key of each resolver query into the prior location's selection set.
60
60
  # E.2) Add a planner step for each new entrypoint location, then extract it (B).
61
61
  #
62
- # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
62
+ # F) Wrap concrete selections targeting abstract resolvers in typed fragments.
63
63
  # **
64
64
 
65
65
  # adds a planning step for fetching and inserting data into the aggregate result.
@@ -71,10 +71,10 @@ module GraphQL
71
71
  variables: {},
72
72
  path: [],
73
73
  operation_type: QUERY_OP,
74
- boundary: nil
74
+ resolver: nil
75
75
  )
76
76
  # coalesce repeat parameters into a single entrypoint
77
- entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{boundary&.key}")
77
+ entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}")
78
78
  path.each { entrypoint << "/#{_1}" }
79
79
 
80
80
  step = @steps_by_entrypoint[entrypoint]
@@ -94,7 +94,7 @@ module GraphQL
94
94
  selections: selections,
95
95
  variables: variables,
96
96
  path: path,
97
- boundary: boundary,
97
+ resolver: resolver,
98
98
  )
99
99
  else
100
100
  step.selections.concat(selections)
@@ -153,7 +153,7 @@ module GraphQL
153
153
  end
154
154
 
155
155
  else
156
- raise "Invalid operation type."
156
+ raise StitchingError, "Invalid operation type."
157
157
  end
158
158
  end
159
159
 
@@ -173,7 +173,7 @@ module GraphQL
173
173
  each_field_in_scope(parent_type, fragment.selections, &block)
174
174
 
175
175
  else
176
- raise "Unexpected node of type #{node.class.name} in selection set."
176
+ raise StitchingError, "Unexpected node of type #{node.class.name} in selection set."
177
177
  end
178
178
  end
179
179
  end
@@ -199,8 +199,8 @@ module GraphQL
199
199
  input_selections.each do |node|
200
200
  case node
201
201
  when GraphQL::Language::Nodes::Field
202
- if node.alias&.start_with?(ExportSelection::EXPORT_PREFIX)
203
- raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{ExportSelection::EXPORT_PREFIX}" is a reserved prefix.)
202
+ if node.alias&.start_with?(Resolver::EXPORT_PREFIX)
203
+ raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{Resolver::EXPORT_PREFIX}" is a reserved prefix.)
204
204
  elsif node.name == TYPENAME
205
205
  locale_selections << node
206
206
  next
@@ -255,53 +255,42 @@ module GraphQL
255
255
  end
256
256
 
257
257
  else
258
- raise "Unexpected node of type #{node.class.name} in selection set."
258
+ raise StitchingError, "Unexpected node of type #{node.class.name} in selection set."
259
259
  end
260
260
  end
261
261
 
262
262
  # B.4) Add a `__typename` export to abstracts and types that implement
263
263
  # fragments so that resolved type information is available during execution.
264
264
  if requires_typename
265
- locale_selections << ExportSelection.typename_node
265
+ locale_selections << Resolver::TYPENAME_EXPORT_NODE
266
266
  end
267
267
 
268
268
  if remote_selections
269
269
  # C) Delegate adjoining selections to new entrypoint locations.
270
270
  remote_selections_by_location = delegate_remote_selections(parent_type, remote_selections)
271
271
 
272
- # D) Create paths routing to new entrypoint locations via boundary queries.
272
+ # D) Create paths routing to new entrypoint locations via resolver queries.
273
273
  routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, remote_selections_by_location.keys)
274
274
 
275
- # E) Translate boundary pathways into new entrypoints.
275
+ # E) Translate resolver pathways into new entrypoints.
276
276
  routes.each_value do |route|
277
- route.reduce(locale_selections) do |parent_selections, boundary|
278
- # E.1) Add the key of each boundary query into the prior location's selection set.
279
- if boundary.key
280
- foreign_key = ExportSelection.key(boundary.key)
281
- has_key = false
282
- has_typename = false
283
-
284
- parent_selections.each do |node|
285
- next unless node.is_a?(GraphQL::Language::Nodes::Field)
286
- has_key ||= node.alias == foreign_key
287
- has_typename ||= node.alias == ExportSelection.typename_node.alias
288
- end
289
-
290
- parent_selections << ExportSelection.key_node(boundary.key) unless has_key
291
- parent_selections << ExportSelection.typename_node unless has_typename
292
- end
277
+ route.reduce(locale_selections) do |parent_selections, resolver|
278
+ # E.1) Add the key of each resolver query into the prior location's selection set.
279
+ parent_selections.push(*resolver.key.export_nodes) if resolver.key
293
280
 
294
281
  # E.2) Add a planner step for each new entrypoint location.
295
282
  add_step(
296
- location: boundary.location,
283
+ location: resolver.location,
297
284
  parent_index: parent_index,
298
285
  parent_type: parent_type,
299
- selections: remote_selections_by_location[boundary.location] || [],
286
+ selections: remote_selections_by_location[resolver.location] || [],
300
287
  path: path.dup,
301
- boundary: boundary.key ? boundary : nil,
288
+ resolver: resolver.key ? resolver : nil,
302
289
  ).selections
303
290
  end
304
291
  end
292
+
293
+ locale_selections.uniq! { _1.alias || _1.name }
305
294
  end
306
295
 
307
296
  locale_selections
@@ -414,14 +403,14 @@ module GraphQL
414
403
  selections_by_location
415
404
  end
416
405
 
417
- # F) Wrap concrete selections targeting abstract boundaries in typed fragments.
418
- def expand_abstract_boundaries
406
+ # F) Wrap concrete selections targeting abstract resolvers in typed fragments.
407
+ def expand_abstract_resolvers
419
408
  @steps_by_entrypoint.each_value do |step|
420
- next unless step.boundary
409
+ next unless step.resolver
421
410
 
422
- boundary_type = @supergraph.memoized_schema_types[step.boundary.type_name]
423
- next unless boundary_type.kind.abstract?
424
- next if boundary_type == step.parent_type
411
+ resolver_type = @supergraph.memoized_schema_types[step.resolver.type_name]
412
+ next unless resolver_type.kind.abstract?
413
+ next if resolver_type == step.parent_type
425
414
 
426
415
  expanded_selections = nil
427
416
  step.selections.reject! do |node|
@@ -9,7 +9,7 @@ module GraphQL
9
9
  GRAPHQL_PRINTER = GraphQL::Language::Printer.new
10
10
 
11
11
  attr_reader :index, :location, :parent_type, :operation_type, :path
12
- attr_accessor :after, :selections, :variables, :boundary
12
+ attr_accessor :after, :selections, :variables, :resolver
13
13
 
14
14
  def initialize(
15
15
  location:,
@@ -20,7 +20,7 @@ module GraphQL
20
20
  selections: [],
21
21
  variables: {},
22
22
  path: [],
23
- boundary: nil
23
+ resolver: nil
24
24
  )
25
25
  @location = location
26
26
  @parent_type = parent_type
@@ -30,7 +30,7 @@ module GraphQL
30
30
  @selections = selections
31
31
  @variables = variables
32
32
  @path = path
33
- @boundary = boundary
33
+ @resolver = resolver
34
34
  end
35
35
 
36
36
  def to_plan_op
@@ -43,17 +43,17 @@ module GraphQL
43
43
  variables: rendered_variables,
44
44
  path: @path,
45
45
  if_type: type_condition,
46
- boundary: @boundary,
46
+ resolver: @resolver&.version,
47
47
  )
48
48
  end
49
49
 
50
50
  private
51
51
 
52
- # Concrete types going to a boundary report themselves as a type condition.
52
+ # Concrete types going to a resolver report themselves as a type condition.
53
53
  # This is used by the executor to evalute which planned fragment selections
54
54
  # actually apply to the resolved object types.
55
55
  def type_condition
56
- @parent_type.graphql_name if @boundary && !parent_type.kind.abstract?
56
+ @parent_type.graphql_name if @resolver && !parent_type.kind.abstract?
57
57
  end
58
58
 
59
59
  def rendered_selections
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Resolver
5
+ # Defines a single resolver argument structure
6
+ # @api private
7
+ class Argument
8
+ attr_reader :name
9
+ attr_reader :value
10
+ attr_reader :type_name
11
+
12
+ def initialize(name:, value:, list: false, type_name: nil)
13
+ @name = name
14
+ @value = value
15
+ @list = list
16
+ @type_name = type_name
17
+ end
18
+
19
+ def list?
20
+ @list
21
+ end
22
+
23
+ def key?
24
+ value.key?
25
+ end
26
+
27
+ def verify_key(key)
28
+ if key?
29
+ value.verify_key(self, key)
30
+ true
31
+ else
32
+ false
33
+ end
34
+ end
35
+
36
+ def ==(other)
37
+ self.class == other.class &&
38
+ @name == other.name &&
39
+ @value == other.value &&
40
+ @type_name == other.type_name &&
41
+ @list == other.list?
42
+ end
43
+
44
+ def build(origin_obj)
45
+ value.build(origin_obj)
46
+ end
47
+
48
+ def print
49
+ "#{name}: #{value.print}"
50
+ end
51
+
52
+ def to_definition
53
+ print.gsub(%|"|, "'")
54
+ end
55
+
56
+ alias_method :to_s, :to_definition
57
+
58
+ def to_type_definition
59
+ "#{name}: #{to_type_signature}"
60
+ end
61
+
62
+ def to_type_signature
63
+ # need to derive nullability...
64
+ list? ? "[#{@type_name}!]!" : "#{@type_name}!"
65
+ end
66
+ end
67
+
68
+ # An abstract argument input value
69
+ # @api private
70
+ class ArgumentValue
71
+ attr_reader :value
72
+
73
+ def initialize(value)
74
+ @value = value
75
+ end
76
+
77
+ def key?
78
+ false
79
+ end
80
+
81
+ def verify_key(arg, key)
82
+ nil
83
+ end
84
+
85
+ def ==(other)
86
+ self.class == other.class && value == other.value
87
+ end
88
+
89
+ def build(origin_obj)
90
+ value
91
+ end
92
+
93
+ def print
94
+ value
95
+ end
96
+ end
97
+
98
+ # An object input value
99
+ # @api private
100
+ class ObjectArgumentValue < ArgumentValue
101
+ def key?
102
+ value.any?(&:key?)
103
+ end
104
+
105
+ def verify_key(arg, key)
106
+ value.each { _1.verify_key(key) }
107
+ end
108
+
109
+ def build(origin_obj)
110
+ value.each_with_object({}) do |arg, memo|
111
+ memo[arg.name] = arg.build(origin_obj)
112
+ end
113
+ end
114
+
115
+ def print
116
+ "{#{value.map(&:print).join(", ")}}"
117
+ end
118
+ end
119
+
120
+ # A key input value
121
+ # @api private
122
+ class KeyArgumentValue < ArgumentValue
123
+ def initialize(value)
124
+ super(Array(value))
125
+ end
126
+
127
+ def key?
128
+ true
129
+ end
130
+
131
+ def verify_key(arg, key)
132
+ key_field = value.reduce(Resolver::KeyField.new("", inner: key)) do |field, ns|
133
+ if ns == Resolver::TYPE_NAME
134
+ Resolver::KeyField.new(Resolver::TYPE_NAME)
135
+ elsif field
136
+ field.inner.find { _1.name == ns }
137
+ end
138
+ end
139
+
140
+ # still not capturing enough type information to accurately compare key/arg types...
141
+ # best we can do for now is to verify the argument insertion matches a key path.
142
+ if key_field.nil?
143
+ raise CompositionError, "Argument `#{arg.name}: #{print}` cannot insert key `#{key.to_definition}`."
144
+ end
145
+ end
146
+
147
+ def build(origin_obj)
148
+ value.each_with_index.reduce(origin_obj) do |obj, (ns, idx)|
149
+ obj[idx.zero? ? Resolver.export_key(ns) : ns]
150
+ end
151
+ end
152
+
153
+ def print
154
+ "$.#{value.join(".")}"
155
+ end
156
+ end
157
+
158
+ # A typed enum input value
159
+ # @api private
160
+ class EnumArgumentValue < ArgumentValue
161
+ end
162
+
163
+ # A primitive input value literal
164
+ # @api private
165
+ class LiteralArgumentValue < ArgumentValue
166
+ def print
167
+ JSON.generate(value)
168
+ end
169
+ end
170
+
171
+ # Parser for building argument templates into resolver structures
172
+ # @api private
173
+ module ArgumentsParser
174
+ # Parses an argument template string into resolver arguments via schema casting.
175
+ # @param template [String] the template string to parse.
176
+ # @param field_def [GraphQL::Schema::FieldDefinition] a field definition providing arguments schema.
177
+ # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments.
178
+ def parse_arguments_with_field(template, field_def)
179
+ ast = parse_arg_defs(template)
180
+ args = build_argument_set(ast, field_def.arguments)
181
+ args.each do |arg|
182
+ next unless arg.key?
183
+
184
+ if field_def.type.list? && !arg.list?
185
+ raise CompositionError, "Cannot use repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \
186
+ "in non-list argument `#{arg.name}`."
187
+ elsif !field_def.type.list? && arg.list?
188
+ raise CompositionError, "Cannot use non-repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \
189
+ "in list argument `#{arg.name}`."
190
+ end
191
+ end
192
+
193
+ args
194
+ end
195
+
196
+ # Parses an argument template string into resolver arguments via SDL casting.
197
+ # @param template [String] the template string to parse.
198
+ # @param type_defs [String] the type definition string declaring argument types.
199
+ # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments.
200
+ def parse_arguments_with_type_defs(template, type_defs)
201
+ type_map = parse_type_defs(type_defs)
202
+ parse_arg_defs(template).map { build_argument(_1, type_struct: type_map[_1.name]) }
203
+ end
204
+
205
+ private
206
+
207
+ def parse_arg_defs(template)
208
+ template = template
209
+ .gsub("'", %|"|) # 'sfoo' -> "sfoo"
210
+ .gsub(/(\$[\w\.]+)/) { %|"#{_1}"| } # $.key -> "$.key"
211
+ .tap(&:strip!)
212
+
213
+ template = template[1..-2] if template.start_with?("(") && template.end_with?(")")
214
+
215
+ GraphQL.parse("{ f(#{template}) }")
216
+ .definitions.first
217
+ .selections.first
218
+ .arguments
219
+ end
220
+
221
+ def parse_type_defs(template)
222
+ GraphQL.parse("type T { #{template} }")
223
+ .definitions.first
224
+ .fields.each_with_object({}) do |node, memo|
225
+ memo[node.name] = GraphQL::Stitching::Util.flatten_ast_type_structure(node.type)
226
+ end
227
+ end
228
+
229
+ def build_argument_set(nodes, argument_defs)
230
+ if argument_defs
231
+ argument_defs.each_value do |argument_def|
232
+ if argument_def.type.non_null? && !nodes.find { _1.name == argument_def.graphql_name }
233
+ raise CompositionError, "Required argument `#{argument_def.graphql_name}` has no input."
234
+ end
235
+ end
236
+ end
237
+
238
+ nodes.map do |node|
239
+ argument_def = if argument_defs
240
+ arg = argument_defs[node.name]
241
+ raise CompositionError, "Input `#{node.name}` is not a valid argument." unless arg
242
+ arg
243
+ end
244
+
245
+ build_argument(node, argument_def: argument_def)
246
+ end
247
+ end
248
+
249
+ def build_argument(node, argument_def: nil, type_struct: nil)
250
+ value = if node.value.is_a?(GraphQL::Language::Nodes::InputObject)
251
+ build_object_value(node.value, argument_def ? argument_def.type.unwrap : nil)
252
+ elsif node.value.is_a?(GraphQL::Language::Nodes::Enum)
253
+ EnumArgumentValue.new(node.value.name)
254
+ elsif node.value.is_a?(String) && node.value.start_with?("$.")
255
+ KeyArgumentValue.new(node.value.sub(/^\$\./, "").split("."))
256
+ else
257
+ LiteralArgumentValue.new(node.value)
258
+ end
259
+
260
+ Argument.new(
261
+ name: node.name,
262
+ value: value,
263
+ # doesn't support nested lists...?
264
+ list: argument_def ? argument_def.type.list? : (type_struct&.first&.list? || false),
265
+ type_name: argument_def ? argument_def.type.unwrap.graphql_name : type_struct&.last&.name,
266
+ )
267
+ end
268
+
269
+ def build_object_value(node, object_def)
270
+ if object_def
271
+ if !object_def.kind.input_object? && !object_def.kind.scalar?
272
+ raise CompositionError, "Objects can only be built into input object and scalar positions."
273
+ elsif object_def.kind.scalar? && GraphQL::Schema::BUILT_IN_TYPES[object_def.graphql_name]
274
+ raise CompositionError, "Objects can only be built into custom scalar types."
275
+ elsif object_def.kind.scalar?
276
+ object_def = nil
277
+ end
278
+ end
279
+
280
+ ObjectArgumentValue.new(build_argument_set(node.arguments, object_def&.arguments))
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Resolver
5
+ EXPORT_PREFIX = "_export_"
6
+ TYPE_NAME = "__typename"
7
+
8
+ class FieldNode
9
+ # GraphQL Ruby changed the argument assigning Field.alias from
10
+ # a generic `alias` hash key to a structured `field_alias` kwarg
11
+ # in https://github.com/rmosolgo/graphql-ruby/pull/4718.
12
+ # This adapts to the library implementation present.
13
+ GRAPHQL_RUBY_FIELD_ALIAS_KWARG = !GraphQL::Language::Nodes::Field.new(field_alias: "a").alias.nil?
14
+
15
+ class << self
16
+ def build(field_name:, field_alias: nil, selections: GraphQL::Stitching::EMPTY_ARRAY)
17
+ if GRAPHQL_RUBY_FIELD_ALIAS_KWARG
18
+ GraphQL::Language::Nodes::Field.new(
19
+ field_alias: field_alias,
20
+ name: field_name,
21
+ selections: selections,
22
+ )
23
+ else
24
+ GraphQL::Language::Nodes::Field.new(
25
+ alias: field_alias,
26
+ name: field_name,
27
+ selections: selections,
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class KeyFieldSet < Array
35
+ def initialize(fields)
36
+ super(fields.sort_by(&:name))
37
+ @to_definition = nil
38
+ @export_nodes = nil
39
+ end
40
+
41
+ def ==(other)
42
+ to_definition == other.to_definition
43
+ end
44
+
45
+ def default_argument_name
46
+ length == 1 ? first.name : nil
47
+ end
48
+
49
+ def to_definition
50
+ @to_definition ||= map(&:to_definition).join(" ").freeze
51
+ end
52
+
53
+ alias_method :to_s, :to_definition
54
+
55
+ def export_nodes
56
+ @export_nodes ||= map(&:export_node)
57
+ end
58
+ end
59
+
60
+ EMPTY_FIELD_SET = KeyFieldSet.new(GraphQL::Stitching::EMPTY_ARRAY)
61
+ TYPENAME_EXPORT_NODE = FieldNode.build(
62
+ field_alias: "#{EXPORT_PREFIX}#{TYPE_NAME}",
63
+ field_name: TYPE_NAME,
64
+ )
65
+
66
+ class Key < KeyFieldSet
67
+ attr_reader :locations
68
+
69
+ def initialize(fields, locations: GraphQL::Stitching::EMPTY_ARRAY)
70
+ super(fields)
71
+ @locations = locations
72
+ to_definition
73
+ export_nodes
74
+ freeze
75
+ end
76
+
77
+ def export_nodes
78
+ @export_nodes ||= begin
79
+ nodes = map(&:export_node)
80
+ nodes << TYPENAME_EXPORT_NODE
81
+ nodes
82
+ end
83
+ end
84
+ end
85
+
86
+ class KeyField
87
+ # name of the key, may be a field alias
88
+ attr_reader :name
89
+
90
+ # inner key selections
91
+ attr_reader :inner
92
+
93
+ # optional information about location and typing, used during composition
94
+ attr_accessor :type_name
95
+ attr_accessor :list
96
+ alias_method :list?, :list
97
+
98
+ def initialize(name, root: false, inner: EMPTY_FIELD_SET)
99
+ @name = name
100
+ @inner = inner
101
+ @root = root
102
+ end
103
+
104
+ def to_definition
105
+ @inner.empty? ? @name : "#{@name} { #{@inner.to_definition} }"
106
+ end
107
+
108
+ def export_node
109
+ FieldNode.build(
110
+ field_alias: @root ? "#{EXPORT_PREFIX}#{@name}" : nil,
111
+ field_name: @name,
112
+ selections: @inner.export_nodes,
113
+ )
114
+ end
115
+ end
116
+
117
+ module KeysParser
118
+ def export_key(name)
119
+ "#{EXPORT_PREFIX}#{name}"
120
+ end
121
+
122
+ def export_key?(name)
123
+ return false unless name
124
+
125
+ name.start_with?(EXPORT_PREFIX)
126
+ end
127
+
128
+ def parse_key(template, locations = GraphQL::Stitching::EMPTY_ARRAY)
129
+ Key.new(parse_field_set(template), locations: locations)
130
+ end
131
+
132
+ def parse_key_with_types(template, subgraph_types_by_location)
133
+ field_set = parse_field_set(template)
134
+ locations = subgraph_types_by_location.filter_map do |location, subgraph_type|
135
+ location if field_set_matches_type?(field_set, subgraph_type)
136
+ end
137
+
138
+ if locations.none?
139
+ message = "Key `#{field_set.to_definition}` does not exist in any location."
140
+ message += " Composite key selections may not be distributed." if field_set.length > 1
141
+ raise CompositionError, message
142
+ end
143
+
144
+ assign_field_set_info!(field_set, subgraph_types_by_location[locations.first])
145
+ Key.new(field_set, locations: locations)
146
+ end
147
+
148
+ private
149
+
150
+ def parse_field_set(template)
151
+ template = template.strip
152
+ template = template[1..-2] if template.start_with?("{") && template.end_with?("}")
153
+
154
+ ast = GraphQL.parse("{ #{template} }").definitions.first.selections
155
+ build_field_set(ast, root: true)
156
+ end
157
+
158
+ def build_field_set(selections, root: false)
159
+ return EMPTY_FIELD_SET if selections.empty?
160
+
161
+ fields = selections.map do |node|
162
+ raise CompositionError, "Key selections must be fields." unless node.is_a?(GraphQL::Language::Nodes::Field)
163
+ raise CompositionError, "Key fields may not specify aliases." unless node.alias.nil?
164
+
165
+ KeyField.new(node.name, inner: build_field_set(node.selections), root: root)
166
+ end
167
+
168
+ KeyFieldSet.new(fields)
169
+ end
170
+
171
+ def field_set_matches_type?(field_set, subgraph_type)
172
+ subgraph_type = subgraph_type.unwrap
173
+ field_set.all? do |field|
174
+ # fixme: union doesn't have fields, but may support these selections...
175
+ next true if subgraph_type.kind.union?
176
+ field_matches_type?(field, subgraph_type.get_field(field.name)&.type&.unwrap)
177
+ end
178
+ end
179
+
180
+ def field_matches_type?(field, subgraph_type)
181
+ return false if subgraph_type.nil?
182
+
183
+ if field.inner.empty? && subgraph_type.kind.composite?
184
+ raise CompositionError, "Composite key fields must contain nested selections."
185
+ end
186
+
187
+ field.inner.empty? || field_set_matches_type?(field.inner, subgraph_type)
188
+ end
189
+
190
+ def assign_field_set_info!(field_set, subgraph_type)
191
+ subgraph_type = subgraph_type.unwrap
192
+ field_set.each do |field|
193
+ # fixme: union doesn't have fields, but may support these selections...
194
+ next if subgraph_type.kind.union?
195
+ assign_field_info!(field, subgraph_type.get_field(field.name).type)
196
+ end
197
+ end
198
+
199
+ def assign_field_info!(field, subgraph_type)
200
+ field.list = subgraph_type.list?
201
+ field.type_name = subgraph_type.unwrap.graphql_name
202
+ assign_field_set_info!(field.inner, subgraph_type)
203
+ end
204
+ end
205
+ end
206
+ end