graph_weaver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -6,8 +6,11 @@ require "json"
6
6
 
7
7
  require_relative "../parsing"
8
8
  require_relative "../schema_loader"
9
+ require_relative "../internal/selection"
10
+ require_relative "../internal"
9
11
  require_relative "../transport"
10
- require_relative "subgraphs"
12
+ require_relative "../internal/planner"
13
+ require_relative "../internal/subgraphs"
11
14
 
12
15
  module GraphWeaver
13
16
  module Testing
@@ -41,6 +44,12 @@ module GraphWeaver
41
44
  "doesn't say which subgraph answers each of its fields. Run this one against a real " \
42
45
  "router.",
43
46
  ],
47
+ context_argument: [
48
+ "a @fromContext argument no fetch here can supply",
49
+ "federation 2.8's @context/@fromContext fills the argument from a selection on an " \
50
+ "ancestor, and only the gateway that planned the fetch knows what to put there. Run " \
51
+ "this one against a real router.",
52
+ ],
44
53
  chained_requires: [
45
54
  "a @requires whose field set names another @requires field",
46
55
  "the router satisfies a @requires with one fetch, so it can't first satisfy that " \
@@ -59,8 +68,9 @@ module GraphWeaver
59
68
  ],
60
69
  shadowed_key: [
61
70
  "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.",
71
+ "the local router injects the @key it crosses on under a reserved response key and " \
72
+ "Apollo injects it under the field's own name, so either way this alias claims a key " \
73
+ "the fetch needs. Rename the alias.",
64
74
  ],
65
75
  root_fields_span: [
66
76
  "a mutation's root fields span subgraphs",
@@ -116,6 +126,21 @@ module GraphWeaver
116
126
  # the short label a report groups this refusal under
117
127
  def label = CATEGORIES.fetch(category).first
118
128
 
129
+ # Refuse a supergraph the routing table couldn't read whole. An unread
130
+ # @join__ construct leaves the table incomplete, so every answer drawn
131
+ # from it is a guess — including which schema serves which subgraph, so
132
+ # this comes before resolving those. Router and Coverage both refuse at
133
+ # construction, before any query, and say it the same way.
134
+ def self.unsupported!(table)
135
+ return if table.unsupported.empty?
136
+
137
+ raise new(
138
+ "this supergraph uses federation constructs the local router doesn't read: " +
139
+ table.unsupported.join("; "),
140
+ category: :unsupported_federation,
141
+ )
142
+ end
143
+
119
144
  def to_h = super.merge("category" => category.to_s, "detail" => detail)
120
145
  end
121
146
 
@@ -128,7 +153,8 @@ module GraphWeaver
128
153
  # context: { current_user: user },
129
154
  # )
130
155
  #
131
- # (`subgraphs:` is optional — see {Subgraphs}.)
156
+ # (`subgraphs:` is optional — they're derived from what each loaded
157
+ # schema defines.)
132
158
  #
133
159
  # A supergraph only **partly** local — the rest of it served by other
134
160
  # processes — needs nothing extra: the subgraphs nobody here defines are
@@ -146,12 +172,12 @@ module GraphWeaver
146
172
  # the object the SDL spells rather than as a flattened path; and a union
147
173
  # or interface at a boundary, planned per concrete type and bucketed on
148
174
  # 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.
175
+ #
176
+ # Everything it can't plan *faithfully* raises {Unplannable}, before any
177
+ # subgraph runs, so a refusal can never be a half-executed query. Apollo's
178
+ # planner is twenty thousand lines; a double that approximated the rest of
179
+ # it would let a test pass on an answer production disagrees with, which is
180
+ # the most expensive thing this library can produce.
155
181
  #
156
182
  # Introspection is answered from the composed API schema — never from a
157
183
  # subgraph, which would reply with its own slice. That is the one split a
@@ -180,6 +206,11 @@ module GraphWeaver
180
206
  # refused at plan time, everything else runs
181
207
  attr_reader :absent
182
208
 
209
+ # subgraphs several loaded schema classes fit equally, so detection
210
+ # can't say which serves them — refused per query like an absent one,
211
+ # and the refusal lists the candidates
212
+ def ambiguous = @ambiguous.keys
213
+
183
214
  # subgraphs answered with fabricated data instead of that refusal
184
215
  attr_reader :faked
185
216
 
@@ -187,89 +218,78 @@ module GraphWeaver
187
218
  # run as a different user without rebuilding the router
188
219
  attr_accessor :context
189
220
 
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
221
+ # The planner injects key fields under this prefix, and the concrete
222
+ # __typename under that key; reading an answer back means stripping
223
+ # both, so the two sides share one definition.
224
+ PREFIX = Internal::Planner::PREFIX
225
+ TYPENAME = Internal::Planner::TYPENAME
226
+ private_constant :PREFIX, :TYPENAME
226
227
 
227
228
  # subgraphs: names the Ruby schema serving each subgraph. Omit it (or
228
229
  # 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
+ # schema defines — see {Internal::Subgraphs}, which also checks the ones you name.
230
231
  # A subgraph nothing serves is absent (refused per query, not here);
231
232
  # `"reviews" => :fake` fabricates its answers instead.
232
- def initialize(supergraph:, subgraphs: nil, context: {})
233
+ # fake: how those fabricate — see {#fake=}.
234
+ def initialize(supergraph:, subgraphs: nil, context: {}, fake: {})
233
235
  source = supergraph.to_s # a path, or the SDL itself — Pathname included
234
236
  @schema = GraphWeaver::SchemaLoader.load(source)
235
237
  @table = GraphWeaver::SchemaLoader.routing_table(source)
236
238
  @context = context
237
239
  @trace = []
238
240
 
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)
241
+ Unplannable.unsupported!(@table)
242
+
243
+ resolution = Internal::Subgraphs.resolve(@table, subgraphs)
244
+ served = resolution.served
245
+ @ambiguous = resolution.ambiguous.freeze
246
+ @faked = served.select { |_name, schema| schema == Internal::Subgraphs::FAKE }.keys.freeze
247
+ @absent = (@table.subgraphs - served.keys - @ambiguous.keys).freeze
248
+ @subgraphs = served.reject { |_name, schema| schema == Internal::Subgraphs::FAKE }
249
+ @built_fake = check_fake!(fake)
250
+ @fake = @built_fake
251
+ build_fakes
252
+ @planner = Internal::Planner.new(table: @table, schema: @schema, absent: @absent, ambiguous: @ambiguous)
258
253
  end
259
254
 
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.
255
+ # Drop the fetches recorded so far, so #trace answers about what runs
256
+ # next. For counting fetches across part of a run the whole example
257
+ # boundary is #reset!.
263
258
  def reset_trace
264
259
  @trace = []
265
260
  self
266
261
  end
267
262
 
263
+ # An example boundary. A router is built once and reused (the rspec tag
264
+ # builds one per suite), so a faked subgraph would otherwise keep
265
+ # fabricating from wherever the previous example left its sequence —
266
+ # making the same example give different data alone than in a full run,
267
+ # which is exactly what `rspec --seed` promises it won't.
268
+ def reset!
269
+ reset_trace
270
+ @fake = @built_fake
271
+ build_fakes
272
+ self
273
+ end
274
+
275
+ # How faked subgraphs fabricate, for the example in hand: the pins and
276
+ # options {FakeClient} takes, in one hash (`"Shipment.carrier" => "UPS",
277
+ # list_size: 2`), merged onto the ones the router was built with. A router is
278
+ # built once for the suite, so this is how one example pins the data a
279
+ # faked subgraph answers with; {#reset!} puts it back.
280
+ def fake=(options)
281
+ @fake = check_fake!(@built_fake.merge(options.to_h))
282
+ build_fakes
283
+ end
284
+
285
+ # the options every faked subgraph is currently fabricating with
286
+ attr_reader :fake
287
+
268
288
  def execute(query, variables: {}, operation_name: nil)
269
289
  document = begin
270
290
  GraphQL.parse(query)
271
291
  rescue GraphQL::ParseError => e
272
- return { "data" => nil, "errors" => [graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
292
+ return { "data" => nil, "errors" => [Internal::Wire.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
273
293
  end
274
294
 
275
295
  # validate the way a router does, so a stale query fails as it fails
@@ -291,12 +311,28 @@ module GraphWeaver
291
311
  parts = ["subgraphs=#{(@subgraphs.keys - @faked).inspect}"]
292
312
  parts << "faked=#{@faked.inspect}" if @faked.any?
293
313
  parts << "absent=#{@absent.inspect}" if @absent.any?
314
+ parts << "ambiguous=#{ambiguous.inspect}" if @ambiguous.any?
294
315
  "#<#{self.class.name} #{parts.join(" ")}>"
295
316
  end
296
317
  alias to_s inspect
297
318
 
298
319
  private
299
320
 
321
+ def build_fakes
322
+ @faked.each { |name| @subgraphs[name] = FakeSubgraph.new(name, @schema, **@fake) }
323
+ end
324
+
325
+ # Options nothing fabricates with pin nothing and leave the example
326
+ # green — the same silent pass a typo'd override key is refused for.
327
+ def check_fake!(options)
328
+ options = options.to_h
329
+ return options.freeze if options.empty? || @faked.any?
330
+
331
+ raise GraphWeaver::ConfigurationError, "fake: says how faked subgraphs fabricate, and this " \
332
+ "router fakes none — ask for one with subgraphs: { \"#{@table.subgraphs.first}\" => " \
333
+ "#{Internal::Subgraphs::FAKE.inspect} }"
334
+ end
335
+
300
336
  # __schema / __type describe the COMPOSED graph; a subgraph would
301
337
  # answer with its own slice
302
338
  def introspect(query, variables, operation_name)
@@ -341,13 +377,16 @@ module GraphWeaver
341
377
  response
342
378
  end
343
379
 
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.
380
+ # Only for evaluating @skip/@include, which read Booleans so only the
381
+ # scalar defaults the parser hands back as Ruby values are wanted. An
382
+ # enum or input-object default is an AST node; sending one to a subgraph
383
+ # puts a parser back-pointer on the wire, or raises inside JSON. The
384
+ # subgraph applies those itself: used_variables copies each declaration
385
+ # verbatim, defaults and all.
347
386
  def variable_defaults(operation)
348
387
  operation.variables.each_with_object({}) do |definition, defaults|
349
388
  value = definition.default_value
350
- next if value.nil? || value.is_a?(GraphQL::Language::Nodes::NullValue)
389
+ next unless value == true || value == false
351
390
 
352
391
  defaults[definition.name] = value
353
392
  end
@@ -358,7 +397,7 @@ module GraphWeaver
358
397
  # and includes under @skip — the same way graphql-ruby resolves it.
359
398
  def included?(node, variables)
360
399
  node.directives.all? do |directive|
361
- next true unless %w[skip include].include?(directive.name)
400
+ next true unless GraphWeaver::Internal::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
362
401
 
363
402
  argument = directive.arguments.find { |arg| arg.name == "if" } or next true
364
403
  value = argument.value
@@ -368,6 +407,9 @@ module GraphWeaver
368
407
  end
369
408
  end
370
409
 
410
+ # Everything the plan applies at this level: one _entities fetch per
411
+ # subgraph the level defers to (all nodes at once — _entities answers
412
+ # in representation order), then the same again one level down.
371
413
  def stitch(step, nodes, operation, variables, errors)
372
414
  return if nodes.empty?
373
415
 
@@ -376,7 +418,7 @@ module GraphWeaver
376
418
  # So bucket on the __typename that came back — each bucket then
377
419
  # crosses on its own type's @key, which is what a representation
378
420
  # needs and what the planner could not have known.
379
- if step.is_a?(Planner::Branches)
421
+ if step.is_a?(Internal::Planner::Branches)
380
422
  step.steps.each do |type_name, branch|
381
423
  stitch(branch, nodes.select { |(node, _)| node[TYPENAME] == type_name },
382
424
  operation, variables, errors)
@@ -386,17 +428,17 @@ module GraphWeaver
386
428
 
387
429
  blocked = prefetch(step, nodes, operation, variables, errors)
388
430
 
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
431
  # A fetch for a selection the operation excluded is a fetch a real
393
432
  # router never makes, and `trace` is something specs assert on. The
394
433
  # plan is built once and reused, so only here are the variables known.
395
434
  wanted = step.deferrals.select { |d| included?(d.node, variables) }
396
435
 
436
+ # a @requires fetch and a plain one need different node sets, so they
437
+ # can't share a call even into the same subgraph — which is the split
438
+ # a real router makes too
397
439
  wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
398
440
  fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
399
- tree = Router.field_tree(deferrals.flat_map(&:representation).uniq)
441
+ tree = Internal::Planner.field_tree(deferrals.flat_map(&:representation).uniq)
400
442
  representations = fetched.map { |(node, _)| representation(node, tree, step.type_name) }
401
443
 
402
444
  entities = []
@@ -442,13 +484,21 @@ module GraphWeaver
442
484
  # don't exist, so nothing depending on them can resolve.
443
485
  def prefetch(step, nodes, operation, variables, errors)
444
486
  blocked = []
445
- step.prefetches.each do |prefetch|
446
- key = Router.field_tree(prefetch.key)
487
+ # the field it feeds was excluded, so this is a fetch a real router
488
+ # never makes — and a test double that runs a resolver production
489
+ # wouldn't is answering a different question
490
+ wanted = step.prefetches.select { |prefetch| included?(prefetch.node, variables) }
491
+
492
+ # two @requires field sets that cross into the same subgraph on the
493
+ # same @key ride one entity fetch, as Apollo's do — the representations
494
+ # are identical, so a second call would only re-run resolvers
495
+ wanted.group_by { |prefetch| [prefetch.subgraph, prefetch.key] }.each do |(subgraph, keys), group|
496
+ key = Internal::Planner.field_tree(keys)
447
497
  representations = nodes.map { |(node, _)| representation(node, key, step.type_name) }
448
- selections = Router.injected_selections(prefetch.paths)
498
+ selections = Internal::Planner.injected_selections(group.flat_map(&:paths).uniq)
449
499
  roots = selections.map(&:alias)
450
500
 
451
- result = entities_fetch(prefetch.subgraph, step.type_name, selections, representations, operation, variables)
501
+ result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
452
502
  entities = result.dig("data", "_entities") || []
453
503
  Array(result["errors"]).each { |error| errors << rewrite(error, nodes) }
454
504
 
@@ -561,6 +611,7 @@ module GraphWeaver
561
611
  ),
562
612
  ),
563
613
  )
614
+ private_constant :REPRESENTATIONS, :REPRESENTATIONS_DEFINITION
564
615
 
565
616
  # A subgraph query may only declare the variables it uses, so each
566
617
  # fetch carries the slice of the operation's definitions it reached.
@@ -591,28 +642,25 @@ module GraphWeaver
591
642
  entry = { subgraph: name, query:, variables: variables.to_h }
592
643
  entry[:faked] = true if faked
593
644
  @trace << entry
594
- tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
645
+ tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
595
646
 
596
647
  # a fabricated answer that passes silently is worse than a failing
597
648
  # one, so it says so every fetch rather than once at construction
598
649
  if faked
599
- GraphWeaver.log(:warn) { "router -> #{name} #{tag} FAKED: fabricated data, not #{name}'s" }
650
+ GraphWeaver::Internal::Log.log(:warn) { "router -> #{name} #{tag} FAKED: fabricated data, not #{name}'s" }
600
651
  end
601
652
 
602
- GraphWeaver.log(:debug) do
603
- "router -> #{name} #{tag} variables=#{JSON.generate(variables)}\n" \
604
- "#{GraphWeaver::Transport.truncate_for_log(query)}"
653
+ GraphWeaver::Internal::Log.log(:debug) do
654
+ "router -> #{name} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}
655
+ " \
656
+ "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
605
657
  end
606
658
 
607
- GraphWeaver.log_timed(:debug, "router -> #{name} #{tag} completed") do
659
+ GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
608
660
  @subgraphs.fetch(name).execute(query, variables:, operation_name:, context: @context).to_h
609
661
  end
610
662
  end
611
663
 
612
- def graphql_error(message, code)
613
- { "message" => message, "extensions" => { "code" => code } }
614
- end
615
-
616
664
  # ---- null propagation ---------------------------------------------
617
665
 
618
666
  # a position whose type forbids null but whose value is null: the
@@ -634,7 +682,10 @@ module GraphWeaver
634
682
  # __typename the fetch bucketed by says what this object is. Without
635
683
  # one the subtree ran whole in one subgraph, which already applied
636
684
  # its own propagation — there is nothing to redo.
637
- concrete = value.is_a?(Hash) ? @schema.types[value[TYPENAME] || value["__typename"]] : nil
685
+ # get_type, not types[]: the latter merges every type through a
686
+ # visibility filter into a fresh hash, and this runs once per
687
+ # response row — so it would cost rows x schema size
688
+ concrete = value.is_a?(Hash) ? @schema.get_type(value[TYPENAME] || value["__typename"]) : nil
638
689
  return value unless concrete&.kind&.fields?
639
690
 
640
691
  propagate_object(value, concrete, @planner.narrow(concrete.graphql_name, selections, fragments), fragments)
@@ -682,750 +733,6 @@ module GraphWeaver
682
733
  ordered
683
734
  end
684
735
 
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
736
  end
1430
737
  end
1431
738
  end