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