graph_weaver 0.5.1 → 0.6.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/CHANGELOG.md +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
require_relative "subgraphs"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Internal
|
|
10
|
+
# Decides which subgraph answers what — and, where an operation crosses
|
|
11
|
+
# a boundary, the tree of fetches that answers it. Separate from the
|
|
12
|
+
# Router because deciding needs only the supergraph: `rake
|
|
13
|
+
# graph_weaver:federation:coverage` measures how much of a query set is
|
|
14
|
+
# plannable without any subgraph being runnable.
|
|
15
|
+
class Planner
|
|
16
|
+
# response keys the planner injects to carry a @key across a boundary,
|
|
17
|
+
# stripped before the caller sees the tree
|
|
18
|
+
PREFIX = "_gw_"
|
|
19
|
+
|
|
20
|
+
# Where the injected __typename lands. Which concrete type an abstract
|
|
21
|
+
# position holds is a fact only the data carries, so every abstract
|
|
22
|
+
# fetch asks for it — under this key whether or not the caller did.
|
|
23
|
+
TYPENAME = "#{PREFIX}__typename"
|
|
24
|
+
|
|
25
|
+
# A field set as dotted paths, back into the selection set it was parsed
|
|
26
|
+
# from ({"origin" => {"lat" => {}, "lon" => {}}}). Both sides of a
|
|
27
|
+
# crossing need it: one to ask for the fields, the other to read them
|
|
28
|
+
# back in the shape the SDL spells.
|
|
29
|
+
def self.field_tree(paths)
|
|
30
|
+
paths.each_with_object({}) do |path, tree|
|
|
31
|
+
path.split(".").reduce(tree) { |node, segment| node[segment] ||= {} }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What a fetch adds to carry a field set across a boundary: one field
|
|
36
|
+
# per root, aliased under PREFIX so the caller's answer never gains a
|
|
37
|
+
# field it didn't ask for, and nested exactly as the field set is —
|
|
38
|
+
# `origin { lat lon }` comes back whole, under one response key.
|
|
39
|
+
def self.injected_selections(paths)
|
|
40
|
+
field_tree(paths).map do |root, children|
|
|
41
|
+
GraphQL::Language::Nodes::Field.new(
|
|
42
|
+
name: root, field_alias: PREFIX + root, selections: field_selections(children),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.field_selections(tree)
|
|
48
|
+
tree.map do |name, children|
|
|
49
|
+
GraphQL::Language::Nodes::Field.new(name:, selections: field_selections(children))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
private_class_method :field_selections
|
|
53
|
+
|
|
54
|
+
# One subgraph fetch. `selections` go over as written; `keys` names the
|
|
55
|
+
# @key/@requires paths this fetch also asks for, to carry entities
|
|
56
|
+
# across a boundary, and `injected` the response keys those land under
|
|
57
|
+
# (PREFIX + the path's first segment — a nested field set
|
|
58
|
+
# arrives as one object), which the answer is stripped of. `children`
|
|
59
|
+
# and `deferrals` are what happens to the objects it answers with — a
|
|
60
|
+
# child stays in this subgraph and only carries deferrals deeper, a
|
|
61
|
+
# deferral is refetched elsewhere. Both are lists: two selections can
|
|
62
|
+
# share a response key, and each brings its own subtree.
|
|
63
|
+
Step = Struct.new(:subgraph, :type_name, :selections, :keys, :injected, :prefetches,
|
|
64
|
+
:children, :deferrals, keyword_init: true) do
|
|
65
|
+
def subgraphs
|
|
66
|
+
[subgraph] + prefetches.map(&:subgraph) +
|
|
67
|
+
children.flat_map { |_key, child| child.subgraphs } + deferrals.flat_map(&:subgraphs)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# the __typename every abstract fetch asks for, under the router's own
|
|
72
|
+
# response key so the caller's answer never gains one it didn't ask for
|
|
73
|
+
TYPENAME_FIELD = GraphQL::Language::Nodes::Field.new(
|
|
74
|
+
name: "__typename", field_alias: TYPENAME,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# What a field returning an abstract type defers to: one plan per
|
|
78
|
+
# concrete type the subgraph can answer with. Which of them applies is
|
|
79
|
+
# a fact about the data, and the planner runs before any fetch — so it
|
|
80
|
+
# plans them all and {Router#stitch} picks by __typename.
|
|
81
|
+
Branches = Struct.new(:steps, keyword_init: true) do
|
|
82
|
+
def subgraphs = steps.each_value.flat_map(&:subgraphs)
|
|
83
|
+
|
|
84
|
+
# what the parent's fetch asks for: each branch under its own type
|
|
85
|
+
# condition, and the __typename that says which one answered
|
|
86
|
+
def selections
|
|
87
|
+
[TYPENAME_FIELD] + steps.filter_map do |type_name, step|
|
|
88
|
+
next if step.selections.empty?
|
|
89
|
+
|
|
90
|
+
GraphQL::Language::Nodes::InlineFragment.new(
|
|
91
|
+
type: GraphQL::Language::Nodes::TypeName.new(name: type_name),
|
|
92
|
+
selections: step.selections,
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A @requires field set the router has to supply: fetch those fields
|
|
99
|
+
# from the subgraph that holds them, into hidden keys on the object,
|
|
100
|
+
# before the fetch whose representation carries them.
|
|
101
|
+
# `node` is the selection whose @requires this feeds — the fetch is
|
|
102
|
+
# only made when that selection survives @skip/@include.
|
|
103
|
+
Prefetch = Struct.new(:subgraph, :key, :paths, :node, keyword_init: true)
|
|
104
|
+
|
|
105
|
+
# A field this subgraph can't resolve: refetch the parent entity from
|
|
106
|
+
# `subgraph` and read it there.
|
|
107
|
+
Deferral = Struct.new(:node, :response_key, :subgraph, :step, :key, :requires,
|
|
108
|
+
keyword_init: true) do
|
|
109
|
+
def subgraphs = [subgraph] + (step ? step.subgraphs : [])
|
|
110
|
+
|
|
111
|
+
# the paths a representation for this deferral has to carry
|
|
112
|
+
def representation = (key + requires).uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What one operation costs. `verbatim` is the shape the whole thing
|
|
116
|
+
# resolves in one subgraph, where the document goes over untouched.
|
|
117
|
+
Plan = Struct.new(:steps, :operation, :selections, :fragments, :root_type, :introspection,
|
|
118
|
+
:verbatim, keyword_init: true) do
|
|
119
|
+
def operation_name = operation&.name
|
|
120
|
+
|
|
121
|
+
def entry = steps.first&.subgraph
|
|
122
|
+
|
|
123
|
+
# every subgraph the plan fetches from, in plan order
|
|
124
|
+
def subgraphs = steps.flat_map(&:subgraphs).uniq
|
|
125
|
+
|
|
126
|
+
# which subgraphs it touches, for a report — "accounts+reviews"
|
|
127
|
+
# when it stitches (sorted: fetch order is what #trace is for)
|
|
128
|
+
def where = introspection ? "(introspection)" : subgraphs.sort.join("+")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# the fields a router answers itself rather than routing
|
|
132
|
+
INTROSPECTION = %w[__schema __type].freeze
|
|
133
|
+
|
|
134
|
+
# a fragment spread can't cycle (validation rejects that), so this is
|
|
135
|
+
# only ever reached by a document validation didn't see
|
|
136
|
+
MAX_DEPTH = 32
|
|
137
|
+
|
|
138
|
+
# absent: subgraphs no schema serves here. ambiguous: the ones
|
|
139
|
+
# several loaded classes fit, as { name => candidate class names }.
|
|
140
|
+
# Planning is otherwise unchanged — coverage plans with neither, which
|
|
141
|
+
# is why both are facts about this process rather than about the
|
|
142
|
+
# graph.
|
|
143
|
+
def initialize(table:, schema:, absent: [], ambiguous: {})
|
|
144
|
+
@table = table
|
|
145
|
+
@schema = schema
|
|
146
|
+
@absent = absent
|
|
147
|
+
@ambiguous = ambiguous
|
|
148
|
+
@unserved = absent + ambiguous.keys
|
|
149
|
+
@interface_objects = table.interface_objects
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# the operation's validation errors, GraphQL-wire shaped
|
|
153
|
+
def validate(document)
|
|
154
|
+
@schema.validate(document)
|
|
155
|
+
.map { |error| Wire.graphql_error(error.message, "GRAPHQL_VALIDATION_FAILED") }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def plan(document, operation_name: nil)
|
|
159
|
+
operation = pick_operation(document, operation_name)
|
|
160
|
+
refuse(:operation_type, "this document is a subscription") if
|
|
161
|
+
operation.operation_type == "subscription"
|
|
162
|
+
|
|
163
|
+
fragments = document.definitions
|
|
164
|
+
.grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |f| [f.name, f] }
|
|
165
|
+
root = root_type(operation)
|
|
166
|
+
selections = narrow(root.graphql_name, operation.selections, fragments)
|
|
167
|
+
plan = Plan.new(operation:, selections:, fragments:, root_type: root, steps: [])
|
|
168
|
+
|
|
169
|
+
introspection, data = selections.partition { |node| INTROSPECTION.include?(node.name) }
|
|
170
|
+
if introspection.any?
|
|
171
|
+
if data.any? { |node| node.name != "__typename" }
|
|
172
|
+
refuse :mixed_introspection,
|
|
173
|
+
"this operation selects #{introspection.map(&:name).uniq.join(" and ")} " \
|
|
174
|
+
"alongside data fields"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
plan.introspection = true
|
|
178
|
+
return plan
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
check_interface_objects!(root.graphql_name, selections, fragments) if @interface_objects.any?
|
|
182
|
+
|
|
183
|
+
entry = single_subgraph(root.graphql_name, selections, fragments)
|
|
184
|
+
if entry
|
|
185
|
+
plan.verbatim = true
|
|
186
|
+
plan.steps = [step(entry, root.graphql_name)]
|
|
187
|
+
return plan
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
plan.steps = root_steps(root.graphql_name, selections, operation, fragments)
|
|
191
|
+
plan
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The selections that apply to ONE concrete type, as plain fields a
|
|
195
|
+
# step can route one at a time: fields written at this position, plus
|
|
196
|
+
# every fragment whose condition that type satisfies, folded in. A
|
|
197
|
+
# fragment it can't be never matches, so it is dropped rather than
|
|
198
|
+
# travelling as written — every position a step plans is concrete, so
|
|
199
|
+
# a condition either holds for all of its objects or for none.
|
|
200
|
+
#
|
|
201
|
+
# Public because {Router#propagate} asks the same question of the
|
|
202
|
+
# merged tree: which selections describe the object in hand.
|
|
203
|
+
def narrow(concrete, selections, fragments, depth = 0)
|
|
204
|
+
return [] if depth > MAX_DEPTH
|
|
205
|
+
|
|
206
|
+
selections.flat_map do |node|
|
|
207
|
+
case node
|
|
208
|
+
when GraphQL::Language::Nodes::Field then [node]
|
|
209
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
210
|
+
next [] unless applies?(node.type&.name, concrete)
|
|
211
|
+
|
|
212
|
+
carry(node, narrow(concrete, node.selections, fragments, depth + 1))
|
|
213
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
214
|
+
fragment = fragments[node.name] or
|
|
215
|
+
refuse(:undefined_fragment, "the document spreads ...#{node.name}, which it never defines")
|
|
216
|
+
next [] unless applies?(fragment.type.name, concrete)
|
|
217
|
+
|
|
218
|
+
carry(node, narrow(concrete, fragment.selections, fragments, depth + 1))
|
|
219
|
+
else []
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# Every type these selections reach, refused if one of them is an
|
|
227
|
+
# @interfaceObject: a subgraph resolves the whole interface there, so
|
|
228
|
+
# the supergraph records no per-field routing for it and every fetch
|
|
229
|
+
# planned against it would be a guess. Asked per query rather than at
|
|
230
|
+
# construction — one such directive shouldn't cost you the queries
|
|
231
|
+
# that never touch the type.
|
|
232
|
+
def check_interface_objects!(type_name, selections, fragments, depth = 0)
|
|
233
|
+
return if depth > MAX_DEPTH
|
|
234
|
+
|
|
235
|
+
selections.each do |node|
|
|
236
|
+
case node
|
|
237
|
+
when GraphQL::Language::Nodes::Field
|
|
238
|
+
next if node.name.start_with?("__")
|
|
239
|
+
|
|
240
|
+
child = raw_child_type(type_name, node.name) or next
|
|
241
|
+
interface_object!(child, "#{type_name}.#{node.name} returns #{child}")
|
|
242
|
+
check_interface_objects!(child, node.selections, fragments, depth + 1)
|
|
243
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
244
|
+
condition = node.type&.name || type_name
|
|
245
|
+
interface_object!(condition, "this operation selects ... on #{condition}")
|
|
246
|
+
check_interface_objects!(condition, node.selections, fragments, depth + 1)
|
|
247
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
248
|
+
fragment = fragments[node.name] or next
|
|
249
|
+
condition = fragment.type.name
|
|
250
|
+
interface_object!(condition, "...#{node.name} is on #{condition}")
|
|
251
|
+
check_interface_objects!(condition, fragment.selections, fragments, depth + 1)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def interface_object!(type_name, where)
|
|
257
|
+
graphs = @interface_objects[type_name] or return
|
|
258
|
+
|
|
259
|
+
refuse :interface_object,
|
|
260
|
+
"#{where}, which #{graphs.join(" and ")} resolves as an @interfaceObject"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Whether a fragment's condition holds for every object of `concrete`
|
|
264
|
+
# — the type itself, or an abstract type it satisfies.
|
|
265
|
+
def applies?(condition, concrete)
|
|
266
|
+
return true if condition.nil? || condition == concrete
|
|
267
|
+
|
|
268
|
+
type = @schema.get_type(condition)
|
|
269
|
+
!!type&.kind&.abstract? && @schema.possible_types(type).any? { |t| t.graphql_name == concrete }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def step(subgraph, type_name)
|
|
273
|
+
Step.new(subgraph:, type_name:, selections: [], keys: [], injected: [], prefetches: [],
|
|
274
|
+
children: [], deferrals: [])
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def pick_operation(document, name)
|
|
278
|
+
operations = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
|
|
279
|
+
named = operations.map { |op| op.name || "anonymous" }
|
|
280
|
+
if name
|
|
281
|
+
return operations.find { |op| op.name == name } || refuse(:ambiguous_operation,
|
|
282
|
+
"the document defines no operation named #{name.inspect} (it has #{named.join(", ")})")
|
|
283
|
+
end
|
|
284
|
+
return operations.first if operations.one?
|
|
285
|
+
|
|
286
|
+
refuse :ambiguous_operation, "the document holds #{operations.size} operations (#{named.join(", ")})"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def root_type(operation)
|
|
290
|
+
root = (operation.operation_type == "mutation") ? @schema.mutation : @schema.query
|
|
291
|
+
root || refuse(:operation_type,
|
|
292
|
+
"the composed schema has no #{operation.operation_type || "query"} root type")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The one subgraph that answers the whole operation, if there is one.
|
|
296
|
+
# Root fields fix the candidates: they're independent, so the ones
|
|
297
|
+
# they share are the only subgraphs that could answer everything.
|
|
298
|
+
def single_subgraph(root, selections, fragments)
|
|
299
|
+
fields = selections.reject { |node| node.name.start_with?("__") }
|
|
300
|
+
shared = fields.map { |node| owners!(root, node.name) }.reduce(:&) || @table.subgraphs
|
|
301
|
+
# no refusal here: an absent candidate just isn't one, and the
|
|
302
|
+
# per-field walk below names it if that's what stops the query
|
|
303
|
+
(shared - @unserved).find { |subgraph| local?(root, selections, subgraph, fragments, []) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Root fields resolve independently, so each picks its own subgraph
|
|
307
|
+
# and one fetch goes to each — preferring a subgraph already in the
|
|
308
|
+
# plan, so a query that could run in fewer doesn't run in more.
|
|
309
|
+
def root_steps(root, selections, operation, fragments)
|
|
310
|
+
if operation.operation_type == "mutation"
|
|
311
|
+
owners = selections.reject { |node| node.name.start_with?("__") }
|
|
312
|
+
.to_h { |node| [node.name, owners!(root, node.name)] }
|
|
313
|
+
# Root mutation fields run in series. Sharing a subgraph, they go
|
|
314
|
+
# over as one document and it serializes them; only roots in
|
|
315
|
+
# *different* subgraphs would run in whatever order the plan
|
|
316
|
+
# happens to. Whatever stitches below a root is an ordinary read
|
|
317
|
+
# afterwards, so it doesn't bear on the ordering.
|
|
318
|
+
shared = owners.values.reduce(:&) || @table.subgraphs
|
|
319
|
+
if shared.empty?
|
|
320
|
+
refuse :root_fields_span, "this mutation's root fields span subgraphs: " \
|
|
321
|
+
"#{owners.map { |name, graphs| "#{root}.#{name} (#{graphs.join(" or ")})" }.join(", ")}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# one root field: shared IS its owners, so an absence names it
|
|
325
|
+
subject = owners.one? ? "#{root}.#{owners.keys.first}" : root
|
|
326
|
+
return [plan_step(root, selections, available!(shared, subject).first, fragments, [], 0)]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
groups = {}
|
|
330
|
+
loose = []
|
|
331
|
+
selections.each do |node|
|
|
332
|
+
if node.name.start_with?("__")
|
|
333
|
+
loose << node
|
|
334
|
+
next
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
graphs = available!(owners!(root, node.name), "#{root}.#{node.name}")
|
|
338
|
+
(groups[(graphs & groups.keys).first || graphs.first] ||= []) << node
|
|
339
|
+
end
|
|
340
|
+
groups[available!(@table.subgraphs, root).first] ||= [] if groups.empty?
|
|
341
|
+
# __typename doesn't route; any subgraph answers it
|
|
342
|
+
groups[groups.keys.first].concat(loose)
|
|
343
|
+
|
|
344
|
+
groups.map { |subgraph, nodes| plan_step(root, nodes, subgraph, fragments, [], 0) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Build the fetch for `selections` on `type_name` in `subgraph`.
|
|
348
|
+
# `provided` names fields a @provides copy makes answerable here even
|
|
349
|
+
# though the routing table places them elsewhere.
|
|
350
|
+
def plan_step(type_name, selections, subgraph, fragments, provided, depth)
|
|
351
|
+
refuse(:too_deep, "this operation nests deeper than #{MAX_DEPTH} levels") if depth > MAX_DEPTH
|
|
352
|
+
|
|
353
|
+
here = step(subgraph, type_name)
|
|
354
|
+
selections.each do |node|
|
|
355
|
+
contextual!(type_name, node)
|
|
356
|
+
# a subtree that never leaves this subgraph goes over as written:
|
|
357
|
+
# the boundary rules govern stitching, so they have no business
|
|
358
|
+
# applying to a query that was never going to cross one
|
|
359
|
+
if node.name.start_with?("__") || local?(type_name, [node], subgraph, fragments, provided)
|
|
360
|
+
here.selections << inline_spreads(node, fragments)
|
|
361
|
+
next
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
owners = owners!(type_name, node.name)
|
|
365
|
+
field = @table.field(type_name, node.name)
|
|
366
|
+
resolves_here = owners.include?(subgraph) || provided.include?(node.name)
|
|
367
|
+
if resolves_here && held?(type_name, field, subgraph)
|
|
368
|
+
descend(here, type_name, node, subgraph, field, fragments, depth)
|
|
369
|
+
else
|
|
370
|
+
# a field whose @requires this subgraph can't supply is refetched
|
|
371
|
+
# even when it resolves here — the fields have to arrive in a
|
|
372
|
+
# representation, and only an entity fetch carries one
|
|
373
|
+
target = resolves_here ? subgraph : available!(owners, "#{type_name}.#{node.name}").first
|
|
374
|
+
defer(here, type_name, node, subgraph, target, field, fragments, selections, depth)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
check_one_source!(type_name, here, subgraph)
|
|
379
|
+
# every crossing this fetch feeds, asked for once and together: a
|
|
380
|
+
# field set shared by two deferrals is one selection, and a nested
|
|
381
|
+
# one is nested rather than a dotted alias no schema has
|
|
382
|
+
here.selections.concat(Planner.injected_selections(here.keys))
|
|
383
|
+
here.injected = (here.keys + here.prefetches.flat_map(&:paths))
|
|
384
|
+
.map { |path| PREFIX + path.split(".").first }.uniq
|
|
385
|
+
check_reserved!(type_name, selections, here.injected)
|
|
386
|
+
here
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# A @fromContext argument is filled by the GATEWAY, out of a selection on
|
|
390
|
+
# an ancestor — so a fetch this planner writes leaves it unset and the
|
|
391
|
+
# field resolves from nothing. Asked only of a field the planner routes
|
|
392
|
+
# itself: a subtree handed to one subgraph whole carries its own context,
|
|
393
|
+
# which is why the verbatim path never reaches here.
|
|
394
|
+
def contextual!(type_name, node)
|
|
395
|
+
names = @table.field(type_name, node.name)&.contextual
|
|
396
|
+
return if names.nil? || names.empty?
|
|
397
|
+
|
|
398
|
+
refuse :context_argument, "#{type_name}.#{node.name} takes #{names.map(&:inspect).join(", ")} " \
|
|
399
|
+
"from a @context an ancestor selection sets, and the router would have to fetch it " \
|
|
400
|
+
"on its own"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The keys a fetch injects are stripped from the answer, so a caller's
|
|
404
|
+
# alias spelling one is stripped with it — silently, since the two are
|
|
405
|
+
# then indistinguishable. Asked of what this fetch actually injects
|
|
406
|
+
# rather than of the prefix, so an alias that collides with nothing
|
|
407
|
+
# still runs.
|
|
408
|
+
def check_reserved!(type_name, selections, injected)
|
|
409
|
+
clash = selections.select { |node| injected.include?(node.alias) }
|
|
410
|
+
return if clash.empty?
|
|
411
|
+
|
|
412
|
+
refuse :shadowed_key, "#{type_name} crosses on a field set the router carries under " \
|
|
413
|
+
"#{injected.map(&:inspect).join(", ")}, and this selection aliases " \
|
|
414
|
+
"#{clash.map { |node| "#{node.name} as #{node.alias.inspect}" }.join(", ")} over it"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# A subtree that goes over as written may still hold a fragment spread
|
|
418
|
+
# — `narrow` only expands the ones at a position it routes. A fetch
|
|
419
|
+
# carries no fragment definitions, so spell each spread as the inline
|
|
420
|
+
# fragment it is: same condition, same directives, and the variables
|
|
421
|
+
# inside it now reachable by used_variables.
|
|
422
|
+
def inline_spreads(node, fragments, depth = 0)
|
|
423
|
+
refuse(:too_deep, "this operation nests deeper than #{MAX_DEPTH} levels") if depth > MAX_DEPTH
|
|
424
|
+
selections = node.respond_to?(:selections) ? node.selections : []
|
|
425
|
+
return node if selections.empty?
|
|
426
|
+
|
|
427
|
+
node.merge(selections: selections.map do |child|
|
|
428
|
+
inline_spreads(spread_inline(child, fragments), fragments, depth + 1)
|
|
429
|
+
end)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def spread_inline(node, fragments)
|
|
433
|
+
return node unless node.is_a?(GraphQL::Language::Nodes::FragmentSpread)
|
|
434
|
+
|
|
435
|
+
fragment = fragments[node.name] or
|
|
436
|
+
refuse(:undefined_fragment, "the document spreads ...#{node.name}, which it never defines")
|
|
437
|
+
GraphQL::Language::Nodes::InlineFragment.new(
|
|
438
|
+
type: GraphQL::Language::Nodes::TypeName.new(name: fragment.type.name),
|
|
439
|
+
directives: node.directives,
|
|
440
|
+
selections: fragment.selections,
|
|
441
|
+
)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# The field resolves in this subgraph but something under it doesn't.
|
|
445
|
+
def descend(step, type_name, node, subgraph, field, fragments, depth)
|
|
446
|
+
child = plan_child(type_name, node, subgraph, field, fragments, depth)
|
|
447
|
+
|
|
448
|
+
step.selections << node.merge(selections: child.selections)
|
|
449
|
+
step.children << [node.alias || node.name, child]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# The plan for what this field returns, run in `subgraph`.
|
|
453
|
+
def plan_child(type_name, node, subgraph, field, fragments, depth)
|
|
454
|
+
child_type = child_type_name(type_name, node.name)
|
|
455
|
+
return plan_branches(type_name, node, child_type, subgraph, field, fragments, depth) if
|
|
456
|
+
@schema.get_type(child_type)&.kind&.abstract?
|
|
457
|
+
|
|
458
|
+
plan_step(child_type, narrow(child_type, node.selections, fragments), subgraph, fragments,
|
|
459
|
+
provides(field), depth + 1)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# One plan per concrete type `subgraph` can answer this abstract type
|
|
463
|
+
# with — the supergraph says which those are, and a fetch may only name
|
|
464
|
+
# those: a subgraph rejects an `... on T` its own schema doesn't place
|
|
465
|
+
# in the abstract type.
|
|
466
|
+
def plan_branches(type_name, node, abstract_name, subgraph, field, fragments, depth)
|
|
467
|
+
possible = @table.possible_types(abstract_name, subgraph)
|
|
468
|
+
if possible.nil?
|
|
469
|
+
refuse :abstract_boundary, "#{type_name}.#{node.name} returns #{abstract_name}, and " \
|
|
470
|
+
"the supergraph doesn't record which concrete types #{subgraph} answers it with " \
|
|
471
|
+
"(no @join__unionMember or @join__implements, and #{abstract_name} is in more than " \
|
|
472
|
+
"one subgraph)"
|
|
473
|
+
end
|
|
474
|
+
if possible.empty?
|
|
475
|
+
refuse :abstract_boundary, "#{type_name}.#{node.name} returns #{abstract_name}, and " \
|
|
476
|
+
"the supergraph places none of its concrete types in #{subgraph}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
Branches.new(steps: possible.sort.to_h do |concrete|
|
|
480
|
+
[concrete, plan_step(concrete, narrow(concrete, node.selections, fragments), subgraph,
|
|
481
|
+
fragments, provides(field), depth + 1)]
|
|
482
|
+
end)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Refetch this object from its @key in the subgraph that resolves the
|
|
486
|
+
# field, and read the field off the entity that comes back. `target`
|
|
487
|
+
# is this subgraph when the field lives here but @requires fields it
|
|
488
|
+
# doesn't hold — the router refetches for those too.
|
|
489
|
+
def defer(step, type_name, node, subgraph, target, field, fragments, siblings, depth)
|
|
490
|
+
key = usable_key(type_name, node, subgraph, target)
|
|
491
|
+
requires = requires_paths(field)
|
|
492
|
+
check_shadowing!(type_name, node, siblings, (key + requires).uniq)
|
|
493
|
+
|
|
494
|
+
# a @requires field this subgraph doesn't hold is fetched from the
|
|
495
|
+
# one that does and handed back in the representation — a fetch
|
|
496
|
+
# before the fetch, which is what makes this a chain
|
|
497
|
+
elsewhere = requires.reject { |path| path_owners(type_name, path).include?(subgraph) }
|
|
498
|
+
prefetch(step, type_name, node, subgraph, elsewhere)
|
|
499
|
+
|
|
500
|
+
((key + requires).uniq - elsewhere).each { |path| inject(step, path) }
|
|
501
|
+
|
|
502
|
+
child = plan_child(type_name, node, target, field, fragments, depth) if node.selections.any?
|
|
503
|
+
|
|
504
|
+
step.deferrals << Deferral.new(
|
|
505
|
+
node: child ? node.merge(selections: child.selections) : node,
|
|
506
|
+
response_key: node.alias || node.name,
|
|
507
|
+
subgraph: target,
|
|
508
|
+
step: child || nil,
|
|
509
|
+
key:, requires:,
|
|
510
|
+
)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# One fetch per subgraph holding a @requires field this one doesn't,
|
|
514
|
+
# ahead of the fetch that needs them. Only one hop: the key for each
|
|
515
|
+
# has to come from `subgraph` itself, so a chain can't grow a chain.
|
|
516
|
+
def prefetch(step, type_name, node, subgraph, paths)
|
|
517
|
+
paths.each { |path| check_chain!(type_name, node, path) }
|
|
518
|
+
|
|
519
|
+
paths.group_by { |path| requires_holder(type_name, node, path) }.each do |holder, held|
|
|
520
|
+
key = usable_key(type_name, node, subgraph, holder)
|
|
521
|
+
key.each { |path| inject(step, path) }
|
|
522
|
+
step.prefetches << Prefetch.new(subgraph: holder, key:, paths: held, node:)
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# A prefetch sends the entity's own @key and nothing else, so a required
|
|
527
|
+
# field that is itself @requires-ed gets computed from a representation
|
|
528
|
+
# missing its input — silently, and the same field then holds two
|
|
529
|
+
# different values in one response. Asked of every field a path walks
|
|
530
|
+
# through, not only its first: nesting doesn't make a chain shallower.
|
|
531
|
+
def check_chain!(type_name, node, path)
|
|
532
|
+
walk(type_name, path).each do |owner, name|
|
|
533
|
+
inner = @table.field(owner, name)&.requires or next
|
|
534
|
+
|
|
535
|
+
refuse :chained_requires,
|
|
536
|
+
"#{type_name}.#{node.name} @requires #{path.inspect}, and #{owner}.#{name} " \
|
|
537
|
+
"itself @requires #{inner.inspect}"
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def requires_holder(type_name, node, path)
|
|
542
|
+
owners = path_owners(type_name, path)
|
|
543
|
+
return available!(owners, "#{type_name}.#{path}").first if owners.any?
|
|
544
|
+
|
|
545
|
+
# two different facts, and only the second is about nesting: a field
|
|
546
|
+
# the supergraph places nowhere, or one whose path it places in
|
|
547
|
+
# subgraphs that don't overlap
|
|
548
|
+
pairs = walk(type_name, path)
|
|
549
|
+
orphan = pairs.find { |owner, name| @table.owners(owner, name).empty? }
|
|
550
|
+
missing = pairs.empty? ? "#{type_name}.#{path}" : orphan&.join(".")
|
|
551
|
+
refuse(:no_owner, "#{type_name}.#{node.name} @requires #{path.inspect}, and the " \
|
|
552
|
+
"supergraph places #{missing} in no subgraph") if missing
|
|
553
|
+
|
|
554
|
+
refuse :nested_field_set, "#{type_name}.#{node.name} @requires a nested field set " \
|
|
555
|
+
"(#{field_set([path]).inspect}) no one subgraph holds whole (" +
|
|
556
|
+
pairs.map { |owner, name| "#{owner}.#{name} in #{@table.owners(owner, name).join(" or ")}" }
|
|
557
|
+
.join(", ") + ")"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def inject(step, path)
|
|
561
|
+
step.keys << path unless step.keys.include?(path)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# A nested field set arrives as ONE object under one response key, so
|
|
565
|
+
# every path sharing a root has to come from the same fetch: half of
|
|
566
|
+
# `origin` from here and half from a prefetch leaves the object
|
|
567
|
+
# half-built, and two prefetches overwrite each other's half.
|
|
568
|
+
def check_one_source!(type_name, step, subgraph)
|
|
569
|
+
sources = Hash.new { |roots, root| roots[root] = {} }
|
|
570
|
+
step.keys.each { |path| sources[path.split(".").first][path] = subgraph }
|
|
571
|
+
step.prefetches.each do |prefetch|
|
|
572
|
+
prefetch.paths.each { |path| sources[path.split(".").first][path] = prefetch.subgraph }
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
sources.each do |root, from|
|
|
576
|
+
next if from.values.uniq.one?
|
|
577
|
+
|
|
578
|
+
refuse :nested_field_set, "#{type_name}'s #{root.inspect} is part of a field set this " \
|
|
579
|
+
"fetch would have to build from more than one subgraph " \
|
|
580
|
+
"(#{from.map { |path, graph| "#{path} from #{graph}" }.join(", ")})"
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Apollo's router injects the @key under its own name and lets it win,
|
|
585
|
+
# so `{ id: username }` next to a stitched field comes back as the
|
|
586
|
+
# user's id. That is an Apollo bug and a spec-conformant server
|
|
587
|
+
# disagrees — and since we can't match both, refuse rather than hand
|
|
588
|
+
# back an answer one of them contradicts.
|
|
589
|
+
def check_shadowing!(type_name, node, siblings, paths)
|
|
590
|
+
# Apollo injects a field set under its own names, so what an alias
|
|
591
|
+
# can collide with is each path's first segment — the field a flat
|
|
592
|
+
# path is, or the object a nested one arrives in
|
|
593
|
+
roots = paths.map { |path| path.split(".").first }.uniq
|
|
594
|
+
shadowed = siblings.select do |sibling|
|
|
595
|
+
sibling.alias && sibling.alias != sibling.name && roots.include?(sibling.alias)
|
|
596
|
+
end
|
|
597
|
+
return if shadowed.empty?
|
|
598
|
+
|
|
599
|
+
refuse :shadowed_key,
|
|
600
|
+
"#{type_name}.#{node.name} is fetched on #{type_name}'s #{roots.map(&:inspect).join(", ")}, " \
|
|
601
|
+
"and this selection aliases " \
|
|
602
|
+
"#{shadowed.map { |s| "#{s.name} as #{s.alias.inspect}" }.join(", ")} over it"
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# A @key field set the source subgraph can build a representation
|
|
606
|
+
# from — the first the supergraph declares that it can, nested or
|
|
607
|
+
# flat. An @external copy counts: it exists precisely so this
|
|
608
|
+
# subgraph can name the field in its @key.
|
|
609
|
+
def usable_key(type_name, node, from, to)
|
|
610
|
+
candidates = @table.keys(type_name, to)
|
|
611
|
+
if candidates.empty?
|
|
612
|
+
refuse :no_key,
|
|
613
|
+
"#{type_name}.#{node.name} resolves in #{to}, and #{type_name} has no resolvable " \
|
|
614
|
+
"@key there"
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
usable = candidates.find { |paths| paths.all? { |path| declares?(type_name, path, from) } }
|
|
618
|
+
return usable if usable
|
|
619
|
+
|
|
620
|
+
refuse :no_key, "#{type_name}.#{node.name} needs a fetch into #{to}, and #{from} can't " \
|
|
621
|
+
"supply any of #{type_name}'s @keys there " \
|
|
622
|
+
"(#{candidates.map { |paths| field_set(paths).inspect }.join(", ")})"
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def requires_paths(field)
|
|
626
|
+
return [] unless field&.requires
|
|
627
|
+
|
|
628
|
+
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.requires)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Dotted paths back to the selection set they were parsed from — the
|
|
632
|
+
# inverse of RoutingTable.parse_field_set, so a refusal spells the
|
|
633
|
+
# field set the way the schema does and is greppable against it.
|
|
634
|
+
def field_set(paths) = render_field_set(Planner.field_tree(paths))
|
|
635
|
+
|
|
636
|
+
def render_field_set(tree)
|
|
637
|
+
tree.map { |name, children|
|
|
638
|
+
children.empty? ? name : "#{name} { #{render_field_set(children)} }"
|
|
639
|
+
}.join(" ")
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# A @requires field set is supplied by the ROUTER: it fetches those
|
|
643
|
+
# fields elsewhere and hands them back in the representation. So a
|
|
644
|
+
# field is only answerable in place when its own subgraph already
|
|
645
|
+
# holds every one of them — which, since @requires fields are
|
|
646
|
+
# @external there, it essentially never does. When it doesn't, the
|
|
647
|
+
# field is planned as a fetch chain instead (see prefetch).
|
|
648
|
+
def held?(type_name, field, subgraph)
|
|
649
|
+
return true unless field&.requires
|
|
650
|
+
|
|
651
|
+
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.requires)
|
|
652
|
+
.all? { |path| path_owners(type_name, path).include?(subgraph) }
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# The subgraphs that can answer a field set path in ONE fetch: the
|
|
656
|
+
# owners of every field it walks through, intersected. A representation
|
|
657
|
+
# carries the nested object whole, so a path answerable only a level at
|
|
658
|
+
# a time is answerable by nobody.
|
|
659
|
+
def path_owners(type_name, path)
|
|
660
|
+
pairs = walk(type_name, path)
|
|
661
|
+
return [] if pairs.empty?
|
|
662
|
+
|
|
663
|
+
pairs.map { |owner, name| @table.owners(owner, name) }.reduce(:&)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Every [type, field] a dotted path names, from `type_name` down —
|
|
667
|
+
# empty when the composed schema doesn't carry the whole walk.
|
|
668
|
+
def walk(type_name, path)
|
|
669
|
+
pairs = []
|
|
670
|
+
path.split(".").each do |segment|
|
|
671
|
+
return [] if type_name.nil?
|
|
672
|
+
|
|
673
|
+
pairs << [type_name, segment]
|
|
674
|
+
type_name = raw_child_type(type_name, segment)
|
|
675
|
+
end
|
|
676
|
+
pairs
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Every field these selections reach is answerable by `subgraph`, so
|
|
680
|
+
# the whole subtree can go over untouched.
|
|
681
|
+
def local?(type_name, selections, subgraph, fragments, provided, depth = 0)
|
|
682
|
+
return false if depth > MAX_DEPTH
|
|
683
|
+
|
|
684
|
+
selections.all? do |node|
|
|
685
|
+
case node
|
|
686
|
+
when GraphQL::Language::Nodes::Field
|
|
687
|
+
next true if node.name.start_with?("__")
|
|
688
|
+
|
|
689
|
+
local_field?(type_name, node, subgraph, fragments, provided, depth)
|
|
690
|
+
when GraphQL::Language::Nodes::InlineFragment
|
|
691
|
+
condition = node.type&.name || type_name
|
|
692
|
+
declared_in?(condition, subgraph) &&
|
|
693
|
+
local?(condition, node.selections, subgraph, fragments, provided, depth + 1)
|
|
694
|
+
when GraphQL::Language::Nodes::FragmentSpread
|
|
695
|
+
fragment = fragments[node.name] or
|
|
696
|
+
refuse(:undefined_fragment, "the document spreads ...#{node.name}, which it never defines")
|
|
697
|
+
declared_in?(fragment.type.name, subgraph) &&
|
|
698
|
+
local?(fragment.type.name, fragment.selections, subgraph, fragments, provided, depth + 1)
|
|
699
|
+
else false
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def local_field?(type_name, node, subgraph, fragments, provided, depth)
|
|
705
|
+
owners = @table.owners(type_name, node.name)
|
|
706
|
+
return false unless owners.include?(subgraph) || provided.include?(node.name)
|
|
707
|
+
|
|
708
|
+
field = @table.field(type_name, node.name)
|
|
709
|
+
return false unless held?(type_name, field, subgraph)
|
|
710
|
+
return true if node.selections.empty?
|
|
711
|
+
|
|
712
|
+
child = raw_child_type(type_name, node.name) or return false
|
|
713
|
+
local?(child, node.selections, subgraph, fragments, provides(field), depth + 1)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Folding a same-type fragment into its parent drops the fragment node,
|
|
717
|
+
# so whatever @skip/@include it carried has to move onto the selections
|
|
718
|
+
# it guarded — otherwise a stitched plan answers a selection the
|
|
719
|
+
# operation excluded, and fetches a subgraph to do it.
|
|
720
|
+
def carry(node, expanded)
|
|
721
|
+
return expanded if node.directives.empty?
|
|
722
|
+
|
|
723
|
+
expanded.map do |field|
|
|
724
|
+
clash = field.directives.map(&:name) & node.directives.map(&:name)
|
|
725
|
+
if clash.any?
|
|
726
|
+
# one selection can't hold two conditions of the same name
|
|
727
|
+
refuse :conditional_fragment,
|
|
728
|
+
"#{field.alias || field.name} carries @#{clash.first}, and so does the fragment " \
|
|
729
|
+
"spread around it"
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
field.merge(directives: node.directives + field.directives)
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# A fragment's type condition has to exist in the subgraph running
|
|
737
|
+
# it; a type only another subgraph declares can't be matched there.
|
|
738
|
+
# Types the routing table says nothing about (scalars, enums) are
|
|
739
|
+
# nobody's.
|
|
740
|
+
def declared_in?(type_name, subgraph)
|
|
741
|
+
declared = @table.declared_in(type_name)
|
|
742
|
+
declared.empty? || declared.include?(subgraph)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Whether `subgraph` can hand back this field set path as part of a
|
|
746
|
+
# representation — every field it walks through, since a nested path
|
|
747
|
+
# is selected there in one go. An @external copy counts, which is the
|
|
748
|
+
# whole reason one is declared.
|
|
749
|
+
def declares?(type_name, path, subgraph)
|
|
750
|
+
pairs = walk(type_name, path)
|
|
751
|
+
pairs.any? && pairs.all? { |owner, name| declares_field?(owner, name, subgraph) }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def declares_field?(type_name, field_name, subgraph)
|
|
755
|
+
field = @table.field(type_name, field_name)
|
|
756
|
+
return @table.declared_in(type_name).include?(subgraph) if field.nil?
|
|
757
|
+
|
|
758
|
+
field.graphs.include?(subgraph) || field.external.include?(subgraph)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# @provides says this subgraph carries its own copy of fields it
|
|
762
|
+
# doesn't own, and the router reads that copy rather than routing to
|
|
763
|
+
# the owner — so a query reaching only provided fields never leaves.
|
|
764
|
+
# Flat sets only; a nested one widens nothing and its fields fall back
|
|
765
|
+
# to the ordinary owner check.
|
|
766
|
+
def provides(field)
|
|
767
|
+
return [] unless field&.provides
|
|
768
|
+
|
|
769
|
+
GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.provides)
|
|
770
|
+
.reject { |path| path.include?(".") }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def owners!(type_name, field_name)
|
|
774
|
+
owners = @table.owners(type_name, field_name)
|
|
775
|
+
return owners if owners.any?
|
|
776
|
+
|
|
777
|
+
refuse :no_owner, "the supergraph places #{type_name}.#{field_name} in no subgraph"
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# The subgraphs among `owners` this process actually serves. A
|
|
781
|
+
# supergraph is routinely only partly local, so absence is refused
|
|
782
|
+
# here — where the field that reached for it is still in hand —
|
|
783
|
+
# rather than at construction, which would refuse the whole suite
|
|
784
|
+
# over fields it may never touch.
|
|
785
|
+
def available!(owners, coordinate)
|
|
786
|
+
here = owners - @unserved
|
|
787
|
+
return here if here.any?
|
|
788
|
+
|
|
789
|
+
# ambiguity first: it's the one with a fix that isn't "stand the
|
|
790
|
+
# service up", and the classes are right here to name
|
|
791
|
+
unsettled = owners.find { |name| @ambiguous.key?(name) }
|
|
792
|
+
ambiguous!(unsettled, coordinate) if unsettled
|
|
793
|
+
|
|
794
|
+
absent = owners.map(&:inspect)
|
|
795
|
+
refuse :absent_subgraph, "#{coordinate} resolves in #{absent.join(" or ")}, which no " \
|
|
796
|
+
"schema here serves — nothing loaded defines what the supergraph says " \
|
|
797
|
+
"#{absent.first} resolves. #{advice(absent.first)}"
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Which class serves this subgraph is a question only the caller can
|
|
801
|
+
# answer — but it is only worth asking about the subgraphs a query
|
|
802
|
+
# reaches, since which classes happen to be loaded is not a fact
|
|
803
|
+
# about the query.
|
|
804
|
+
def ambiguous!(name, coordinate)
|
|
805
|
+
found = @ambiguous.fetch(name)
|
|
806
|
+
raise GraphWeaver::ConfigurationError, "#{coordinate} resolves in #{name.inspect}, and " \
|
|
807
|
+
"#{found.size} loaded schema classes define everything the supergraph says it " \
|
|
808
|
+
"resolves (#{found.join(", ")}) — which of them serves it is a question only you can " \
|
|
809
|
+
"answer. Pin it: subgraphs: { #{name.inspect} => #{found.first} } " \
|
|
810
|
+
"(GraphWeaver::Testing.config.router = { subgraphs: … } under the rspec tag, or " \
|
|
811
|
+
"subgraphs: on Router.new)."
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Two causes, and only one of them applies at a time. A class Rails
|
|
815
|
+
# hasn't autoloaded yet is the usual one — but not when eager loading
|
|
816
|
+
# is already on, and *that* the library can just ask, rather than
|
|
817
|
+
# leading with a guess it can see is wrong. The other cause is a
|
|
818
|
+
# subgraph that genuinely runs in another service, and its fix has to
|
|
819
|
+
# come first for the reader it applies to. Either way the surface
|
|
820
|
+
# named is the one an rspec example can reach: there is no Router.new
|
|
821
|
+
# in sight from inside one.
|
|
822
|
+
def advice(name)
|
|
823
|
+
fake = "subgraphs: { #{name} => #{Subgraphs::FAKE.inspect} } " \
|
|
824
|
+
"(GraphWeaver::Testing.config.router = { subgraphs: … } under the rspec tag, or " \
|
|
825
|
+
"subgraphs: on Router.new) — or a schema class in place of #{Subgraphs::FAKE.inspect}"
|
|
826
|
+
if eager_loaded?
|
|
827
|
+
"Eager loading is on, so it isn't a class waiting to be autoloaded — it runs " \
|
|
828
|
+
"elsewhere. Fabricate its answers: #{fake}."
|
|
829
|
+
else
|
|
830
|
+
"Rails autoloads, so the class is probably just not loaded yet: eager-load it " \
|
|
831
|
+
"(config.eager_load, or config.rake_eager_load under rake). If it runs elsewhere, " \
|
|
832
|
+
"fabricate its answers instead — #{fake}."
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Whether the "not autoloaded yet" half of the advice is already ruled
|
|
837
|
+
# out. Outside Rails there is no autoloading to blame either.
|
|
838
|
+
# const_get rather than a bare Rails: sorbet can't resolve a constant
|
|
839
|
+
# the gem doesn't depend on.
|
|
840
|
+
def eager_loaded?
|
|
841
|
+
return false unless Object.const_defined?(:Rails)
|
|
842
|
+
|
|
843
|
+
config = Object.const_get(:Rails).application&.config or return false
|
|
844
|
+
!!(config.eager_load ||
|
|
845
|
+
(Object.const_defined?(:Rake) && config.respond_to?(:rake_eager_load) && config.rake_eager_load))
|
|
846
|
+
rescue NoMethodError
|
|
847
|
+
false # something else named Rails
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def child_type_name(type_name, field_name)
|
|
851
|
+
raw_child_type(type_name, field_name) ||
|
|
852
|
+
refuse(:no_owner, "#{type_name}.#{field_name} is not a field of the composed schema")
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def raw_child_type(type_name, field_name)
|
|
856
|
+
type = @schema.get_type(type_name)
|
|
857
|
+
return unless type.respond_to?(:fields)
|
|
858
|
+
|
|
859
|
+
field = type.fields[field_name] or return
|
|
860
|
+
field.type.unwrap.graphql_name
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def refuse(category, message)
|
|
864
|
+
raise Testing::Unplannable.new(message, category:)
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
end
|