graph_weaver 0.4.6 → 0.5.1

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