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.
- checksums.yaml +4 -4
- data/README.md +67 -17
- data/docs/README.md +2 -1
- data/docs/mechanics.md +2 -1
- data/docs/resolver.md +101 -0
- data/lib/graphql/stitching/client.rb +5 -1
- data/lib/graphql/stitching/composer/{boundary_config.rb → resolver_config.rb} +18 -13
- data/lib/graphql/stitching/composer/validate_interfaces.rb +4 -4
- data/lib/graphql/stitching/composer/validate_resolvers.rb +97 -0
- data/lib/graphql/stitching/composer.rb +107 -112
- data/lib/graphql/stitching/executor/{boundary_source.rb → resolver_source.rb} +40 -32
- data/lib/graphql/stitching/executor.rb +3 -3
- data/lib/graphql/stitching/plan.rb +3 -4
- data/lib/graphql/stitching/planner.rb +30 -41
- data/lib/graphql/stitching/planner_step.rb +6 -6
- data/lib/graphql/stitching/resolver/arguments.rb +284 -0
- data/lib/graphql/stitching/resolver/keys.rb +206 -0
- data/lib/graphql/stitching/resolver.rb +70 -0
- data/lib/graphql/stitching/shaper.rb +3 -3
- data/lib/graphql/stitching/skip_include.rb +1 -1
- data/lib/graphql/stitching/supergraph/key_directive.rb +13 -0
- data/lib/graphql/stitching/supergraph/resolver_directive.rb +4 -4
- data/lib/graphql/stitching/supergraph/to_definition.rb +165 -0
- data/lib/graphql/stitching/supergraph.rb +31 -144
- data/lib/graphql/stitching/util.rb +28 -0
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +3 -2
- metadata +11 -7
- data/lib/graphql/stitching/boundary.rb +0 -29
- data/lib/graphql/stitching/composer/validate_boundaries.rb +0 -96
- 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
|
-
|
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
|
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
|
59
|
-
# E.1) Add the key of each
|
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
|
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
|
-
|
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}/#{
|
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
|
-
|
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?(
|
203
|
-
raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{
|
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 <<
|
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
|
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
|
275
|
+
# E) Translate resolver pathways into new entrypoints.
|
276
276
|
routes.each_value do |route|
|
277
|
-
route.reduce(locale_selections) do |parent_selections,
|
278
|
-
# E.1) Add the key of each
|
279
|
-
if
|
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:
|
283
|
+
location: resolver.location,
|
297
284
|
parent_index: parent_index,
|
298
285
|
parent_type: parent_type,
|
299
|
-
selections: remote_selections_by_location[
|
286
|
+
selections: remote_selections_by_location[resolver.location] || [],
|
300
287
|
path: path.dup,
|
301
|
-
|
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
|
418
|
-
def
|
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.
|
409
|
+
next unless step.resolver
|
421
410
|
|
422
|
-
|
423
|
-
next unless
|
424
|
-
next if
|
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, :
|
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
|
-
|
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
|
-
@
|
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
|
-
|
46
|
+
resolver: @resolver&.version,
|
47
47
|
)
|
48
48
|
end
|
49
49
|
|
50
50
|
private
|
51
51
|
|
52
|
-
# Concrete types going to a
|
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 @
|
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
|