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
|
@@ -7,33 +7,88 @@ require "yaml"
|
|
|
7
7
|
|
|
8
8
|
module GraphWeaver
|
|
9
9
|
module Testing
|
|
10
|
-
# Raised by Replayer when a request has no recording.
|
|
10
|
+
# Raised by Replayer when a request has no recording. The query
|
|
11
|
+
# usually matches and the variables don't, so the variables lead and
|
|
12
|
+
# the recorded ones for the same query come next — the diff you'd
|
|
13
|
+
# otherwise do by eye against the YAML.
|
|
11
14
|
class MissingRecording < GraphWeaver::Error
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#{
|
|
18
|
-
|
|
15
|
+
# how many recorded variable sets to print before summarizing
|
|
16
|
+
SHOWN = 5
|
|
17
|
+
|
|
18
|
+
def initialize(path:, query:, variables:, recorded:, size:)
|
|
19
|
+
super([
|
|
20
|
+
"no recording for this request in #{path}",
|
|
21
|
+
" variables: #{Cassette.normalize_variables(variables).inspect}",
|
|
22
|
+
" #{self.class.recorded_summary(recorded, size)}",
|
|
23
|
+
" query: #{Cassette.summarize(query)}",
|
|
24
|
+
"re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
|
|
25
|
+
].join("\n"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.recorded_summary(recorded, size)
|
|
29
|
+
return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
|
|
30
|
+
|
|
31
|
+
more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
|
|
32
|
+
"#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
|
|
33
|
+
"with variables #{recorded.first(SHOWN).map(&:inspect).join(", ")}#{more}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# The cassette-backed client: replays spec/cassettes/<name>.yml when
|
|
39
|
+
# it exists, records it through client: when it doesn't (VCR's once
|
|
40
|
+
# mode). Record mode (GRAPHWEAVER_RECORD=1 / config.record) always
|
|
41
|
+
# records, so it needs a client: too.
|
|
42
|
+
#
|
|
43
|
+
# client = GraphWeaver::Testing.cassette("github", client: live)
|
|
44
|
+
# result = RepoQuery.execute!(client:, owner: "dpep")
|
|
45
|
+
#
|
|
46
|
+
def cassette(name, client: nil)
|
|
47
|
+
file = Cassette.new(name)
|
|
48
|
+
|
|
49
|
+
if client && (config.record || !file.exist?)
|
|
50
|
+
Recorder.new(client, file)
|
|
51
|
+
elsif config.record
|
|
52
|
+
# record mode without a client would quietly serve the stale
|
|
53
|
+
# recording — the one thing "re-record everything" didn't ask for
|
|
54
|
+
raise GraphWeaver::Error, "record mode is on but no `client:` was given for #{file.path} " \
|
|
55
|
+
"— pass a live `client:` to re-record it, or turn record mode off " \
|
|
56
|
+
"(GRAPHWEAVER_RECORD / Testing.config.record)."
|
|
57
|
+
elsif file.exist?
|
|
58
|
+
Replayer.new(file)
|
|
59
|
+
else
|
|
60
|
+
# a first run, not a missing recording: there is no request yet
|
|
61
|
+
raise GraphWeaver::Error, "#{file.path} doesn't exist and no `client:` was given to " \
|
|
62
|
+
"record with — pass `client:` on the first run, or commit the cassette."
|
|
63
|
+
end
|
|
19
64
|
end
|
|
20
65
|
end
|
|
21
66
|
|
|
22
|
-
#
|
|
23
|
-
# cassette
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# # record against a real client, replay when the file exists:
|
|
27
|
-
# client = GraphWeaver::Testing::Cassette.use("github", client: real)
|
|
28
|
-
#
|
|
29
|
-
# Cassettes hold real responses — anonymize before committing:
|
|
30
|
-
#
|
|
31
|
-
# Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
|
|
32
|
-
#
|
|
33
|
-
# keeps every shape (list lengths, null positions, enums, __typename,
|
|
34
|
-
# id relationships via a consistent mapping) while replacing values
|
|
35
|
-
# with fakes, semantically matched where field names allow.
|
|
67
|
+
# The cassette file itself: a YAML list of {query, variables, response}
|
|
68
|
+
# entries. Testing.cassette wraps one in a record/replay client; this is
|
|
69
|
+
# the file object behind it — and what the anonymize rake task rewrites.
|
|
36
70
|
class Cassette
|
|
71
|
+
# What replaying one cassette through the current generated modules
|
|
72
|
+
# found. `checked` is how many recordings a module claimed: a run that
|
|
73
|
+
# claimed none proved nothing, which is a different answer from "all
|
|
74
|
+
# good" — the same distinction `federation:diff` draws.
|
|
75
|
+
Check = Struct.new(:path, :checked, :skipped, :stale, keyword_init: true) do
|
|
76
|
+
def ok? = stale.empty?
|
|
77
|
+
|
|
78
|
+
def report
|
|
79
|
+
counted = ["#{checked} checked"]
|
|
80
|
+
counted << "#{skipped} not sent by any query module" if skipped.positive?
|
|
81
|
+
|
|
82
|
+
["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
|
|
83
|
+
stale.flat_map do |entry|
|
|
84
|
+
[" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# One recording the generated structs can no longer read.
|
|
90
|
+
Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
|
|
91
|
+
|
|
37
92
|
attr_reader :path
|
|
38
93
|
|
|
39
94
|
def initialize(path)
|
|
@@ -44,40 +99,62 @@ module GraphWeaver
|
|
|
44
99
|
def exist? = File.exist?(@path)
|
|
45
100
|
def size = @entries.size
|
|
46
101
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# records — the "just re-record everything" switch.
|
|
51
|
-
def self.use(path, client: nil)
|
|
52
|
-
cassette = new(path)
|
|
53
|
-
if Testing.config.record && client
|
|
54
|
-
Recorder.new(client, cassette)
|
|
55
|
-
elsif cassette.exist?
|
|
56
|
-
Replayer.new(cassette)
|
|
57
|
-
elsif client
|
|
58
|
-
Recorder.new(client, cassette)
|
|
59
|
-
else
|
|
60
|
-
raise MissingRecording.new(path: cassette.path, query: "(no client to record with)")
|
|
61
|
-
end
|
|
102
|
+
def lookup(query, variables, operation_name = nil)
|
|
103
|
+
wanted = self.class.key(query, variables, operation_name)
|
|
104
|
+
@entries.find { |entry| self.class.entry_key(entry) == wanted }
|
|
62
105
|
end
|
|
63
106
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
107
|
+
# every variables hash recorded for this query — what a miss needs
|
|
108
|
+
# to show, since the variables are what usually differ
|
|
109
|
+
def variants(query, operation_name = nil)
|
|
110
|
+
normalized = self.class.normalize_query(query)
|
|
111
|
+
@entries.select do |entry|
|
|
112
|
+
self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
|
|
113
|
+
end.map { |entry| entry["variables"] || {} }
|
|
67
114
|
end
|
|
68
115
|
|
|
69
|
-
def record(query, variables, response)
|
|
70
|
-
entry = {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@entries.reject! { |existing| existing
|
|
116
|
+
def record(query, variables, response, operation_name = nil)
|
|
117
|
+
entry = { "query" => query }
|
|
118
|
+
entry["operationName"] = operation_name if operation_name
|
|
119
|
+
entry["variables"] = self.class.normalize_variables(variables)
|
|
120
|
+
entry["response"] = response
|
|
121
|
+
|
|
122
|
+
wanted = self.class.key(query, variables, operation_name)
|
|
123
|
+
@entries.reject! { |existing| self.class.entry_key(existing) == wanted }
|
|
77
124
|
@entries << entry
|
|
78
125
|
save
|
|
79
126
|
end
|
|
80
127
|
|
|
128
|
+
# Replay every recording through `modules` — the generated query
|
|
129
|
+
# modules — and report the ones that no longer cast.
|
|
130
|
+
#
|
|
131
|
+
# A cassette is the one artifact here recorded from a *foreign* server,
|
|
132
|
+
# and nothing else notices when that server's answers drift out of the
|
|
133
|
+
# shape the structs were generated for: `verify`, `queries:check` and
|
|
134
|
+
# `schema:diff` all ask about the local side. Without this the drift
|
|
135
|
+
# surfaces mid-spec as a `TypeError` naming a struct and a sorbet
|
|
136
|
+
# frame, with nothing pointing at the stale file.
|
|
137
|
+
#
|
|
138
|
+
# Matching is on the query text, which is the module that sent it — a
|
|
139
|
+
# recording no module sends is skipped rather than guessed at.
|
|
140
|
+
def check(modules)
|
|
141
|
+
index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
|
|
142
|
+
checked = 0
|
|
143
|
+
stale = @entries.filter_map do |entry|
|
|
144
|
+
mod = index[self.class.normalize_query(entry["query"])] or next
|
|
145
|
+
checked += 1
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
mod.from_response(entry["response"])
|
|
149
|
+
nil
|
|
150
|
+
rescue GraphWeaver::Error => e
|
|
151
|
+
Stale.new(module_name: mod.name, variables: entry["variables"] || {}, message: e.message)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Check.new(path: @path, checked:, skipped: @entries.size - checked, stale:)
|
|
156
|
+
end
|
|
157
|
+
|
|
81
158
|
# Replace recorded response values with fakes, preserving structure.
|
|
82
159
|
# Walks each entry's query against the schema (like FakeClient,
|
|
83
160
|
# but transforming what's there instead of generating from scratch).
|
|
@@ -91,8 +168,34 @@ module GraphWeaver
|
|
|
91
168
|
self
|
|
92
169
|
end
|
|
93
170
|
|
|
94
|
-
|
|
95
|
-
|
|
171
|
+
# The request's identity, exactly as the server sees it. operationName
|
|
172
|
+
# is part of that: it picks the operation the document runs, so two
|
|
173
|
+
# requests with identical text but different names are different
|
|
174
|
+
# requests. Derived, never stored — the file holds the request once,
|
|
175
|
+
# so a hand-edited entry can't disagree with what replay matches on.
|
|
176
|
+
def self.key(query, variables, operation_name = nil)
|
|
177
|
+
key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
|
|
178
|
+
key["operationName"] = operation_name if operation_name
|
|
179
|
+
key
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.entry_key(entry)
|
|
183
|
+
key(entry["query"], entry["variables"], entry["operationName"])
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
|
|
187
|
+
|
|
188
|
+
# one readable line: an error naming a 60-line query is a wall, not a hint
|
|
189
|
+
def self.summarize(query, limit: 160)
|
|
190
|
+
normalized = normalize_query(query)
|
|
191
|
+
(normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# JSON round-trip so symbol keys become strings — otherwise YAML.dump
|
|
195
|
+
# writes Ruby symbols the safe loader rejects on the next run, and lookup
|
|
196
|
+
# keys stay stable across processes
|
|
197
|
+
def self.normalize_variables(variables)
|
|
198
|
+
JSON.parse(JSON.generate(variables || {}))
|
|
96
199
|
end
|
|
97
200
|
|
|
98
201
|
private
|
|
@@ -104,16 +207,16 @@ module GraphWeaver
|
|
|
104
207
|
end
|
|
105
208
|
|
|
106
209
|
# Tees requests through a live client and records every response.
|
|
107
|
-
# With Testing.config.anonymize
|
|
108
|
-
#
|
|
109
|
-
#
|
|
210
|
+
# With Testing.config.anonymize, responses are anonymized as they're
|
|
211
|
+
# recorded — and the anonymized version is what the caller sees too,
|
|
212
|
+
# so assertions written now hold on replay.
|
|
110
213
|
class Recorder
|
|
111
|
-
def initialize(client, cassette
|
|
214
|
+
def initialize(client, cassette)
|
|
112
215
|
@client = client
|
|
113
216
|
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
114
217
|
|
|
115
218
|
config = Testing.config
|
|
116
|
-
if
|
|
219
|
+
if config.anonymize
|
|
117
220
|
unless config.schema
|
|
118
221
|
raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
|
|
119
222
|
end
|
|
@@ -122,13 +225,13 @@ module GraphWeaver
|
|
|
122
225
|
end
|
|
123
226
|
end
|
|
124
227
|
|
|
125
|
-
def execute(query, variables: {})
|
|
126
|
-
response = @client.execute(query, variables:).to_h
|
|
228
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
229
|
+
response = @client.execute(query, variables:, operation_name:).to_h
|
|
127
230
|
if @anonymizer && (data = response["data"])
|
|
128
231
|
response = response.merge("data" => @anonymizer.anonymize(query, data))
|
|
129
232
|
end
|
|
130
233
|
|
|
131
|
-
@cassette.record(query, variables, response)
|
|
234
|
+
@cassette.record(query, variables, response, operation_name)
|
|
132
235
|
response
|
|
133
236
|
end
|
|
134
237
|
end
|
|
@@ -140,9 +243,12 @@ module GraphWeaver
|
|
|
140
243
|
@cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
|
|
141
244
|
end
|
|
142
245
|
|
|
143
|
-
def execute(query, variables: {})
|
|
144
|
-
entry = @cassette.lookup(query, variables)
|
|
145
|
-
|
|
246
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
247
|
+
entry = @cassette.lookup(query, variables, operation_name)
|
|
248
|
+
unless entry
|
|
249
|
+
raise MissingRecording.new(path: @cassette.path, query:, variables:,
|
|
250
|
+
recorded: @cassette.variants(query, operation_name), size: @cassette.size)
|
|
251
|
+
end
|
|
146
252
|
|
|
147
253
|
entry["response"]
|
|
148
254
|
end
|
|
@@ -167,6 +273,19 @@ module GraphWeaver
|
|
|
167
273
|
|
|
168
274
|
private
|
|
169
275
|
|
|
276
|
+
# Anonymization walks recorded data, not a live dispatch. When the query
|
|
277
|
+
# narrows an abstract type without selecting __typename (`named { name
|
|
278
|
+
# ... on Pet { species } }`), the recorded data has no type tag, so the
|
|
279
|
+
# strict applies? would drop the `... on Pet` fields. Treat any concrete
|
|
280
|
+
# member condition as applying; object_value's `data.key?(key)` guard
|
|
281
|
+
# discards fields the actual member's response didn't carry.
|
|
282
|
+
def applies?(condition, type)
|
|
283
|
+
return true if super
|
|
284
|
+
|
|
285
|
+
member = @schema.get_type(condition)
|
|
286
|
+
!!member && @schema.possible_types(type).include?(member)
|
|
287
|
+
end
|
|
288
|
+
|
|
170
289
|
def object_value(type, selections, data)
|
|
171
290
|
return data if data.nil?
|
|
172
291
|
|
|
@@ -190,7 +309,12 @@ module GraphWeaver
|
|
|
190
309
|
end
|
|
191
310
|
|
|
192
311
|
def field_value(parent_type, node, value)
|
|
193
|
-
|
|
312
|
+
# a field from a `... on Member` fragment lives on the member, not the
|
|
313
|
+
# abstract type we're walking (no __typename to narrow by), so fall back
|
|
314
|
+
# to whichever possible type declares it
|
|
315
|
+
field = @schema.get_field(parent_type.graphql_name, node.name) ||
|
|
316
|
+
@schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
|
|
317
|
+
type_value(field.type, node, value)
|
|
194
318
|
end
|
|
195
319
|
|
|
196
320
|
def type_value(type, node, value)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "graphql"
|
|
5
|
+
|
|
6
|
+
require_relative "router"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
module Testing
|
|
10
|
+
# How much of a query set the local {Router} can plan, and — for the rest
|
|
11
|
+
# — exactly what stopped it:
|
|
12
|
+
#
|
|
13
|
+
# rake graph_weaver:federation:coverage SUPERGRAPH=supergraph.graphql
|
|
14
|
+
#
|
|
15
|
+
# The router refuses every query that crosses a subgraph boundary, so its
|
|
16
|
+
# worth to a suite is one number: the fraction of *your* queries it can
|
|
17
|
+
# answer. Nobody can guess that from outside — it depends on the shape of
|
|
18
|
+
# your graph and the shape of your queries — so measure it before
|
|
19
|
+
# deciding the router is (or isn't) enough.
|
|
20
|
+
#
|
|
21
|
+
# Planning needs the supergraph and nothing else, so this runs without
|
|
22
|
+
# any subgraph being loadable, in CI or on a laptop with the SDL alone.
|
|
23
|
+
class Coverage
|
|
24
|
+
# One query file's verdict: `category` nil means the router can plan
|
|
25
|
+
# it, and `subgraph` is where it runs — "accounts+reviews" when the
|
|
26
|
+
# plan stitches across a boundary. `absent` names the subgraphs that
|
|
27
|
+
# plan reaches which nothing in this process serves — plannable and
|
|
28
|
+
# runnable-here are different questions.
|
|
29
|
+
Result = Struct.new(:path, :subgraph, :absent, :category, :detail) do
|
|
30
|
+
def servable? = category.nil? && absent.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# the label each verdict groups under
|
|
34
|
+
LABELS = Unplannable::CATEGORIES
|
|
35
|
+
.transform_values(&:first)
|
|
36
|
+
.merge(invalid: "doesn't validate against the supergraph")
|
|
37
|
+
.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :results
|
|
40
|
+
|
|
41
|
+
def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
|
|
42
|
+
source = supergraph.to_s
|
|
43
|
+
table = GraphWeaver::SchemaLoader.routing_table(source)
|
|
44
|
+
unless table.unsupported.empty?
|
|
45
|
+
# the same refusal the Router makes at construction: with the table
|
|
46
|
+
# incomplete, every number this would report is a guess
|
|
47
|
+
raise Unplannable.new(
|
|
48
|
+
"this supergraph uses federation constructs the local router doesn't read: " +
|
|
49
|
+
table.unsupported.join("; "),
|
|
50
|
+
category: :unsupported_federation,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Planning still runs with `absent` empty — a query is plannable or
|
|
55
|
+
# not whoever is serving. Which subgraphs are *here* is asked
|
|
56
|
+
# separately, from evidence (a loaded schema defining what the table
|
|
57
|
+
# says one resolves), and never refuses: with nothing loaded the
|
|
58
|
+
# answer is simply "none", which is the SDL-alone CI run.
|
|
59
|
+
@planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
|
|
60
|
+
@absent = table.subgraphs.reject { |name| Subgraphs.candidates(table, name).any? }
|
|
61
|
+
@local = @absent.size < table.subgraphs.size
|
|
62
|
+
@shared = GraphWeaver::Codegen.load_fragments(fragments)
|
|
63
|
+
@results = GraphWeaver.query_files(queries).map { |path| measure(path) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def plannable = @results.count { |result| result.category.nil? }
|
|
67
|
+
|
|
68
|
+
# plannable *and* every subgraph the plan reaches is served here
|
|
69
|
+
def servable = @results.count(&:servable?)
|
|
70
|
+
|
|
71
|
+
# plannable, but reaching a subgraph another process serves
|
|
72
|
+
def elsewhere = @results.select { |result| result.category.nil? && result.absent.any? }
|
|
73
|
+
|
|
74
|
+
def refused = @results.reject { |result| result.category.nil? }
|
|
75
|
+
|
|
76
|
+
# whole percent: this is a count of a handful of files, and a decimal
|
|
77
|
+
# place would claim precision the sample doesn't have
|
|
78
|
+
def percent = @results.empty? ? 0 : (plannable * 100.0 / @results.size).round
|
|
79
|
+
|
|
80
|
+
def report
|
|
81
|
+
return "no queries found" if @results.empty?
|
|
82
|
+
|
|
83
|
+
[headline, *breakdown, *served_here, *refusals].join("\n")
|
|
84
|
+
end
|
|
85
|
+
alias to_s report
|
|
86
|
+
|
|
87
|
+
def inspect = "#<#{self.class.name} #{plannable}/#{@results.size} plannable>"
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def headline
|
|
92
|
+
counted = "#{plannable}/#{@results.size} #{(@results.size == 1) ? "query" : "queries"} " \
|
|
93
|
+
"plannable locally (#{percent}%)"
|
|
94
|
+
@local ? "#{counted}, #{servable} servable here" : counted
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Plan-only is by design — a query is plannable whoever serves it — but
|
|
98
|
+
# the question this report exists to answer is whether wiring the router
|
|
99
|
+
# up is worth it, and a suite can only *run* what this process serves.
|
|
100
|
+
# A partly-local supergraph is the usual migration shape, so the second
|
|
101
|
+
# number has to be here rather than inferred from a refusal later.
|
|
102
|
+
def served_here
|
|
103
|
+
unless @local
|
|
104
|
+
return ["", "nothing here serves any of this supergraph's subgraphs " \
|
|
105
|
+
"(#{@absent.join(", ")}), so this counts planning only"]
|
|
106
|
+
end
|
|
107
|
+
return [] if elsewhere.empty?
|
|
108
|
+
|
|
109
|
+
example = elsewhere.first.absent.first
|
|
110
|
+
["", "plannable, but nothing here serves what they reach (#{elsewhere.size}) — name a " \
|
|
111
|
+
"schema for those subgraphs, fake them (subgraphs: { #{example.inspect} => :fake }), " \
|
|
112
|
+
"or run these against a real router:"] +
|
|
113
|
+
elsewhere.map { |result| " #{name(result)} #{result.absent.join(", ")}" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# where the plannable ones land — a graph whose queries all sit in one
|
|
117
|
+
# subgraph is a different situation from one that's evenly spread
|
|
118
|
+
def breakdown
|
|
119
|
+
by_subgraph = @results.filter_map(&:subgraph).tally.sort_by { |name, count| [-count, name] }
|
|
120
|
+
return [] if by_subgraph.empty?
|
|
121
|
+
|
|
122
|
+
[" " + by_subgraph.map { |name, count| "#{name} #{count}" }.join(", ")]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def refusals
|
|
126
|
+
return [] if refused.empty?
|
|
127
|
+
|
|
128
|
+
groups = refused.group_by(&:category).sort_by { |category, group| [-group.size, category.to_s] }
|
|
129
|
+
|
|
130
|
+
["", "refused (#{refused.size})"] + groups.flat_map do |category, group|
|
|
131
|
+
["", " #{LABELS.fetch(category, category)} (#{group.size})"] +
|
|
132
|
+
group.map { |result| " #{name(result)} #{result.detail}" }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# the file column: filenames when every query came from one directory,
|
|
137
|
+
# padded so what sits beside them lines up
|
|
138
|
+
def name(result) = basename(result).ljust(width)
|
|
139
|
+
|
|
140
|
+
def basename(result) = result.path.delete_prefix(shared_dir)
|
|
141
|
+
|
|
142
|
+
def width = @width ||= @results.map { |result| basename(result).length }.max
|
|
143
|
+
|
|
144
|
+
# the directory every query came from, so the report's file column is
|
|
145
|
+
# filenames rather than the same path repeated
|
|
146
|
+
def shared_dir
|
|
147
|
+
dirs = @results.map { |result| File.dirname(result.path) }.uniq
|
|
148
|
+
dirs.one? ? "#{dirs.first}/" : ""
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def measure(path)
|
|
152
|
+
document = GraphQL.parse(GraphWeaver::Codegen.inline_fragments(File.read(path), @shared, path))
|
|
153
|
+
errors = @planner.validate(document)
|
|
154
|
+
return Result.new(path, nil, [], :invalid, errors.first["message"]) if errors.any?
|
|
155
|
+
|
|
156
|
+
plan = @planner.plan(document)
|
|
157
|
+
Result.new(path, plan.where, plan.subgraphs & @absent, nil, nil)
|
|
158
|
+
rescue Unplannable => e
|
|
159
|
+
Result.new(path, nil, [], e.category, e.detail)
|
|
160
|
+
rescue GraphQL::ParseError, GraphWeaver::Error => e
|
|
161
|
+
Result.new(path, nil, [], :invalid, e.message)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -9,10 +9,10 @@ module GraphWeaver
|
|
|
9
9
|
# transports produce, so error-handling paths are testable without a
|
|
10
10
|
# server that misbehaves on cue:
|
|
11
11
|
#
|
|
12
|
-
# PersonQuery.execute(id: "1"
|
|
13
|
-
# PersonQuery.execute(
|
|
14
|
-
# PersonQuery.execute(id: "1"
|
|
15
|
-
# PersonQuery.execute(id: "1"
|
|
12
|
+
# PersonQuery.execute(client: Failure.transport, id: "1") # TransportError
|
|
13
|
+
# PersonQuery.execute(client: Failure.server(status: 502), id: "1")
|
|
14
|
+
# PersonQuery.execute(client: Failure.throttled, id: "1") # QueryError, code THROTTLED
|
|
15
|
+
# PersonQuery.execute(client: Failure.stale_schema, id: "1") # schema_stale? => true
|
|
16
16
|
#
|
|
17
17
|
# For type mismatches, corrupt the wire with a FakeClient override:
|
|
18
18
|
# FakeClient.new(schema:, overrides: { "Person.birthday" => 123 })
|
|
@@ -56,24 +56,11 @@ module GraphWeaver
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# A validation-shaped rejection — trips schema_stale? and its
|
|
59
|
-
# regenerate hint, as if the schema changed under the module. Name
|
|
60
|
-
#
|
|
61
|
-
# type/field (as if the server just dropped it):
|
|
59
|
+
# regenerate hint, as if the schema changed under the module. Name the
|
|
60
|
+
# casualty when the message matters:
|
|
62
61
|
#
|
|
63
62
|
# Failure.stale_schema(type: "Person", field: "name")
|
|
64
|
-
|
|
65
|
-
# Failure.stale_schema(schema: MySchema, seed: 42) # reproducibly random
|
|
66
|
-
def stale_schema(field: nil, type: nil, schema: nil, seed: nil)
|
|
67
|
-
if schema && (field.nil? || type.nil?)
|
|
68
|
-
rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
|
|
69
|
-
candidates = schema.types.values.select do |candidate|
|
|
70
|
-
candidate.kind.name == "OBJECT" && !candidate.graphql_name.start_with?("__")
|
|
71
|
-
end
|
|
72
|
-
chosen = candidates.sort_by(&:graphql_name).sample(random: rng)
|
|
73
|
-
type ||= chosen.graphql_name
|
|
74
|
-
field ||= chosen.fields.keys.sort.sample(random: rng)
|
|
75
|
-
end
|
|
76
|
-
|
|
63
|
+
def stale_schema(field: nil, type: nil)
|
|
77
64
|
graphql("Field '#{field || "someField"}' doesn't exist on type '#{type || "SomeType"}'")
|
|
78
65
|
end
|
|
79
66
|
end
|
|
@@ -84,7 +71,7 @@ module GraphWeaver
|
|
|
84
71
|
@response = response
|
|
85
72
|
end
|
|
86
73
|
|
|
87
|
-
def execute(_query, variables: {})
|
|
74
|
+
def execute(_query, variables: {}, operation_name: nil)
|
|
88
75
|
@response.call
|
|
89
76
|
end
|
|
90
77
|
end
|
|
@@ -99,10 +86,10 @@ module GraphWeaver
|
|
|
99
86
|
@calls = 0
|
|
100
87
|
end
|
|
101
88
|
|
|
102
|
-
def execute(query, variables: {})
|
|
89
|
+
def execute(query, variables: {}, operation_name: nil)
|
|
103
90
|
client = @clients[[@calls, @clients.size - 1].min]
|
|
104
91
|
@calls += 1
|
|
105
|
-
client.execute(query, variables:)
|
|
92
|
+
client.execute(query, variables:, operation_name:)
|
|
106
93
|
end
|
|
107
94
|
end
|
|
108
95
|
end
|