graph_weaver 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -7,92 +7,205 @@ 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
- def initialize(path:, query:)
13
- super(<<~MSG.strip)
14
- no recording for this request in #{path} — re-record it
15
- (Recorder / Cassette.use with a live client, or delete
16
- the cassette to start over). Query:
17
- #{query.strip[0, 200]}
18
- MSG
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
- # Capture/replay above the transport (no HTTP interception): a
23
- # cassette is a YAML file of {query, variables, response} entries,
24
- # keyed on the normalized query + variables.
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
+
92
+ # Shapes that are a credential whatever the field around them is
93
+ # called. Anonymization can't cover everything a cassette holds — the
94
+ # variables ARE the replay key, so they're written verbatim — so the
95
+ # bytes that reach disk get one look before anyone commits them.
96
+ # Deliberately narrow: a false alarm costs a glance, while a password
97
+ # like "hunter2" has no shape at all, so a quiet run is not a clean
98
+ # bill of health.
99
+ CREDENTIAL_SHAPES = {
100
+ "a JWT" => /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
101
+ "an AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
102
+ "a GitHub token" => /\b(?:gh[opusr]|github_pat)_[A-Za-z0-9_]{20,}/,
103
+ "a Slack token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
104
+ "a Stripe key" => /\bsk_(?:live|test)_[A-Za-z0-9]{10,}/,
105
+ "a private key" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
106
+ "an Authorization header" => /\bBearer\s+\S{16,}/,
107
+ }.freeze
108
+
37
109
  attr_reader :path
38
110
 
39
111
  def initialize(path)
40
112
  @path = Testing.cassette_path(path)
41
113
  @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
114
+ @flagged = []
42
115
  end
43
116
 
44
117
  def exist? = File.exist?(@path)
45
118
  def size = @entries.size
46
119
 
47
- # Replay when recorded, record when not (VCR's once mode).
48
- # client: is required to record; omit it to replay-or-raise.
49
- # With Testing.config.record on (or GRAPHWEAVER_RECORD=1), always
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
120
+ def lookup(query, variables, operation_name = nil)
121
+ wanted = self.class.key(query, variables, operation_name)
122
+ @entries.find { |entry| self.class.entry_key(entry) == wanted }
62
123
  end
63
124
 
64
- def lookup(query, variables)
65
- wanted = self.class.key(query, variables)
66
- @entries.find { |entry| entry["key"] == wanted }
125
+ # every variables hash recorded for this query — what a miss needs
126
+ # to show, since the variables are what usually differ
127
+ def variants(query, operation_name = nil)
128
+ normalized = self.class.normalize_query(query)
129
+ @entries.select do |entry|
130
+ self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
131
+ end.map { |entry| entry["variables"] || {} }
67
132
  end
68
133
 
69
- def record(query, variables, response)
70
- entry = {
71
- "key" => self.class.key(query, variables),
72
- "query" => query,
73
- "variables" => self.class.normalize_variables(variables),
74
- "response" => response,
75
- }
76
- @entries.reject! { |existing| existing["key"] == entry["key"] }
134
+ def record(query, variables, response, operation_name = nil)
135
+ entry = { "query" => query }
136
+ entry["operationName"] = operation_name if operation_name
137
+ entry["variables"] = self.class.normalize_variables(variables)
138
+ entry["response"] = response
139
+
140
+ wanted = self.class.key(query, variables, operation_name)
141
+ @entries.reject! { |existing| self.class.entry_key(existing) == wanted }
77
142
  @entries << entry
78
143
  save
79
144
  end
80
145
 
146
+ # Replay every recording through `modules` — the generated query
147
+ # modules — and report the ones that no longer cast.
148
+ #
149
+ # A cassette is the one artifact here recorded from a *foreign* server,
150
+ # and nothing else notices when that server's answers drift out of the
151
+ # shape the structs were generated for: `verify`, `queries:check` and
152
+ # `schema:diff` all ask about the local side. Without this the drift
153
+ # surfaces mid-spec as a `TypeError` naming a struct and a sorbet
154
+ # frame, with nothing pointing at the stale file.
155
+ #
156
+ # Matching is on the query text, which is the module that sent it — a
157
+ # recording no module sends is skipped rather than guessed at.
158
+ def check(modules)
159
+ index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
160
+ checked = 0
161
+ stale = @entries.filter_map do |entry|
162
+ mod = index[self.class.normalize_query(entry["query"])] or next
163
+ checked += 1
164
+
165
+ begin
166
+ mod.from_response(entry["response"])
167
+ nil
168
+ rescue GraphWeaver::Error => e
169
+ Stale.new(module_name: mod.name, variables: entry["variables"] || {}, message: e.message)
170
+ end
171
+ end
172
+
173
+ Check.new(path: @path, checked:, skipped: @entries.size - checked, stale:)
174
+ end
175
+
81
176
  # Replace recorded response values with fakes, preserving structure.
82
177
  # Walks each entry's query against the schema (like FakeClient,
83
178
  # but transforming what's there instead of generating from scratch).
84
179
  def anonymize!(schema:, seed: nil, mode: nil)
85
180
  anonymizer = Anonymizer.new(schema:, seed:, mode:)
86
181
  @entries.each do |entry|
87
- data = entry.dig("response", "data")
88
- entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
182
+ entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
89
183
  end
90
184
  save
91
185
  self
92
186
  end
93
187
 
94
- def self.key(query, variables)
95
- { "query" => query.gsub(/\s+/, " ").strip, "variables" => normalize_variables(variables) }
188
+ # The request's identity, exactly as the server sees it. operationName
189
+ # is part of that: it picks the operation the document runs, so two
190
+ # requests with identical text but different names are different
191
+ # requests. Derived, never stored — the file holds the request once,
192
+ # so a hand-edited entry can't disagree with what replay matches on.
193
+ def self.key(query, variables, operation_name = nil)
194
+ key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
195
+ key["operationName"] = operation_name if operation_name
196
+ key
197
+ end
198
+
199
+ def self.entry_key(entry)
200
+ key(entry["query"], entry["variables"], entry["operationName"])
201
+ end
202
+
203
+ def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
204
+
205
+ # one readable line: an error naming a 60-line query is a wall, not a hint
206
+ def self.summarize(query, limit: 160)
207
+ normalized = normalize_query(query)
208
+ (normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
96
209
  end
97
210
 
98
211
  # JSON round-trip so symbol keys become strings — otherwise YAML.dump
@@ -105,22 +218,39 @@ module GraphWeaver
105
218
  private
106
219
 
107
220
  def save
221
+ yaml = YAML.dump(@entries)
108
222
  FileUtils.mkdir_p(File.dirname(@path))
109
- File.write(@path, YAML.dump(@entries))
223
+ File.write(@path, yaml)
224
+ flag_credentials(yaml)
225
+ end
226
+
227
+ # Once per shape per cassette: a recording run saves after every
228
+ # request, and one line is a warning where forty is noise. On stderr
229
+ # rather than GraphWeaver.logger — the logger is silent by default,
230
+ # and this has to reach whoever is about to commit the file.
231
+ def flag_credentials(yaml)
232
+ found = CREDENTIAL_SHAPES.reject { |name, _| @flagged.include?(name) }
233
+ .select { |_, pattern| pattern.match?(yaml) }.keys
234
+ return if found.empty?
235
+
236
+ @flagged.concat(found)
237
+ warn "graph_weaver: #{@path} contains #{found.join(", ")} — a cassette is committed as " \
238
+ "written, so review this one first. Testing.config.anonymize scrubs the response; the " \
239
+ "query and variables are the replay key and are recorded verbatim."
110
240
  end
111
241
  end
112
242
 
113
243
  # Tees requests through a live client and records every response.
114
- # With Testing.config.anonymize (or anonymize: true), responses are
115
- # anonymized as they're recorded — and the anonymized version is what
116
- # the caller sees too, so assertions written now hold on replay.
244
+ # With Testing.config.anonymize, responses are anonymized as they're
245
+ # recorded — and the anonymized version is what the caller sees too,
246
+ # so assertions written now hold on replay.
117
247
  class Recorder
118
- def initialize(client, cassette, anonymize: nil)
248
+ def initialize(client, cassette)
119
249
  @client = client
120
250
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
121
251
 
122
252
  config = Testing.config
123
- if anonymize.nil? ? config.anonymize : anonymize
253
+ if config.anonymize
124
254
  unless config.schema
125
255
  raise ArgumentError, "anonymizing recordings needs GraphWeaver::Testing.config.schema"
126
256
  end
@@ -129,13 +259,11 @@ module GraphWeaver
129
259
  end
130
260
  end
131
261
 
132
- def execute(query, variables: {})
133
- response = @client.execute(query, variables:).to_h
134
- if @anonymizer && (data = response["data"])
135
- response = response.merge("data" => @anonymizer.anonymize(query, data))
136
- end
262
+ def execute(query, variables: {}, operation_name: nil)
263
+ response = @client.execute(query, variables:, operation_name:).to_h
264
+ response = @anonymizer.anonymize(query, response) if @anonymizer
137
265
 
138
- @cassette.record(query, variables, response)
266
+ @cassette.record(query, variables, response, operation_name)
139
267
  response
140
268
  end
141
269
  end
@@ -147,9 +275,12 @@ module GraphWeaver
147
275
  @cassette = cassette.is_a?(Cassette) ? cassette : Cassette.new(cassette)
148
276
  end
149
277
 
150
- def execute(query, variables: {})
151
- entry = @cassette.lookup(query, variables)
152
- raise MissingRecording.new(path: @cassette.path, query:) unless entry
278
+ def execute(query, variables: {}, operation_name: nil)
279
+ entry = @cassette.lookup(query, variables, operation_name)
280
+ unless entry
281
+ raise MissingRecording.new(path: @cassette.path, query:, variables:,
282
+ recorded: @cassette.variants(query, operation_name), size: @cassette.size)
283
+ end
153
284
 
154
285
  entry["response"]
155
286
  end
@@ -161,18 +292,52 @@ module GraphWeaver
161
292
  class Anonymizer
162
293
  include GraphWeaver::Selection
163
294
 
295
+ # Keys under `errors`/`extensions` whose value describes the request
296
+ # rather than carrying data: `path` and `locations` point into the
297
+ # document, and `code` is the errors-world enum — call sites branch on
298
+ # it exactly as they branch on an enum in `data`, which is preserved
299
+ # for the same reason.
300
+ VERBATIM_KEYS = %w[path locations code].freeze
301
+
164
302
  def initialize(schema:, seed: nil, mode: nil)
165
303
  @schema = schema
166
304
  @values = Values.new(seed:, mode:)
167
305
  end
168
306
 
169
- def anonymize(query, data)
170
- operation = load_operation(query)
307
+ # The whole response, not just `data`: an error message routinely
308
+ # quotes the input that caused it, and `extensions` is whatever the
309
+ # server felt like attaching. One rule — `data` is walked against the
310
+ # schema, everything else by shape.
311
+ def anonymize(query, response)
312
+ response.to_h do |key, value|
313
+ [key, (key == "data") ? data_value(query, value) : untyped_value(key, value)]
314
+ end
315
+ end
316
+
317
+ private
171
318
 
319
+ def data_value(query, data)
320
+ return if data.nil?
321
+
322
+ operation = load_operation(query)
172
323
  object_value(operation_root_type(operation), operation.selections, data)
173
324
  end
174
325
 
175
- private
326
+ # No schema stands behind errors or extensions, so shape is all there
327
+ # is to preserve: keys, nesting, list lengths, nulls and booleans
328
+ # survive; every string and number is replaced.
329
+ def untyped_value(key, value)
330
+ return value if VERBATIM_KEYS.include?(key)
331
+
332
+ case value
333
+ when Hash then value.to_h { |name, nested| [name, untyped_value(name, nested)] }
334
+ when Array then value.map { |element| untyped_value(key, element) }
335
+ when String then @values.scalar("String", key)
336
+ when Integer then @values.scalar("Int", key)
337
+ when Float then @values.scalar("Float", key)
338
+ else value
339
+ end
340
+ end
176
341
 
177
342
  # Anonymization walks recorded data, not a live dispatch. When the query
178
343
  # narrows an abstract type without selecting __typename (`named { name
@@ -196,42 +361,45 @@ module GraphWeaver
196
361
  end
197
362
 
198
363
  result = {}
199
- each_field(type, selections) do |key, node|
364
+ # gather (not each_field) so a key selected twice — `a { x } a { y }` —
365
+ # keeps the MERGED shape codegen's struct expects, not last-writer-wins
366
+ gather(type, selections).each do |key, nodes|
200
367
  next unless data.key?(key)
201
368
 
369
+ node = nodes.first
202
370
  result[key] = if node.name == "__typename"
203
371
  data[key]
204
372
  else
205
- field_value(type, node, data[key])
373
+ field_value(type, node.name, nodes.flat_map(&:selections), data[key])
206
374
  end
207
375
  end
208
376
 
209
377
  result
210
378
  end
211
379
 
212
- def field_value(parent_type, node, value)
380
+ def field_value(parent_type, name, selections, value)
213
381
  # a field from a `... on Member` fragment lives on the member, not the
214
382
  # abstract type we're walking (no __typename to narrow by), so fall back
215
383
  # to whichever possible type declares it
216
- field = @schema.get_field(parent_type.graphql_name, node.name) ||
217
- @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
218
- type_value(field.type, node, value)
384
+ field = @schema.get_field(parent_type.graphql_name, name) ||
385
+ @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
386
+ type_value(field.type, name, selections, value)
219
387
  end
220
388
 
221
- def type_value(type, node, value)
389
+ def type_value(type, name, selections, value)
222
390
  return if value.nil? # preserve null positions
223
391
 
224
392
  case type.kind.name
225
393
  when "NON_NULL"
226
- type_value(type.of_type, node, value)
394
+ type_value(type.of_type, name, selections, value)
227
395
  when "LIST"
228
- value.map { |element| type_value(type.of_type, node, element) }
396
+ value.map { |element| type_value(type.of_type, name, selections, element) }
229
397
  when "SCALAR"
230
- scalar_value(type.graphql_name, node.name, value)
398
+ scalar_value(type.graphql_name, name, value)
231
399
  when "ENUM"
232
400
  value # enums aren't PII; preserving them keeps semantics
233
401
  when "OBJECT", "UNION", "INTERFACE"
234
- object_value(type, node.selections, value)
402
+ object_value(type, selections, value)
235
403
  else
236
404
  value
237
405
  end
@@ -0,0 +1,160 @@
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
+ # with the table incomplete, every number this would report is a guess
45
+ Unplannable.unsupported!(table)
46
+
47
+ # Planning still runs with `absent` empty — a query is plannable or
48
+ # not whoever is serving. Which subgraphs are *here* is asked
49
+ # separately, from evidence (a loaded schema defining what the table
50
+ # says one resolves), and never refuses: with nothing loaded the
51
+ # answer is simply "none", which is the SDL-alone CI run.
52
+ @planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
53
+ @absent = table.subgraphs.reject { |name| Subgraphs.served?(table, name) }
54
+ @local = @absent.size < table.subgraphs.size
55
+ @shared = GraphWeaver::Codegen.load_fragments(fragments)
56
+ @results = GraphWeaver.query_files(queries).map { |path| measure(path) }
57
+ end
58
+
59
+ def plannable = @results.count { |result| result.category.nil? }
60
+
61
+ # plannable *and* every subgraph the plan reaches is served here
62
+ def servable = @results.count(&:servable?)
63
+
64
+ # plannable, but reaching a subgraph another process serves
65
+ def elsewhere = @results.select { |result| result.category.nil? && result.absent.any? }
66
+
67
+ def refused = @results.reject { |result| result.category.nil? }
68
+
69
+ # whole percent: this is a count of a handful of files, and a decimal
70
+ # place would claim precision the sample doesn't have
71
+ def percent = @results.empty? ? 0 : (plannable * 100.0 / @results.size).round
72
+
73
+ def report
74
+ return "no queries found" if @results.empty?
75
+
76
+ [headline, *breakdown, *served_here, *refusals].join("\n")
77
+ end
78
+ alias to_s report
79
+
80
+ def inspect = "#<#{self.class.name} #{plannable}/#{@results.size} plannable>"
81
+
82
+ private
83
+
84
+ def headline
85
+ counted = "#{plannable}/#{@results.size} #{(@results.size == 1) ? "query" : "queries"} " \
86
+ "plannable locally (#{percent}%)"
87
+ @local ? "#{counted}, #{servable} servable here" : counted
88
+ end
89
+
90
+ # Plan-only is by design — a query is plannable whoever serves it — but
91
+ # the question this report exists to answer is whether wiring the router
92
+ # up is worth it, and a suite can only *run* what this process serves.
93
+ # A partly-local supergraph is the usual migration shape, so the second
94
+ # number has to be here rather than inferred from a refusal later.
95
+ def served_here
96
+ unless @local
97
+ return ["", "nothing here serves any of this supergraph's subgraphs " \
98
+ "(#{@absent.join(", ")}), so this counts planning only"]
99
+ end
100
+ return [] if elsewhere.empty?
101
+
102
+ example = elsewhere.first.absent.first
103
+ ["", "plannable, but nothing here serves what they reach (#{elsewhere.size}) — name a " \
104
+ "schema for those subgraphs, fake them (subgraphs: { #{example.inspect} => :fake }), " \
105
+ "or run these against a real router:"] +
106
+ elsewhere.map { |result| " #{name(result)} #{result.absent.join(", ")}" }
107
+ end
108
+
109
+ # where the plannable ones land — a graph whose queries all sit in one
110
+ # subgraph is a different situation from one that's evenly spread
111
+ def breakdown
112
+ by_subgraph = @results.filter_map(&:subgraph).tally.sort_by { |name, count| [-count, name] }
113
+ return [] if by_subgraph.empty?
114
+
115
+ [" " + by_subgraph.map { |name, count| "#{name} #{count}" }.join(", ")]
116
+ end
117
+
118
+ def refusals
119
+ return [] if refused.empty?
120
+
121
+ groups = refused.group_by(&:category).sort_by { |category, group| [-group.size, category.to_s] }
122
+
123
+ ["", "refused (#{refused.size})"] + groups.flat_map do |category, group|
124
+ ["", " #{LABELS.fetch(category, category)} (#{group.size})"] +
125
+ group.map { |result| " #{name(result)} #{result.detail}" }
126
+ end
127
+ end
128
+
129
+ # the file column: filenames when every query came from one directory,
130
+ # padded so what sits beside them lines up
131
+ def name(result) = basename(result).ljust(width)
132
+
133
+ def basename(result) = result.path.delete_prefix(shared_dir)
134
+
135
+ def width = @width ||= @results.map { |result| basename(result).length }.max
136
+
137
+ # the directory every query came from, so the report's file column is
138
+ # filenames rather than the same path repeated
139
+ def shared_dir
140
+ @shared_dir ||= begin
141
+ dirs = @results.map { |result| File.dirname(result.path) }.uniq
142
+ dirs.one? ? "#{dirs.first}/" : ""
143
+ end
144
+ end
145
+
146
+ def measure(path)
147
+ document = GraphQL.parse(GraphWeaver::Codegen.inline_fragments(File.read(path), @shared, path))
148
+ errors = @planner.validate(document)
149
+ return Result.new(path, nil, [], :invalid, errors.first["message"]) if errors.any?
150
+
151
+ plan = @planner.plan(document)
152
+ Result.new(path, plan.where, plan.subgraphs & @absent, nil, nil)
153
+ rescue Unplannable => e
154
+ Result.new(path, nil, [], e.category, e.detail)
155
+ rescue GraphQL::ParseError, GraphWeaver::Error => e
156
+ Result.new(path, nil, [], :invalid, e.message)
157
+ end
158
+ end
159
+ end
160
+ end