graph_weaver 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
|
@@ -6,9 +6,11 @@ require "json"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../parsing"
|
|
8
8
|
require_relative "../schema_loader"
|
|
9
|
-
require_relative "../selection"
|
|
9
|
+
require_relative "../internal/selection"
|
|
10
|
+
require_relative "../internal"
|
|
10
11
|
require_relative "../transport"
|
|
11
|
-
require_relative "
|
|
12
|
+
require_relative "../internal/planner"
|
|
13
|
+
require_relative "../internal/subgraphs"
|
|
12
14
|
|
|
13
15
|
module GraphWeaver
|
|
14
16
|
module Testing
|
|
@@ -42,6 +44,12 @@ module GraphWeaver
|
|
|
42
44
|
"doesn't say which subgraph answers each of its fields. Run this one against a real " \
|
|
43
45
|
"router.",
|
|
44
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
|
+
],
|
|
45
53
|
chained_requires: [
|
|
46
54
|
"a @requires whose field set names another @requires field",
|
|
47
55
|
"the router satisfies a @requires with one fetch, so it can't first satisfy that " \
|
|
@@ -60,8 +68,9 @@ module GraphWeaver
|
|
|
60
68
|
],
|
|
61
69
|
shadowed_key: [
|
|
62
70
|
"an alias shadowing an injected @key",
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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.",
|
|
65
74
|
],
|
|
66
75
|
root_fields_span: [
|
|
67
76
|
"a mutation's root fields span subgraphs",
|
|
@@ -144,7 +153,8 @@ module GraphWeaver
|
|
|
144
153
|
# context: { current_user: user },
|
|
145
154
|
# )
|
|
146
155
|
#
|
|
147
|
-
# (`subgraphs:` is optional —
|
|
156
|
+
# (`subgraphs:` is optional — they're derived from what each loaded
|
|
157
|
+
# schema defines.)
|
|
148
158
|
#
|
|
149
159
|
# A supergraph only **partly** local — the rest of it served by other
|
|
150
160
|
# processes — needs nothing extra: the subgraphs nobody here defines are
|
|
@@ -196,6 +206,11 @@ module GraphWeaver
|
|
|
196
206
|
# refused at plan time, everything else runs
|
|
197
207
|
attr_reader :absent
|
|
198
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
|
+
|
|
199
214
|
# subgraphs answered with fabricated data instead of that refusal
|
|
200
215
|
attr_reader :faked
|
|
201
216
|
|
|
@@ -203,55 +218,20 @@ module GraphWeaver
|
|
|
203
218
|
# run as a different user without rebuilding the router
|
|
204
219
|
attr_accessor :context
|
|
205
220
|
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
248
227
|
|
|
249
228
|
# subgraphs: names the Ruby schema serving each subgraph. Omit it (or
|
|
250
229
|
# 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.
|
|
230
|
+
# schema defines — see {Internal::Subgraphs}, which also checks the ones you name.
|
|
252
231
|
# A subgraph nothing serves is absent (refused per query, not here);
|
|
253
232
|
# `"reviews" => :fake` fabricates its answers instead.
|
|
254
|
-
|
|
233
|
+
# fake: how those fabricate — see {#fake=}.
|
|
234
|
+
def initialize(supergraph:, subgraphs: nil, context: {}, fake: {})
|
|
255
235
|
source = supergraph.to_s # a path, or the SDL itself — Pathname included
|
|
256
236
|
@schema = GraphWeaver::SchemaLoader.load(source)
|
|
257
237
|
@table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
@@ -260,13 +240,16 @@ module GraphWeaver
|
|
|
260
240
|
|
|
261
241
|
Unplannable.unsupported!(@table)
|
|
262
242
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
@
|
|
266
|
-
@
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
@
|
|
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)
|
|
270
253
|
end
|
|
271
254
|
|
|
272
255
|
# Drop the fetches recorded so far, so #trace answers about what runs
|
|
@@ -284,15 +267,29 @@ module GraphWeaver
|
|
|
284
267
|
# which is exactly what `rspec --seed` promises it won't.
|
|
285
268
|
def reset!
|
|
286
269
|
reset_trace
|
|
287
|
-
@
|
|
270
|
+
@fake = @built_fake
|
|
271
|
+
build_fakes
|
|
288
272
|
self
|
|
289
273
|
end
|
|
290
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
|
+
|
|
291
288
|
def execute(query, variables: {}, operation_name: nil)
|
|
292
289
|
document = begin
|
|
293
290
|
GraphQL.parse(query)
|
|
294
291
|
rescue GraphQL::ParseError => e
|
|
295
|
-
return { "data" => nil, "errors" => [
|
|
292
|
+
return { "data" => nil, "errors" => [Internal::Wire.graphql_error(e.message, "GRAPHQL_PARSE_FAILED")] }
|
|
296
293
|
end
|
|
297
294
|
|
|
298
295
|
# validate the way a router does, so a stale query fails as it fails
|
|
@@ -314,12 +311,28 @@ module GraphWeaver
|
|
|
314
311
|
parts = ["subgraphs=#{(@subgraphs.keys - @faked).inspect}"]
|
|
315
312
|
parts << "faked=#{@faked.inspect}" if @faked.any?
|
|
316
313
|
parts << "absent=#{@absent.inspect}" if @absent.any?
|
|
314
|
+
parts << "ambiguous=#{ambiguous.inspect}" if @ambiguous.any?
|
|
317
315
|
"#<#{self.class.name} #{parts.join(" ")}>"
|
|
318
316
|
end
|
|
319
317
|
alias to_s inspect
|
|
320
318
|
|
|
321
319
|
private
|
|
322
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
|
+
|
|
323
336
|
# __schema / __type describe the COMPOSED graph; a subgraph would
|
|
324
337
|
# answer with its own slice
|
|
325
338
|
def introspect(query, variables, operation_name)
|
|
@@ -384,7 +397,7 @@ module GraphWeaver
|
|
|
384
397
|
# and includes under @skip — the same way graphql-ruby resolves it.
|
|
385
398
|
def included?(node, variables)
|
|
386
399
|
node.directives.all? do |directive|
|
|
387
|
-
next true unless GraphWeaver::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
|
|
400
|
+
next true unless GraphWeaver::Internal::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
|
|
388
401
|
|
|
389
402
|
argument = directive.arguments.find { |arg| arg.name == "if" } or next true
|
|
390
403
|
value = argument.value
|
|
@@ -405,7 +418,7 @@ module GraphWeaver
|
|
|
405
418
|
# So bucket on the __typename that came back — each bucket then
|
|
406
419
|
# crosses on its own type's @key, which is what a representation
|
|
407
420
|
# needs and what the planner could not have known.
|
|
408
|
-
if step.is_a?(Planner::Branches)
|
|
421
|
+
if step.is_a?(Internal::Planner::Branches)
|
|
409
422
|
step.steps.each do |type_name, branch|
|
|
410
423
|
stitch(branch, nodes.select { |(node, _)| node[TYPENAME] == type_name },
|
|
411
424
|
operation, variables, errors)
|
|
@@ -425,7 +438,7 @@ module GraphWeaver
|
|
|
425
438
|
# a real router makes too
|
|
426
439
|
wanted.group_by { |d| [d.subgraph, d.requires.any?] }.each do |(target, chained), deferrals|
|
|
427
440
|
fetched = chained ? nodes.reject { |(node, _)| blocked.include?(node.object_id) } : nodes
|
|
428
|
-
tree =
|
|
441
|
+
tree = Internal::Planner.field_tree(deferrals.flat_map(&:representation).uniq)
|
|
429
442
|
representations = fetched.map { |(node, _)| representation(node, tree, step.type_name) }
|
|
430
443
|
|
|
431
444
|
entities = []
|
|
@@ -471,13 +484,21 @@ module GraphWeaver
|
|
|
471
484
|
# don't exist, so nothing depending on them can resolve.
|
|
472
485
|
def prefetch(step, nodes, operation, variables, errors)
|
|
473
486
|
blocked = []
|
|
474
|
-
|
|
475
|
-
|
|
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)
|
|
476
497
|
representations = nodes.map { |(node, _)| representation(node, key, step.type_name) }
|
|
477
|
-
selections =
|
|
498
|
+
selections = Internal::Planner.injected_selections(group.flat_map(&:paths).uniq)
|
|
478
499
|
roots = selections.map(&:alias)
|
|
479
500
|
|
|
480
|
-
result = entities_fetch(
|
|
501
|
+
result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
|
|
481
502
|
entities = result.dig("data", "_entities") || []
|
|
482
503
|
Array(result["errors"]).each { |error| errors << rewrite(error, nodes) }
|
|
483
504
|
|
|
@@ -590,6 +611,7 @@ module GraphWeaver
|
|
|
590
611
|
),
|
|
591
612
|
),
|
|
592
613
|
)
|
|
614
|
+
private_constant :REPRESENTATIONS, :REPRESENTATIONS_DEFINITION
|
|
593
615
|
|
|
594
616
|
# A subgraph query may only declare the variables it uses, so each
|
|
595
617
|
# fetch carries the slice of the operation's definitions it reached.
|
|
@@ -620,20 +642,21 @@ module GraphWeaver
|
|
|
620
642
|
entry = { subgraph: name, query:, variables: variables.to_h }
|
|
621
643
|
entry[:faked] = true if faked
|
|
622
644
|
@trace << entry
|
|
623
|
-
tag = GraphWeaver.logger && GraphWeaver::
|
|
645
|
+
tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
|
|
624
646
|
|
|
625
647
|
# a fabricated answer that passes silently is worse than a failing
|
|
626
648
|
# one, so it says so every fetch rather than once at construction
|
|
627
649
|
if faked
|
|
628
|
-
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" }
|
|
629
651
|
end
|
|
630
652
|
|
|
631
|
-
GraphWeaver.log(:debug) do
|
|
632
|
-
"router -> #{name} #{tag} variables=#{JSON.generate(variables)}
|
|
633
|
-
|
|
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)}"
|
|
634
657
|
end
|
|
635
658
|
|
|
636
|
-
GraphWeaver.log_timed(:debug, "router -> #{name} #{tag} completed") do
|
|
659
|
+
GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
|
|
637
660
|
@subgraphs.fetch(name).execute(query, variables:, operation_name:, context: @context).to_h
|
|
638
661
|
end
|
|
639
662
|
end
|
|
@@ -710,743 +733,6 @@ module GraphWeaver
|
|
|
710
733
|
ordered
|
|
711
734
|
end
|
|
712
735
|
|
|
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
736
|
end
|
|
1451
737
|
end
|
|
1452
738
|
end
|