graph_weaver 0.5.0 → 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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. 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