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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- 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 +45 -43
- 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 +6 -13
- 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 +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- 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 +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- 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 +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- 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 "
|
|
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
|
-
"
|
|
63
|
-
"
|
|
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 —
|
|
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
|
-
#
|
|
150
|
-
# *faithfully* raises {Unplannable}, before any
|
|
151
|
-
# refusal can never be a half-executed query. Apollo's
|
|
152
|
-
# thousand lines; a double that approximated the rest of
|
|
153
|
-
# test pass on an answer production disagrees with, which is
|
|
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
|
-
#
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
261
|
-
#
|
|
262
|
-
#
|
|
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
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
446
|
-
|
|
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 =
|
|
498
|
+
selections = Internal::Planner.injected_selections(group.flat_map(&:paths).uniq)
|
|
449
499
|
roots = selections.map(&:alias)
|
|
450
500
|
|
|
451
|
-
result = entities_fetch(
|
|
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::
|
|
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)}
|
|
604
|
-
|
|
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
|
-
|
|
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
|