graph_weaver 0.4.4 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -0,0 +1,1431 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+ require "json"
6
+
7
+ require_relative "../parsing"
8
+ require_relative "../schema_loader"
9
+ require_relative "../transport"
10
+ require_relative "subgraphs"
11
+
12
+ module GraphWeaver
13
+ module Testing
14
+ # A query the local router will not plan. Every one of these is a query
15
+ # a real router *would* answer — refusing is the whole design, because
16
+ # the alternative is a test that passes against semantics production
17
+ # doesn't have.
18
+ #
19
+ # A refusal is #detail (what stopped this query) plus the advice its
20
+ # #category carries, so a pile of refusals aggregates by category and
21
+ # still reads as one sentence each. `rake
22
+ # graph_weaver:federation:coverage` is that pile, counted.
23
+ class Unplannable < GraphWeaver::Error
24
+ # every way the local router refuses: the label a report groups by, and
25
+ # the next action the message ends with
26
+ CATEGORIES = {
27
+ no_key: [
28
+ "no @key to cross the boundary on",
29
+ "an entity fetch sends a representation built from a @key; with none there's nothing to " \
30
+ "send. Run this one against a real router.",
31
+ ],
32
+ abstract_boundary: [
33
+ "an abstract type the supergraph doesn't break down",
34
+ "the local router crosses an abstract boundary by bucketing objects on their " \
35
+ "__typename, so it has to know which concrete types the subgraph can answer with — " \
36
+ "and this supergraph doesn't say. Run this one against a real router.",
37
+ ],
38
+ interface_object: [
39
+ "an @interfaceObject the routing table can't attribute",
40
+ "one subgraph resolves a whole interface's implementations there, so the supergraph " \
41
+ "doesn't say which subgraph answers each of its fields. Run this one against a real " \
42
+ "router.",
43
+ ],
44
+ chained_requires: [
45
+ "a @requires whose field set names another @requires field",
46
+ "the router satisfies a @requires with one fetch, so it can't first satisfy that " \
47
+ "field's own requirement. Run this one against a real router.",
48
+ ],
49
+ nested_field_set: [
50
+ "a nested field set no one fetch can build",
51
+ "a representation carries a nested field set as one object, so one fetch has to answer " \
52
+ "the whole of it — and here every subgraph answers only part. Run this one against a " \
53
+ "real router.",
54
+ ],
55
+ conditional_fragment: [
56
+ "@skip/@include on both a fragment and its field",
57
+ "one selection can't carry two conditions of the same name. Spell the condition once, " \
58
+ "on the field or on the fragment.",
59
+ ],
60
+ shadowed_key: [
61
+ "an alias shadowing an injected @key",
62
+ "Apollo's router resolves that collision in favour of its own injected key and a " \
63
+ "spec-conformant server doesn't, so there is no one answer to agree with. Rename the alias.",
64
+ ],
65
+ root_fields_span: [
66
+ "a mutation's root fields span subgraphs",
67
+ "root mutation fields run in series and the local router can't serialize across " \
68
+ "subgraphs. Split it into one operation per subgraph, or run this one against a real router.",
69
+ ],
70
+ no_owner: [
71
+ "the routing table names no subgraph",
72
+ "nothing can route a field the supergraph doesn't place. Run this one against a real router.",
73
+ ],
74
+ absent_subgraph: [
75
+ "a subgraph nothing here serves",
76
+ "a query that never reaches an absent subgraph's fields still runs, so nothing else has " \
77
+ "to change.",
78
+ ],
79
+ mixed_introspection: [
80
+ "introspection mixed with data",
81
+ "the local router answers introspection from the composed API schema and data from the " \
82
+ "subgraphs, and can't merge the two. Split them into two operations.",
83
+ ],
84
+ ambiguous_operation: [
85
+ "the document isn't one operation",
86
+ "pass operation_name: naming one of them.",
87
+ ],
88
+ operation_type: [
89
+ "not a query or a mutation",
90
+ "the local router plans queries and mutations against the composed schema's roots. Run " \
91
+ "this one against a real router.",
92
+ ],
93
+ undefined_fragment: [
94
+ "a fragment the document never defines",
95
+ "define it, or point the query at the file that does.",
96
+ ],
97
+ unsupported_federation: [
98
+ "a federation construct the routing table doesn't read",
99
+ "the routing table is incomplete, so every answer it gives about this supergraph would be " \
100
+ "a guess. Run this graph's queries against a real router.",
101
+ ],
102
+ too_deep: [
103
+ "nested deeper than the router walks",
104
+ "run this one against a real router.",
105
+ ],
106
+ }.freeze
107
+
108
+ attr_reader :category, :detail
109
+
110
+ def initialize(detail, category:)
111
+ @category = category
112
+ @detail = detail
113
+ super("#{detail} — #{CATEGORIES.fetch(category).last}")
114
+ end
115
+
116
+ # the short label a report groups this refusal under
117
+ def label = CATEGORIES.fetch(category).first
118
+
119
+ def to_h = super.merge("category" => category.to_s, "detail" => detail)
120
+ end
121
+
122
+ # A federation router for tests: it satisfies the client contract, so
123
+ # `GraphWeaver.client = router` runs every generated module against your
124
+ # real subgraph resolvers, in-process — no gateway, no node, no sockets.
125
+ #
126
+ # GraphWeaver::Testing::Router.new(
127
+ # supergraph: Rails.root.join("supergraph.graphql"),
128
+ # context: { current_user: user },
129
+ # )
130
+ #
131
+ # (`subgraphs:` is optional — see {Subgraphs}.)
132
+ #
133
+ # A supergraph only **partly** local — the rest of it served by other
134
+ # processes — needs nothing extra: the subgraphs nobody here defines are
135
+ # absent, the router builds and runs, and only a query that reaches an
136
+ # absent subgraph's fields is refused, at plan time, naming it. Ask for
137
+ # fabricated data instead with `subgraphs: { "reviews" => :fake }`; every
138
+ # fetch that came from one is marked `faked: true` in #trace.
139
+ #
140
+ # It plans the shapes a router spends its life on: an operation that
141
+ # resolves in one subgraph, handed over verbatim; one that crosses a
142
+ # boundary — split at the crossing, refetched from the owning subgraph
143
+ # through `_entities(representations:)`, and stitched back; a `@requires`
144
+ # field set, fetched from the subgraph that holds it and handed back in
145
+ # the representation; a nested `@key` or `@requires`, which crosses as
146
+ # the object the SDL spells rather than as a flattened path; and a union
147
+ # or interface at a boundary, planned per concrete type and bucketed on
148
+ # the `__typename` the data comes back with.
149
+ # Everything it can't plan
150
+ # *faithfully* raises {Unplannable}, before any subgraph runs, so a
151
+ # refusal can never be a half-executed query. Apollo's planner is twenty
152
+ # thousand lines; a double that approximated the rest of it would let a
153
+ # test pass on an answer production disagrees with, which is the most
154
+ # expensive thing this library can produce.
155
+ #
156
+ # Introspection is answered from the composed API schema — never from a
157
+ # subgraph, which would reply with its own slice. That is the one split a
158
+ # real router also makes.
159
+ #
160
+ # #trace records the fetches made since the last #reset_trace, in order
161
+ # (subgraph, query, variables); the same lines go to GraphWeaver.logger
162
+ # at :debug. The rspec integration resets it per example; anywhere else,
163
+ # reset it yourself around the code path you're measuring.
164
+ class Router
165
+ include GraphWeaver::Parsing
166
+
167
+ # the schema the router serves — the supergraph with its composition
168
+ # machinery stripped, exactly what a real router exposes
169
+ attr_reader :schema
170
+
171
+ # who resolves what (GraphWeaver::SchemaLoader::RoutingTable)
172
+ attr_reader :table
173
+
174
+ # every fetch made since the last {#reset_trace}, in order — so "which
175
+ # subgraphs did this code path touch" is answerable for a service
176
+ # object that runs more than one query
177
+ attr_reader :trace
178
+
179
+ # subgraphs no schema here serves: a query reaching their fields is
180
+ # refused at plan time, everything else runs
181
+ attr_reader :absent
182
+
183
+ # subgraphs answered with fabricated data instead of that refusal
184
+ attr_reader :faked
185
+
186
+ # the context handed to every subgraph — settable, so one example can
187
+ # run as a different user without rebuilding the router
188
+ attr_accessor :context
189
+
190
+ # response keys the planner injects to carry a @key across a boundary,
191
+ # stripped before the caller sees the tree
192
+ PREFIX = "_gw_"
193
+
194
+ # Where the injected __typename lands. Which concrete type an abstract
195
+ # position holds is a fact only the data carries, so every abstract
196
+ # fetch asks for it — under this key whether or not the caller did.
197
+ TYPENAME = "#{PREFIX}__typename"
198
+
199
+ # A field set as dotted paths, back into the selection set it was parsed
200
+ # from ({"origin" => {"lat" => {}, "lon" => {}}}). Both sides of a
201
+ # crossing need it: one to ask for the fields, the other to read them
202
+ # back in the shape the SDL spells.
203
+ def self.field_tree(paths)
204
+ paths.each_with_object({}) do |path, tree|
205
+ path.split(".").reduce(tree) { |node, segment| node[segment] ||= {} }
206
+ end
207
+ end
208
+
209
+ # What a fetch adds to carry a field set across a boundary: one field
210
+ # per root, aliased under PREFIX so the caller's answer never gains a
211
+ # field it didn't ask for, and nested exactly as the field set is —
212
+ # `origin { lat lon }` comes back whole, under one response key.
213
+ def self.injected_selections(paths)
214
+ field_tree(paths).map do |root, children|
215
+ GraphQL::Language::Nodes::Field.new(
216
+ name: root, field_alias: PREFIX + root, selections: field_selections(children),
217
+ )
218
+ end
219
+ end
220
+
221
+ def self.field_selections(tree)
222
+ tree.map do |name, children|
223
+ GraphQL::Language::Nodes::Field.new(name:, selections: field_selections(children))
224
+ end
225
+ end
226
+
227
+ # subgraphs: names the Ruby schema serving each subgraph. Omit it (or
228
+ # any of its entries) and the rest are derived from what each loaded
229
+ # schema defines — see {Subgraphs}, which also checks the ones you name.
230
+ # A subgraph nothing serves is absent (refused per query, not here);
231
+ # `"reviews" => :fake` fabricates its answers instead.
232
+ def initialize(supergraph:, subgraphs: nil, context: {})
233
+ source = supergraph.to_s # a path, or the SDL itself — Pathname included
234
+ @schema = GraphWeaver::SchemaLoader.load(source)
235
+ @table = GraphWeaver::SchemaLoader.routing_table(source)
236
+ @context = context
237
+ @trace = []
238
+
239
+ # refuse at construction, not per query: an unread @join__ construct
240
+ # means the routing table is incomplete, and every answer it gives
241
+ # about this supergraph is a guess — including which schema serves
242
+ # which subgraph, so this comes before resolving those
243
+ unless @table.unsupported.empty?
244
+ raise Unplannable.new(
245
+ "this supergraph uses federation constructs the local router doesn't read: " +
246
+ @table.unsupported.join("; "),
247
+ category: :unsupported_federation,
248
+ )
249
+ end
250
+
251
+ served = Subgraphs.resolve(@table, subgraphs)
252
+ @faked = served.select { |_name, schema| schema == Subgraphs::FAKE }.keys.freeze
253
+ @absent = (@table.subgraphs - served.keys).freeze
254
+ @subgraphs = served.to_h do |name, schema|
255
+ [name, (schema == Subgraphs::FAKE) ? FakeSubgraph.new(name, @schema) : schema]
256
+ end
257
+ @planner = Planner.new(table: @table, schema: @schema, absent: @absent)
258
+ end
259
+
260
+ # Drop the fetches recorded so far. A router is built once and reused
261
+ # (the rspec tag builds one per suite), so without this #trace answers
262
+ # about whatever ran before as well.
263
+ def reset_trace
264
+ @trace = []
265
+ self
266
+ end
267
+
268
+ def execute(query, variables: {}, operation_name: nil)
269
+ document = begin
270
+ GraphQL.parse(query)
271
+ rescue GraphQL::ParseError => e
272
+ return { "data" => nil, "errors" => [graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
273
+ end
274
+
275
+ # validate the way a router does, so a stale query fails as it fails
276
+ # in production rather than somewhere inside the planner
277
+ errors = @planner.validate(document)
278
+ return { "data" => nil, "errors" => errors } if errors.any?
279
+
280
+ plan = @planner.plan(document, operation_name:)
281
+ return introspect(query, variables, plan.operation_name) if plan.introspection
282
+ # one subgraph answers the whole thing: hand it the document as
283
+ # written, so nothing is rewritten that didn't have to be
284
+ return fetch(plan.entry, query, variables, plan.operation_name) if plan.verbatim
285
+
286
+ run(plan, variables)
287
+ end
288
+
289
+ # never leak the context (tokens, current_user) through logs or errors
290
+ def inspect
291
+ parts = ["subgraphs=#{(@subgraphs.keys - @faked).inspect}"]
292
+ parts << "faked=#{@faked.inspect}" if @faked.any?
293
+ parts << "absent=#{@absent.inspect}" if @absent.any?
294
+ "#<#{self.class.name} #{parts.join(" ")}>"
295
+ end
296
+ alias to_s inspect
297
+
298
+ private
299
+
300
+ # __schema / __type describe the COMPOSED graph; a subgraph would
301
+ # answer with its own slice
302
+ def introspect(query, variables, operation_name)
303
+ @schema.execute(query, variables: variables.to_h, operation_name:).to_h
304
+ end
305
+
306
+ # ---- execution ----------------------------------------------------
307
+
308
+ def run(plan, variables)
309
+ errors = []
310
+ data = {}
311
+ # An operation's declared defaults are part of the variables, and
312
+ # graphql-ruby applies them — so @skip/@include has to see them too,
313
+ # or a field the caller never opted out of goes missing.
314
+ given = variable_defaults(plan.operation)
315
+ .merge(variables.to_h { |name, value| [name.to_s, value] })
316
+
317
+ plan.steps.each do |step|
318
+ result = fetch_step(step, plan.operation, given)
319
+ Array(result["errors"]).each { |error| errors << rewrite(error) }
320
+ payload = result["data"]
321
+ if payload.nil?
322
+ # the subgraph nulled its whole response, so every field it was
323
+ # asked for is null — recording that is what lets propagation
324
+ # decide what it does to the merged tree
325
+ step.selections.each { |node| data[node.alias || node.name] = nil }
326
+ else
327
+ payload.each { |key, value| data[key] = value }
328
+ end
329
+ end
330
+
331
+ plan.steps.each { |step| stitch(step, [[data, []]], plan.operation, given, errors) }
332
+
333
+ # A stitched fetch can leave a null where the composed schema says
334
+ # non-null, and nothing re-applies GraphQL's propagation rules over a
335
+ # merged tree unless this does: without it the local answer is
336
+ # *wrong* rather than incomplete, handing back a populated subtree the
337
+ # router would have nulled.
338
+ merged = propagate(data, plan.root_type, plan.selections, plan.fragments)
339
+ response = { "data" => merged.equal?(BUBBLE) ? nil : merged }
340
+ response["errors"] = errors if errors.any?
341
+ response
342
+ end
343
+
344
+ # Everything the plan applies at this level: one _entities fetch per
345
+ # subgraph the level defers to (all nodes at once — _entities answers
346
+ # in representation order), then the same again one level down.
347
+ def variable_defaults(operation)
348
+ operation.variables.each_with_object({}) do |definition, defaults|
349
+ value = definition.default_value
350
+ next if value.nil? || value.is_a?(GraphQL::Language::Nodes::NullValue)
351
+
352
+ defaults[definition.name] = value
353
+ end
354
+ end
355
+
356
+ # @skip/@include against the variables in hand, defaults included. A
357
+ # variable with neither reads as absent, which excludes under @include
358
+ # and includes under @skip — the same way graphql-ruby resolves it.
359
+ def included?(node, variables)
360
+ node.directives.all? do |directive|
361
+ next true unless %w[skip include].include?(directive.name)
362
+
363
+ argument = directive.arguments.find { |arg| arg.name == "if" } or next true
364
+ value = argument.value
365
+ value = variables[value.name] if value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
366
+
367
+ directive.name == "skip" ? !value : !value.nil? && value != false
368
+ end
369
+ end
370
+
371
+ def stitch(step, nodes, operation, variables, errors)
372
+ return if nodes.empty?
373
+
374
+ # An abstract position: the plan holds one branch per concrete type
375
+ # the subgraph can answer with, and only the data says which applies.
376
+ # So bucket on the __typename that came back — each bucket then
377
+ # crosses on its own type's @key, which is what a representation
378
+ # needs and what the planner could not have known.
379
+ if step.is_a?(Planner::Branches)
380
+ step.steps.each do |type_name, branch|
381
+ stitch(branch, nodes.select { |(node, _)| node[TYPENAME] == type_name },
382
+ operation, variables, errors)
383
+ end
384
+ return
385
+ end
386
+
387
+ blocked = prefetch(step, nodes, operation, variables, errors)
388
+
389
+ # a @requires fetch and a plain one need different node sets, so they
390
+ # can't share a call even into the same subgraph — which is the split
391
+ # a real router makes too
392
+ # A fetch for a selection the operation excluded is a fetch a real
393
+ # router never makes, and `trace` is something specs assert on. The
394
+ # plan is built once and reused, so only here are the variables known.
395
+ wanted = step.deferrals.select { |d| included?(d.node, variables) }
396
+
397
+ wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
398
+ fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
399
+ tree = Router.field_tree(deferrals.flat_map(&:representation).uniq)
400
+ representations = fetched.map { |(node, _)| representation(node, tree, step.type_name) }
401
+
402
+ entities = []
403
+ if fetched.any?
404
+ result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation, variables)
405
+ entities = result.dig("data", "_entities") || []
406
+ Array(result["errors"]).each { |error| errors << rewrite(error, fetched) }
407
+ end
408
+
409
+ fetched.each_with_index do |(node, _), index|
410
+ entity = entities[index]
411
+ deferrals.each do |deferral|
412
+ # @skip/@include leave a key ABSENT rather than null, and
413
+ # copying a null would invent one the router never emits
414
+ next if entity && !entity.key?(deferral.response_key)
415
+
416
+ node[deferral.response_key] = entity && entity[deferral.response_key]
417
+ end
418
+ end
419
+
420
+ # nothing supplied its @requires, so nothing can resolve the field
421
+ (nodes - fetched).each do |(node, _)|
422
+ deferrals.each { |deferral| node[deferral.response_key] = nil }
423
+ end
424
+
425
+ deferrals.each do |deferral|
426
+ next unless deferral.step
427
+
428
+ stitch(deferral.step, descend(nodes, deferral.response_key), operation, variables, errors)
429
+ end
430
+ end
431
+
432
+ step.children.each do |key, child|
433
+ stitch(child, descend(nodes, key), operation, variables, errors)
434
+ end
435
+
436
+ nodes.each { |(node, _)| strip!(node, step) }
437
+ end
438
+
439
+ # The @requires fields the router has to hand back, fetched into hidden
440
+ # keys before the fetch whose representation carries them. Returns the
441
+ # nodes the holding subgraph didn't recognize: their required fields
442
+ # don't exist, so nothing depending on them can resolve.
443
+ def prefetch(step, nodes, operation, variables, errors)
444
+ blocked = []
445
+ step.prefetches.each do |prefetch|
446
+ key = Router.field_tree(prefetch.key)
447
+ representations = nodes.map { |(node, _)| representation(node, key, step.type_name) }
448
+ selections = Router.injected_selections(prefetch.paths)
449
+ roots = selections.map(&:alias)
450
+
451
+ result = entities_fetch(prefetch.subgraph, step.type_name, selections, representations, operation, variables)
452
+ entities = result.dig("data", "_entities") || []
453
+ Array(result["errors"]).each { |error| errors << rewrite(error, nodes) }
454
+
455
+ nodes.each_with_index do |(node, _), index|
456
+ entity = entities[index]
457
+ blocked << node.object_id if entity.nil?
458
+ roots.each { |root| node[root] = entity && entity[root] }
459
+ end
460
+ end
461
+ blocked
462
+ end
463
+
464
+ # The representation an entity fetch sends for one object: every path
465
+ # the field set names, read back out of the response key its injected
466
+ # selection landed under. Pruned to that field set — one selection can
467
+ # carry two deferrals' fields, and a representation holding fields the
468
+ # @key doesn't name isn't the one a router sends.
469
+ def representation(node, tree, type_name)
470
+ tree.to_h { |root, children| [root, prune(node[PREFIX + root], children)] }
471
+ .merge("__typename" => type_name)
472
+ end
473
+
474
+ # a null object contributes a null rather than dropping the field, which
475
+ # is the representation a real gateway sends for one too
476
+ def prune(value, tree)
477
+ return value if tree.empty?
478
+
479
+ case value
480
+ when Array then value.map { |item| prune(item, tree) }
481
+ when Hash then tree.to_h { |name, children| [name, prune(value[name], children)] }
482
+ end
483
+ end
484
+
485
+ # Every object the plan's next level applies to, with the response path
486
+ # that reached it — list dimensions flattened, nulls contributing
487
+ # nothing (a null parent has no representation, so it needs no fetch).
488
+ def descend(nodes, key)
489
+ nodes.flat_map { |(node, path)| flatten(node[key], path + [key]) }
490
+ end
491
+
492
+ def flatten(value, path)
493
+ case value
494
+ when Array then value.each_with_index.flat_map { |item, i| flatten(item, path + [i]) }
495
+ when Hash then [[value, path]]
496
+ else []
497
+ end
498
+ end
499
+
500
+ def strip!(node, step)
501
+ step.injected.each { |key| node.delete(key) }
502
+ end
503
+
504
+ # A subgraph reports where the failure was in the query IT ran, and a
505
+ # stitched plan runs queries the caller never wrote: `_entities.<i>.…`
506
+ # is a path into the fetch, and `locations` a position in it. Re-path
507
+ # what can be re-pathed and drop what can't, rather than hand back a
508
+ # line number pointing into a document that doesn't exist.
509
+ def rewrite(error, nodes = nil)
510
+ path = error["path"]
511
+ return error.except("locations") unless path.is_a?(Array)
512
+
513
+ stitched = nodes && path.first == "_entities"
514
+ prefix = stitched ? (nodes.dig(path[1], 1) || []) : []
515
+ error.except("locations").merge("path" => prefix + unalias(stitched ? path[2..] : path))
516
+ end
517
+
518
+ # The @key/@requires fields we inject are ours; an error path naming one
519
+ # points the caller at a field no schema has.
520
+ def unalias(path)
521
+ Array(path).map { |segment| segment.is_a?(String) ? segment.delete_prefix(PREFIX) : segment }
522
+ end
523
+
524
+ def fetch_step(step, operation, variables)
525
+ document = GraphQL::Language::Nodes::OperationDefinition.new(
526
+ operation_type: operation.operation_type || "query",
527
+ variables: used_variables(step.selections, operation),
528
+ selections: step.selections,
529
+ )
530
+ run_subgraph(step.subgraph, document, variables)
531
+ end
532
+
533
+ def entities_fetch(subgraph, type_name, nodes, representations, operation, variables)
534
+ entities = GraphQL::Language::Nodes::Field.new(
535
+ name: "_entities",
536
+ arguments: [GraphQL::Language::Nodes::Argument.new(
537
+ name: "representations",
538
+ value: GraphQL::Language::Nodes::VariableIdentifier.new(name: REPRESENTATIONS),
539
+ )],
540
+ selections: [GraphQL::Language::Nodes::InlineFragment.new(
541
+ type: GraphQL::Language::Nodes::TypeName.new(name: type_name),
542
+ selections: nodes,
543
+ )],
544
+ )
545
+ document = GraphQL::Language::Nodes::OperationDefinition.new(
546
+ operation_type: "query",
547
+ variables: [REPRESENTATIONS_DEFINITION] + used_variables(nodes, operation),
548
+ selections: [entities],
549
+ )
550
+ run_subgraph(subgraph, document, variables.merge(REPRESENTATIONS => representations))
551
+ end
552
+
553
+ REPRESENTATIONS = "representations"
554
+ REPRESENTATIONS_DEFINITION = GraphQL::Language::Nodes::VariableDefinition.new(
555
+ name: REPRESENTATIONS,
556
+ type: GraphQL::Language::Nodes::NonNullType.new(
557
+ of_type: GraphQL::Language::Nodes::ListType.new(
558
+ of_type: GraphQL::Language::Nodes::NonNullType.new(
559
+ of_type: GraphQL::Language::Nodes::TypeName.new(name: "_Any"),
560
+ ),
561
+ ),
562
+ ),
563
+ )
564
+
565
+ # A subgraph query may only declare the variables it uses, so each
566
+ # fetch carries the slice of the operation's definitions it reached.
567
+ def used_variables(nodes, operation)
568
+ names = variable_names(nodes)
569
+ operation.variables.select { |definition| names.include?(definition.name) }
570
+ end
571
+
572
+ # #children is every child node — arguments, directives, selections,
573
+ # and an argument's value when that value is a node — so a $var reached
574
+ # anywhere under these selections is reached from here.
575
+ def variable_names(node)
576
+ case node
577
+ when GraphQL::Language::Nodes::VariableIdentifier then [node.name]
578
+ when Array then node.flat_map { |item| variable_names(item) }
579
+ when GraphQL::Language::Nodes::AbstractNode then variable_names(node.children)
580
+ else []
581
+ end
582
+ end
583
+
584
+ def run_subgraph(subgraph, document, variables)
585
+ declared = document.variables.map(&:name)
586
+ fetch(subgraph, document.to_query_string, variables.slice(*declared), nil)
587
+ end
588
+
589
+ def fetch(name, query, variables, operation_name)
590
+ faked = @faked.include?(name)
591
+ entry = { subgraph: name, query:, variables: variables.to_h }
592
+ entry[:faked] = true if faked
593
+ @trace << entry
594
+ tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
595
+
596
+ # a fabricated answer that passes silently is worse than a failing
597
+ # one, so it says so every fetch rather than once at construction
598
+ if faked
599
+ GraphWeaver.log(:warn) { "router -> #{name} #{tag} FAKED: fabricated data, not #{name}'s" }
600
+ end
601
+
602
+ GraphWeaver.log(:debug) do
603
+ "router -> #{name} #{tag} variables=#{JSON.generate(variables)}\n" \
604
+ "#{GraphWeaver::Transport.truncate_for_log(query)}"
605
+ end
606
+
607
+ GraphWeaver.log_timed(:debug, "router -> #{name} #{tag} completed") do
608
+ @subgraphs.fetch(name).execute(query, variables:, operation_name:, context: @context).to_h
609
+ end
610
+ end
611
+
612
+ def graphql_error(message, code)
613
+ { "message" => message, "extensions" => { "code" => code } }
614
+ end
615
+
616
+ # ---- null propagation ---------------------------------------------
617
+
618
+ # a position whose type forbids null but whose value is null: the
619
+ # parent goes null, and again, until a nullable spot absorbs it
620
+ BUBBLE = Object.new
621
+ private_constant :BUBBLE
622
+
623
+ def propagate(value, type, selections, fragments)
624
+ if type.non_null?
625
+ inner = propagate(value, type.of_type, selections, fragments)
626
+ (inner.nil? || inner.equal?(BUBBLE)) ? BUBBLE : inner
627
+ elsif type.list?
628
+ return if value.nil?
629
+
630
+ items = value.map { |item| propagate(item, type.of_type, selections, fragments) }
631
+ items.any? { |item| item.equal?(BUBBLE) } ? nil : items
632
+ elsif type.kind.abstract?
633
+ # which selections apply here is a fact about the data: the same
634
+ # __typename the fetch bucketed by says what this object is. Without
635
+ # one the subtree ran whole in one subgraph, which already applied
636
+ # its own propagation — there is nothing to redo.
637
+ concrete = value.is_a?(Hash) ? @schema.types[value[TYPENAME] || value["__typename"]] : nil
638
+ return value unless concrete&.kind&.fields?
639
+
640
+ propagate_object(value, concrete, @planner.narrow(concrete.graphql_name, selections, fragments), fragments)
641
+ elsif type.kind.fields?
642
+ propagate_object(value, type, selections, fragments)
643
+ else
644
+ value
645
+ end
646
+ end
647
+
648
+ def propagate_object(value, type, selections, fragments)
649
+ return if value.nil?
650
+ return value unless value.is_a?(Hash)
651
+
652
+ # a merged tree carries each subgraph's keys in fetch order; the
653
+ # response is supposed to be in the query's
654
+ ordered = {}
655
+ selections.each do |node|
656
+ key = node.alias || node.name
657
+ ordered[key] = value[key] if value.key?(key)
658
+ end
659
+ # an injected key is the router's own bookkeeping, never the caller's
660
+ value.each { |key, held| ordered[key] = held unless ordered.key?(key) || key.start_with?(PREFIX) }
661
+
662
+ selections.each do |node|
663
+ next if node.name.start_with?("__")
664
+
665
+ key = node.alias || node.name
666
+ next unless ordered.key?(key)
667
+
668
+ field = type.fields[node.name] or next
669
+ child = field.type.unwrap
670
+ # an abstract position picks its selections per object, from the
671
+ # __typename in the data — nothing can inline them for a type yet
672
+ sub =
673
+ if node.selections.empty? then []
674
+ elsif child.kind.abstract? then node.selections
675
+ else @planner.narrow(child.graphql_name, node.selections, fragments)
676
+ end
677
+ result = propagate(ordered[key], field.type, sub, fragments)
678
+ return if result.equal?(BUBBLE)
679
+
680
+ ordered[key] = result
681
+ end
682
+ ordered
683
+ end
684
+
685
+ # Decides which subgraph answers what — and, where an operation crosses
686
+ # a boundary, the tree of fetches that answers it. Separate from the
687
+ # Router because deciding needs only the supergraph: `rake
688
+ # graph_weaver:federation:coverage` measures how much of a query set is
689
+ # plannable without any subgraph being runnable.
690
+ class Planner
691
+ # One subgraph fetch. `selections` go over as written; `keys` names the
692
+ # @key/@requires paths this fetch also asks for, to carry entities
693
+ # across a boundary, and `injected` the response keys those land under
694
+ # (Router::PREFIX + the path's first segment — a nested field set
695
+ # arrives as one object), which the answer is stripped of. `children`
696
+ # and `deferrals` are what happens to the objects it answers with — a
697
+ # child stays in this subgraph and only carries deferrals deeper, a
698
+ # deferral is refetched elsewhere. Both are lists: two selections can
699
+ # share a response key, and each brings its own subtree.
700
+ Step = Struct.new(:subgraph, :type_name, :selections, :keys, :injected, :prefetches,
701
+ :children, :deferrals, keyword_init: true) do
702
+ def subgraphs
703
+ [subgraph] + prefetches.map(&:subgraph) +
704
+ children.flat_map { |_key, child| child.subgraphs } + deferrals.flat_map(&:subgraphs)
705
+ end
706
+ end
707
+
708
+ # the __typename every abstract fetch asks for, under the router's own
709
+ # response key so the caller's answer never gains one it didn't ask for
710
+ TYPENAME_FIELD = GraphQL::Language::Nodes::Field.new(
711
+ name: "__typename", field_alias: Router::TYPENAME,
712
+ )
713
+
714
+ # What a field returning an abstract type defers to: one plan per
715
+ # concrete type the subgraph can answer with. Which of them applies is
716
+ # a fact about the data, and the planner runs before any fetch — so it
717
+ # plans them all and {Router#stitch} picks by __typename.
718
+ Branches = Struct.new(:steps, keyword_init: true) do
719
+ def subgraphs = steps.each_value.flat_map(&:subgraphs)
720
+
721
+ # what the parent's fetch asks for: each branch under its own type
722
+ # condition, and the __typename that says which one answered
723
+ def selections
724
+ [TYPENAME_FIELD] + steps.filter_map do |type_name, step|
725
+ next if step.selections.empty?
726
+
727
+ GraphQL::Language::Nodes::InlineFragment.new(
728
+ type: GraphQL::Language::Nodes::TypeName.new(name: type_name),
729
+ selections: step.selections,
730
+ )
731
+ end
732
+ end
733
+ end
734
+
735
+ # A @requires field set the router has to supply: fetch those fields
736
+ # from the subgraph that holds them, into hidden keys on the object,
737
+ # before the fetch whose representation carries them.
738
+ Prefetch = Struct.new(:subgraph, :key, :paths, keyword_init: true)
739
+
740
+ # A field this subgraph can't resolve: refetch the parent entity from
741
+ # `subgraph` and read it there.
742
+ Deferral = Struct.new(:node, :response_key, :subgraph, :step, :key, :requires,
743
+ keyword_init: true) do
744
+ def subgraphs = [subgraph] + (step ? step.subgraphs : [])
745
+
746
+ # the paths a representation for this deferral has to carry
747
+ def representation = (key + requires).uniq
748
+ end
749
+
750
+ # What one operation costs. `verbatim` is the shape the whole thing
751
+ # resolves in one subgraph, where the document goes over untouched.
752
+ Plan = Struct.new(:steps, :operation, :selections, :fragments, :root_type, :introspection,
753
+ :verbatim, keyword_init: true) do
754
+ def operation_name = operation&.name
755
+
756
+ def entry = steps.first&.subgraph
757
+
758
+ # every subgraph the plan fetches from, in plan order
759
+ def subgraphs = steps.flat_map(&:subgraphs).uniq
760
+
761
+ # which subgraphs it touches, for a report — "accounts+reviews"
762
+ # when it stitches (sorted: fetch order is what #trace is for)
763
+ def where = introspection ? "(introspection)" : subgraphs.sort.join("+")
764
+ end
765
+
766
+ # the fields a router answers itself rather than routing
767
+ INTROSPECTION = %w[__schema __type].freeze
768
+
769
+ # a fragment spread can't cycle (validation rejects that), so this is
770
+ # only ever reached by a document validation didn't see
771
+ MAX_DEPTH = 32
772
+
773
+ # absent: subgraphs no schema serves here. Planning is otherwise
774
+ # unchanged — coverage plans with none of them loaded, which is why
775
+ # absence is a fact about this process rather than about the graph.
776
+ def initialize(table:, schema:, absent: [])
777
+ @table = table
778
+ @schema = schema
779
+ @absent = absent
780
+ @interface_objects = table.interface_objects
781
+ end
782
+
783
+ # the operation's validation errors, GraphQL-wire shaped
784
+ def validate(document)
785
+ @schema.validate(document).map do |error|
786
+ { "message" => error.message, "extensions" => { "code" => "GRAPHQL_VALIDATION_FAILED" } }
787
+ end
788
+ end
789
+
790
+ def plan(document, operation_name: nil)
791
+ operation = pick_operation(document, operation_name)
792
+ refuse(:operation_type, "this document is a subscription") if
793
+ operation.operation_type == "subscription"
794
+
795
+ fragments = document.definitions
796
+ .grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |f| [f.name, f] }
797
+ root = root_type(operation)
798
+ selections = narrow(root.graphql_name, operation.selections, fragments)
799
+ plan = Plan.new(operation:, selections:, fragments:, root_type: root, steps: [])
800
+
801
+ introspection, data = selections.partition { |node| INTROSPECTION.include?(node.name) }
802
+ if introspection.any?
803
+ if data.any? { |node| node.name != "__typename" }
804
+ refuse :mixed_introspection,
805
+ "this operation selects #{introspection.map(&:name).uniq.join(" and ")} " \
806
+ "alongside data fields"
807
+ end
808
+
809
+ plan.introspection = true
810
+ return plan
811
+ end
812
+
813
+ check_interface_objects!(root.graphql_name, selections, fragments) if @interface_objects.any?
814
+
815
+ entry = single_subgraph(root.graphql_name, selections, fragments)
816
+ if entry
817
+ plan.verbatim = true
818
+ plan.steps = [step(entry, root.graphql_name)]
819
+ return plan
820
+ end
821
+
822
+ plan.steps = root_steps(root.graphql_name, selections, operation, fragments)
823
+ plan
824
+ end
825
+
826
+ # The selections that apply to ONE concrete type, as plain fields a
827
+ # step can route one at a time: fields written at this position, plus
828
+ # every fragment whose condition that type satisfies, folded in. A
829
+ # fragment it can't be never matches, so it is dropped rather than
830
+ # travelling as written — every position a step plans is concrete, so
831
+ # a condition either holds for all of its objects or for none.
832
+ #
833
+ # Public because {Router#propagate} asks the same question of the
834
+ # merged tree: which selections describe the object in hand.
835
+ def narrow(concrete, selections, fragments, depth = 0)
836
+ return [] if depth > MAX_DEPTH
837
+
838
+ selections.flat_map do |node|
839
+ case node
840
+ when GraphQL::Language::Nodes::Field then [node]
841
+ when GraphQL::Language::Nodes::InlineFragment
842
+ next [] unless applies?(node.type&.name, concrete)
843
+
844
+ carry(node, narrow(concrete, node.selections, fragments, depth + 1))
845
+ when GraphQL::Language::Nodes::FragmentSpread
846
+ fragment = fragments[node.name] or
847
+ refuse(:undefined_fragment, "the document spreads ...#{node.name}, which it never defines")
848
+ next [] unless applies?(fragment.type.name, concrete)
849
+
850
+ carry(node, narrow(concrete, fragment.selections, fragments, depth + 1))
851
+ else []
852
+ end
853
+ end
854
+ end
855
+
856
+ private
857
+
858
+ # Every type these selections reach, refused if one of them is an
859
+ # @interfaceObject: a subgraph resolves the whole interface there, so
860
+ # the supergraph records no per-field routing for it and every fetch
861
+ # planned against it would be a guess. Asked per query rather than at
862
+ # construction — one such directive shouldn't cost you the queries
863
+ # that never touch the type.
864
+ def check_interface_objects!(type_name, selections, fragments, depth = 0)
865
+ return if depth > MAX_DEPTH
866
+
867
+ selections.each do |node|
868
+ case node
869
+ when GraphQL::Language::Nodes::Field
870
+ next if node.name.start_with?("__")
871
+
872
+ child = raw_child_type(type_name, node.name) or next
873
+ interface_object!(child, "#{type_name}.#{node.name} returns #{child}")
874
+ check_interface_objects!(child, node.selections, fragments, depth + 1)
875
+ when GraphQL::Language::Nodes::InlineFragment
876
+ condition = node.type&.name || type_name
877
+ interface_object!(condition, "this operation selects ... on #{condition}")
878
+ check_interface_objects!(condition, node.selections, fragments, depth + 1)
879
+ when GraphQL::Language::Nodes::FragmentSpread
880
+ fragment = fragments[node.name] or next
881
+ condition = fragment.type.name
882
+ interface_object!(condition, "...#{node.name} is on #{condition}")
883
+ check_interface_objects!(condition, fragment.selections, fragments, depth + 1)
884
+ end
885
+ end
886
+ end
887
+
888
+ def interface_object!(type_name, where)
889
+ graphs = @interface_objects[type_name] or return
890
+
891
+ refuse :interface_object,
892
+ "#{where}, which #{graphs.join(" and ")} resolves as an @interfaceObject"
893
+ end
894
+
895
+ # Whether a fragment's condition holds for every object of `concrete`
896
+ # — the type itself, or an abstract type it satisfies.
897
+ def applies?(condition, concrete)
898
+ return true if condition.nil? || condition == concrete
899
+
900
+ type = @schema.types[condition]
901
+ !!type&.kind&.abstract? && @schema.possible_types(type).any? { |t| t.graphql_name == concrete }
902
+ end
903
+
904
+ def step(subgraph, type_name)
905
+ Step.new(subgraph:, type_name:, selections: [], keys: [], injected: [], prefetches: [],
906
+ children: [], deferrals: [])
907
+ end
908
+
909
+ def pick_operation(document, name)
910
+ operations = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
911
+ named = operations.map { |op| op.name || "anonymous" }
912
+ if name
913
+ return operations.find { |op| op.name == name } || refuse(:ambiguous_operation,
914
+ "the document defines no operation named #{name.inspect} (it has #{named.join(", ")})")
915
+ end
916
+ return operations.first if operations.one?
917
+
918
+ refuse :ambiguous_operation, "the document holds #{operations.size} operations (#{named.join(", ")})"
919
+ end
920
+
921
+ def root_type(operation)
922
+ root = (operation.operation_type == "mutation") ? @schema.mutation : @schema.query
923
+ root || refuse(:operation_type,
924
+ "the composed schema has no #{operation.operation_type || "query"} root type")
925
+ end
926
+
927
+ # The one subgraph that answers the whole operation, if there is one.
928
+ # Root fields fix the candidates: they're independent, so the ones
929
+ # they share are the only subgraphs that could answer everything.
930
+ def single_subgraph(root, selections, fragments)
931
+ fields = selections.reject { |node| node.name.start_with?("__") }
932
+ shared = fields.map { |node| owners!(root, node.name) }.reduce(:&) || @table.subgraphs
933
+ # no refusal here: an absent candidate just isn't one, and the
934
+ # per-field walk below names it if that's what stops the query
935
+ (shared - @absent).find { |subgraph| local?(root, selections, subgraph, fragments, []) }
936
+ end
937
+
938
+ # Root fields resolve independently, so each picks its own subgraph
939
+ # and one fetch goes to each — preferring a subgraph already in the
940
+ # plan, so a query that could run in fewer doesn't run in more.
941
+ def root_steps(root, selections, operation, fragments)
942
+ if operation.operation_type == "mutation"
943
+ owners = selections.reject { |node| node.name.start_with?("__") }
944
+ .to_h { |node| [node.name, owners!(root, node.name)] }
945
+ # Root mutation fields run in series. Sharing a subgraph, they go
946
+ # over as one document and it serializes them; only roots in
947
+ # *different* subgraphs would run in whatever order the plan
948
+ # happens to. Whatever stitches below a root is an ordinary read
949
+ # afterwards, so it doesn't bear on the ordering.
950
+ shared = owners.values.reduce(:&) || @table.subgraphs
951
+ if shared.empty?
952
+ refuse :root_fields_span, "this mutation's root fields span subgraphs: " \
953
+ "#{owners.map { |name, graphs| "#{root}.#{name} (#{graphs.join(" or ")})" }.join(", ")}"
954
+ end
955
+
956
+ # one root field: shared IS its owners, so an absence names it
957
+ subject = owners.one? ? "#{root}.#{owners.keys.first}" : root
958
+ return [plan_step(root, selections, available!(shared, subject).first, fragments, [], 0)]
959
+ end
960
+
961
+ groups = {}
962
+ loose = []
963
+ selections.each do |node|
964
+ if node.name.start_with?("__")
965
+ loose << node
966
+ next
967
+ end
968
+
969
+ graphs = available!(owners!(root, node.name), "#{root}.#{node.name}")
970
+ (groups[(graphs & groups.keys).first || graphs.first] ||= []) << node
971
+ end
972
+ groups[available!(@table.subgraphs, root).first] ||= [] if groups.empty?
973
+ # __typename doesn't route; any subgraph answers it
974
+ groups[groups.keys.first].concat(loose)
975
+
976
+ groups.map { |subgraph, nodes| plan_step(root, nodes, subgraph, fragments, [], 0) }
977
+ end
978
+
979
+ # Build the fetch for `selections` on `type_name` in `subgraph`.
980
+ # `provided` names fields a @provides copy makes answerable here even
981
+ # though the routing table places them elsewhere.
982
+ def plan_step(type_name, selections, subgraph, fragments, provided, depth)
983
+ refuse(:too_deep, "this operation nests deeper than #{MAX_DEPTH} levels") if depth > MAX_DEPTH
984
+
985
+ here = step(subgraph, type_name)
986
+ selections.each do |node|
987
+ # a subtree that never leaves this subgraph goes over as written:
988
+ # the boundary rules govern stitching, so they have no business
989
+ # applying to a query that was never going to cross one
990
+ if node.name.start_with?("__") || local?(type_name, [node], subgraph, fragments, provided)
991
+ here.selections << node
992
+ next
993
+ end
994
+
995
+ owners = owners!(type_name, node.name)
996
+ field = @table.field(type_name, node.name)
997
+ resolves_here = owners.include?(subgraph) || provided.include?(node.name)
998
+ if resolves_here && held?(type_name, field, subgraph)
999
+ descend(here, type_name, node, subgraph, field, fragments, depth)
1000
+ else
1001
+ # a field whose @requires this subgraph can't supply is refetched
1002
+ # even when it resolves here — the fields have to arrive in a
1003
+ # representation, and only an entity fetch carries one
1004
+ target = resolves_here ? subgraph : available!(owners, "#{type_name}.#{node.name}").first
1005
+ defer(here, type_name, node, subgraph, target, field, fragments, selections, depth)
1006
+ end
1007
+ end
1008
+
1009
+ check_one_source!(type_name, here, subgraph)
1010
+ # every crossing this fetch feeds, asked for once and together: a
1011
+ # field set shared by two deferrals is one selection, and a nested
1012
+ # one is nested rather than a dotted alias no schema has
1013
+ here.selections.concat(Router.injected_selections(here.keys))
1014
+ here.injected = (here.keys + here.prefetches.flat_map(&:paths))
1015
+ .map { |path| Router::PREFIX + path.split(".").first }.uniq
1016
+ here
1017
+ end
1018
+
1019
+ # The field resolves in this subgraph but something under it doesn't.
1020
+ def descend(step, type_name, node, subgraph, field, fragments, depth)
1021
+ child = plan_child(type_name, node, subgraph, field, fragments, depth)
1022
+
1023
+ step.selections << node.merge(selections: child.selections)
1024
+ step.children << [node.alias || node.name, child]
1025
+ end
1026
+
1027
+ # The plan for what this field returns, run in `subgraph`.
1028
+ def plan_child(type_name, node, subgraph, field, fragments, depth)
1029
+ child_type = child_type_name(type_name, node.name)
1030
+ return plan_branches(type_name, node, child_type, subgraph, field, fragments, depth) if
1031
+ @schema.types[child_type]&.kind&.abstract?
1032
+
1033
+ plan_step(child_type, narrow(child_type, node.selections, fragments), subgraph, fragments,
1034
+ provides(field), depth + 1)
1035
+ end
1036
+
1037
+ # One plan per concrete type `subgraph` can answer this abstract type
1038
+ # with — the supergraph says which those are, and a fetch may only name
1039
+ # those: a subgraph rejects an `... on T` its own schema doesn't place
1040
+ # in the abstract type.
1041
+ def plan_branches(type_name, node, abstract_name, subgraph, field, fragments, depth)
1042
+ possible = @table.possible_types(abstract_name, subgraph)
1043
+ if possible.nil?
1044
+ refuse :abstract_boundary, "#{type_name}.#{node.name} returns #{abstract_name}, and " \
1045
+ "the supergraph doesn't record which concrete types #{subgraph} answers it with " \
1046
+ "(no @join__unionMember or @join__implements, and #{abstract_name} is in more than " \
1047
+ "one subgraph)"
1048
+ end
1049
+ if possible.empty?
1050
+ refuse :abstract_boundary, "#{type_name}.#{node.name} returns #{abstract_name}, and " \
1051
+ "the supergraph places none of its concrete types in #{subgraph}"
1052
+ end
1053
+
1054
+ Branches.new(steps: possible.sort.to_h do |concrete|
1055
+ [concrete, plan_step(concrete, narrow(concrete, node.selections, fragments), subgraph,
1056
+ fragments, provides(field), depth + 1)]
1057
+ end)
1058
+ end
1059
+
1060
+ # Refetch this object from its @key in the subgraph that resolves the
1061
+ # field, and read the field off the entity that comes back. `target`
1062
+ # is this subgraph when the field lives here but @requires fields it
1063
+ # doesn't hold — the router refetches for those too.
1064
+ def defer(step, type_name, node, subgraph, target, field, fragments, siblings, depth)
1065
+ key = usable_key(type_name, node, subgraph, target)
1066
+ requires = requires_paths(field)
1067
+ check_shadowing!(type_name, node, siblings, (key + requires).uniq)
1068
+
1069
+ # a @requires field this subgraph doesn't hold is fetched from the
1070
+ # one that does and handed back in the representation — a fetch
1071
+ # before the fetch, which is what makes this a chain
1072
+ elsewhere = requires.reject { |path| path_owners(type_name, path).include?(subgraph) }
1073
+ prefetch(step, type_name, node, subgraph, elsewhere)
1074
+
1075
+ ((key + requires).uniq - elsewhere).each { |path| inject(step, path) }
1076
+
1077
+ child = plan_child(type_name, node, target, field, fragments, depth) if node.selections.any?
1078
+
1079
+ step.deferrals << Deferral.new(
1080
+ node: child ? node.merge(selections: child.selections) : node,
1081
+ response_key: node.alias || node.name,
1082
+ subgraph: target,
1083
+ step: child || nil,
1084
+ key:, requires:,
1085
+ )
1086
+ end
1087
+
1088
+ # One fetch per subgraph holding a @requires field this one doesn't,
1089
+ # ahead of the fetch that needs them. Only one hop: the key for each
1090
+ # has to come from `subgraph` itself, so a chain can't grow a chain.
1091
+ def prefetch(step, type_name, node, subgraph, paths)
1092
+ paths.each { |path| check_chain!(type_name, node, path) }
1093
+
1094
+ paths.group_by { |path| requires_holder(type_name, node, path) }.each do |holder, held|
1095
+ key = usable_key(type_name, node, subgraph, holder)
1096
+ key.each { |path| inject(step, path) }
1097
+ step.prefetches << Prefetch.new(subgraph: holder, key:, paths: held)
1098
+ end
1099
+ end
1100
+
1101
+ # A prefetch sends the entity's own @key and nothing else, so a required
1102
+ # field that is itself @requires-ed gets computed from a representation
1103
+ # missing its input — silently, and the same field then holds two
1104
+ # different values in one response. Asked of every field a path walks
1105
+ # through, not only its first: nesting doesn't make a chain shallower.
1106
+ def check_chain!(type_name, node, path)
1107
+ walk(type_name, path).each do |owner, name|
1108
+ inner = @table.field(owner, name)&.requires or next
1109
+
1110
+ refuse :chained_requires,
1111
+ "#{type_name}.#{node.name} @requires #{path.inspect}, and #{owner}.#{name} " \
1112
+ "itself @requires #{inner.inspect}"
1113
+ end
1114
+ end
1115
+
1116
+ def requires_holder(type_name, node, path)
1117
+ owners = path_owners(type_name, path)
1118
+ return available!(owners, "#{type_name}.#{path}").first if owners.any?
1119
+
1120
+ # two different facts, and only the second is about nesting: a field
1121
+ # the supergraph places nowhere, or one whose path it places in
1122
+ # subgraphs that don't overlap
1123
+ pairs = walk(type_name, path)
1124
+ orphan = pairs.find { |owner, name| @table.owners(owner, name).empty? }
1125
+ missing = pairs.empty? ? "#{type_name}.#{path}" : orphan&.join(".")
1126
+ refuse(:no_owner, "#{type_name}.#{node.name} @requires #{path.inspect}, and the " \
1127
+ "supergraph places #{missing} in no subgraph") if missing
1128
+
1129
+ refuse :nested_field_set, "#{type_name}.#{node.name} @requires a nested field set " \
1130
+ "(#{field_set([path]).inspect}) no one subgraph holds whole (" +
1131
+ pairs.map { |owner, name| "#{owner}.#{name} in #{@table.owners(owner, name).join(" or ")}" }
1132
+ .join(", ") + ")"
1133
+ end
1134
+
1135
+ def inject(step, path)
1136
+ step.keys << path unless step.keys.include?(path)
1137
+ end
1138
+
1139
+ # A nested field set arrives as ONE object under one response key, so
1140
+ # every path sharing a root has to come from the same fetch: half of
1141
+ # `origin` from here and half from a prefetch leaves the object
1142
+ # half-built, and two prefetches overwrite each other's half.
1143
+ def check_one_source!(type_name, step, subgraph)
1144
+ sources = Hash.new { |roots, root| roots[root] = {} }
1145
+ step.keys.each { |path| sources[path.split(".").first][path] = subgraph }
1146
+ step.prefetches.each do |prefetch|
1147
+ prefetch.paths.each { |path| sources[path.split(".").first][path] = prefetch.subgraph }
1148
+ end
1149
+
1150
+ sources.each do |root, from|
1151
+ next if from.values.uniq.one?
1152
+
1153
+ refuse :nested_field_set, "#{type_name}'s #{root.inspect} is part of a field set this " \
1154
+ "fetch would have to build from more than one subgraph " \
1155
+ "(#{from.map { |path, graph| "#{path} from #{graph}" }.join(", ")})"
1156
+ end
1157
+ end
1158
+
1159
+ # Apollo's router injects the @key under its own name and lets it win,
1160
+ # so `{ id: username }` next to a stitched field comes back as the
1161
+ # user's id. That is an Apollo bug and a spec-conformant server
1162
+ # disagrees — and since we can't match both, refuse rather than hand
1163
+ # back an answer one of them contradicts.
1164
+ def check_shadowing!(type_name, node, siblings, paths)
1165
+ # Apollo injects a field set under its own names, so what an alias
1166
+ # can collide with is each path's first segment — the field a flat
1167
+ # path is, or the object a nested one arrives in
1168
+ roots = paths.map { |path| path.split(".").first }.uniq
1169
+ shadowed = siblings.select do |sibling|
1170
+ sibling.alias && sibling.alias != sibling.name && roots.include?(sibling.alias)
1171
+ end
1172
+ return if shadowed.empty?
1173
+
1174
+ refuse :shadowed_key,
1175
+ "#{type_name}.#{node.name} is fetched on #{type_name}'s #{roots.map(&:inspect).join(", ")}, " \
1176
+ "and this selection aliases " \
1177
+ "#{shadowed.map { |s| "#{s.name} as #{s.alias.inspect}" }.join(", ")} over it"
1178
+ end
1179
+
1180
+ # A @key field set the source subgraph can build a representation
1181
+ # from — the first the supergraph declares that it can, nested or
1182
+ # flat. An @external copy counts: it exists precisely so this
1183
+ # subgraph can name the field in its @key.
1184
+ def usable_key(type_name, node, from, to)
1185
+ candidates = @table.keys(type_name, to)
1186
+ if candidates.empty?
1187
+ refuse :no_key,
1188
+ "#{type_name}.#{node.name} resolves in #{to}, and #{type_name} has no resolvable " \
1189
+ "@key there"
1190
+ end
1191
+
1192
+ usable = candidates.find { |paths| paths.all? { |path| declares?(type_name, path, from) } }
1193
+ return usable if usable
1194
+
1195
+ refuse :no_key, "#{type_name}.#{node.name} needs a fetch into #{to}, and #{from} can't " \
1196
+ "supply any of #{type_name}'s @keys there " \
1197
+ "(#{candidates.map { |paths| field_set(paths).inspect }.join(", ")})"
1198
+ end
1199
+
1200
+ def requires_paths(field)
1201
+ return [] unless field&.requires
1202
+
1203
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.requires)
1204
+ end
1205
+
1206
+ # Dotted paths back to the selection set they were parsed from — the
1207
+ # inverse of RoutingTable.parse_field_set, so a refusal spells the
1208
+ # field set the way the schema does and is greppable against it.
1209
+ def field_set(paths)
1210
+ tree = {}
1211
+ paths.each do |path|
1212
+ path.split(".").reduce(tree) { |node, segment| node[segment] ||= {} }
1213
+ end
1214
+ render_field_set(tree)
1215
+ end
1216
+
1217
+ def render_field_set(tree)
1218
+ tree.map { |name, children|
1219
+ children.empty? ? name : "#{name} { #{render_field_set(children)} }"
1220
+ }.join(" ")
1221
+ end
1222
+
1223
+ # A @requires field set is supplied by the ROUTER: it fetches those
1224
+ # fields elsewhere and hands them back in the representation. So a
1225
+ # field is only answerable in place when its own subgraph already
1226
+ # holds every one of them — which, since @requires fields are
1227
+ # @external there, it essentially never does. When it doesn't, the
1228
+ # field is planned as a fetch chain instead (see prefetch).
1229
+ def held?(type_name, field, subgraph)
1230
+ return true unless field&.requires
1231
+
1232
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.requires)
1233
+ .all? { |path| path_owners(type_name, path).include?(subgraph) }
1234
+ end
1235
+
1236
+ # The subgraphs that can answer a field set path in ONE fetch: the
1237
+ # owners of every field it walks through, intersected. A representation
1238
+ # carries the nested object whole, so a path answerable only a level at
1239
+ # a time is answerable by nobody.
1240
+ def path_owners(type_name, path)
1241
+ pairs = walk(type_name, path)
1242
+ return [] if pairs.empty?
1243
+
1244
+ pairs.map { |owner, name| @table.owners(owner, name) }.reduce(:&)
1245
+ end
1246
+
1247
+ # Every [type, field] a dotted path names, from `type_name` down —
1248
+ # empty when the composed schema doesn't carry the whole walk.
1249
+ def walk(type_name, path)
1250
+ pairs = []
1251
+ path.split(".").each do |segment|
1252
+ return [] if type_name.nil?
1253
+
1254
+ pairs << [type_name, segment]
1255
+ type_name = raw_child_type(type_name, segment)
1256
+ end
1257
+ pairs
1258
+ end
1259
+
1260
+ # Every field these selections reach is answerable by `subgraph`, so
1261
+ # the whole subtree can go over untouched.
1262
+ def local?(type_name, selections, subgraph, fragments, provided, depth = 0)
1263
+ return false if depth > MAX_DEPTH
1264
+
1265
+ selections.all? do |node|
1266
+ case node
1267
+ when GraphQL::Language::Nodes::Field
1268
+ next true if node.name.start_with?("__")
1269
+
1270
+ local_field?(type_name, node, subgraph, fragments, provided, depth)
1271
+ when GraphQL::Language::Nodes::InlineFragment
1272
+ condition = node.type&.name || type_name
1273
+ declared_in?(condition, subgraph) &&
1274
+ local?(condition, node.selections, subgraph, fragments, provided, depth + 1)
1275
+ when GraphQL::Language::Nodes::FragmentSpread
1276
+ fragment = fragments[node.name] or
1277
+ refuse(:undefined_fragment, "the document spreads ...#{node.name}, which it never defines")
1278
+ declared_in?(fragment.type.name, subgraph) &&
1279
+ local?(fragment.type.name, fragment.selections, subgraph, fragments, provided, depth + 1)
1280
+ else false
1281
+ end
1282
+ end
1283
+ end
1284
+
1285
+ def local_field?(type_name, node, subgraph, fragments, provided, depth)
1286
+ owners = @table.owners(type_name, node.name)
1287
+ return false unless owners.include?(subgraph) || provided.include?(node.name)
1288
+
1289
+ field = @table.field(type_name, node.name)
1290
+ return false unless held?(type_name, field, subgraph)
1291
+ return true if node.selections.empty?
1292
+
1293
+ child = raw_child_type(type_name, node.name) or return false
1294
+ local?(child, node.selections, subgraph, fragments, provides(field), depth + 1)
1295
+ end
1296
+
1297
+ # Folding a same-type fragment into its parent drops the fragment node,
1298
+ # so whatever @skip/@include it carried has to move onto the selections
1299
+ # it guarded — otherwise a stitched plan answers a selection the
1300
+ # operation excluded, and fetches a subgraph to do it.
1301
+ def carry(node, expanded)
1302
+ return expanded if node.directives.empty?
1303
+
1304
+ expanded.map do |field|
1305
+ clash = field.directives.map(&:name) & node.directives.map(&:name)
1306
+ if clash.any?
1307
+ # one selection can't hold two conditions of the same name
1308
+ refuse :conditional_fragment,
1309
+ "#{field.alias || field.name} carries @#{clash.first}, and so does the fragment " \
1310
+ "spread around it"
1311
+ end
1312
+
1313
+ field.merge(directives: node.directives + field.directives)
1314
+ end
1315
+ end
1316
+
1317
+ # A fragment's type condition has to exist in the subgraph running
1318
+ # it; a type only another subgraph declares can't be matched there.
1319
+ # Types the routing table says nothing about (scalars, enums) are
1320
+ # nobody's.
1321
+ def declared_in?(type_name, subgraph)
1322
+ declared = @table.declared_in(type_name)
1323
+ declared.empty? || declared.include?(subgraph)
1324
+ end
1325
+
1326
+ # Whether `subgraph` can hand back this field set path as part of a
1327
+ # representation — every field it walks through, since a nested path
1328
+ # is selected there in one go. An @external copy counts, which is the
1329
+ # whole reason one is declared.
1330
+ def declares?(type_name, path, subgraph)
1331
+ pairs = walk(type_name, path)
1332
+ pairs.any? && pairs.all? { |owner, name| declares_field?(owner, name, subgraph) }
1333
+ end
1334
+
1335
+ def declares_field?(type_name, field_name, subgraph)
1336
+ field = @table.field(type_name, field_name)
1337
+ return @table.declared_in(type_name).include?(subgraph) if field.nil?
1338
+
1339
+ field.graphs.include?(subgraph) || field.external.include?(subgraph)
1340
+ end
1341
+
1342
+ # @provides says this subgraph carries its own copy of fields it
1343
+ # doesn't own, and the router reads that copy rather than routing to
1344
+ # the owner — so a query reaching only provided fields never leaves.
1345
+ # Flat sets only; a nested one widens nothing and its fields fall back
1346
+ # to the ordinary owner check.
1347
+ def provides(field)
1348
+ return [] unless field&.provides
1349
+
1350
+ GraphWeaver::SchemaLoader::RoutingTable.parse_field_set(field.provides)
1351
+ .reject { |path| path.include?(".") }
1352
+ end
1353
+
1354
+ def owners!(type_name, field_name)
1355
+ owners = @table.owners(type_name, field_name)
1356
+ return owners if owners.any?
1357
+
1358
+ refuse :no_owner, "the supergraph places #{type_name}.#{field_name} in no subgraph"
1359
+ end
1360
+
1361
+ # The subgraphs among `owners` this process actually serves. A
1362
+ # supergraph is routinely only partly local, so absence is refused
1363
+ # here — where the field that reached for it is still in hand —
1364
+ # rather than at construction, which would refuse the whole suite
1365
+ # over fields it may never touch.
1366
+ def available!(owners, coordinate)
1367
+ here = owners - @absent
1368
+ return here if here.any?
1369
+
1370
+ absent = owners.map(&:inspect)
1371
+ refuse :absent_subgraph, "#{coordinate} resolves in #{absent.join(" or ")}, which no " \
1372
+ "schema here serves — nothing loaded defines what the supergraph says " \
1373
+ "#{absent.first} resolves. #{advice(absent.first)}"
1374
+ end
1375
+
1376
+ # Two causes, and only one of them applies at a time. A class Rails
1377
+ # hasn't autoloaded yet is the usual one — but not when eager loading
1378
+ # is already on, and *that* the library can just ask, rather than
1379
+ # leading with a guess it can see is wrong. The other cause is a
1380
+ # subgraph that genuinely runs in another service, and its fix has to
1381
+ # come first for the reader it applies to. Either way the surface
1382
+ # named is the one an rspec example can reach: there is no Router.new
1383
+ # in sight from inside one.
1384
+ def advice(name)
1385
+ fake = "subgraphs: { #{name} => #{Subgraphs::FAKE.inspect} } " \
1386
+ "(GraphWeaver::Testing.config.router = { subgraphs: … } under the rspec tag, or " \
1387
+ "subgraphs: on Router.new) — or a schema class in place of #{Subgraphs::FAKE.inspect}"
1388
+ if eager_loaded?
1389
+ "Eager loading is on, so it isn't a class waiting to be autoloaded — it runs " \
1390
+ "elsewhere. Fabricate its answers: #{fake}."
1391
+ else
1392
+ "Rails autoloads, so the class is probably just not loaded yet: eager-load it " \
1393
+ "(config.eager_load, or config.rake_eager_load under rake). If it runs elsewhere, " \
1394
+ "fabricate its answers instead — #{fake}."
1395
+ end
1396
+ end
1397
+
1398
+ # Whether the "not autoloaded yet" half of the advice is already ruled
1399
+ # out. Outside Rails there is no autoloading to blame either.
1400
+ # const_get rather than a bare Rails: sorbet can't resolve a constant
1401
+ # the gem doesn't depend on.
1402
+ def eager_loaded?
1403
+ return false unless Object.const_defined?(:Rails)
1404
+
1405
+ config = Object.const_get(:Rails).application&.config or return false
1406
+ !!(config.eager_load ||
1407
+ (Object.const_defined?(:Rake) && config.respond_to?(:rake_eager_load) && config.rake_eager_load))
1408
+ rescue NoMethodError
1409
+ false # something else named Rails
1410
+ end
1411
+
1412
+ def child_type_name(type_name, field_name)
1413
+ raw_child_type(type_name, field_name) ||
1414
+ refuse(:no_owner, "#{type_name}.#{field_name} is not a field of the composed schema")
1415
+ end
1416
+
1417
+ def raw_child_type(type_name, field_name)
1418
+ type = @schema.types[type_name]
1419
+ return unless type.respond_to?(:fields)
1420
+
1421
+ field = type.fields[field_name] or return
1422
+ field.type.unwrap.graphql_name
1423
+ end
1424
+
1425
+ def refuse(category, message)
1426
+ raise Unplannable.new(message, category:)
1427
+ end
1428
+ end
1429
+ end
1430
+ end
1431
+ end