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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. 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
- 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
+
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
- # 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
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
- def lookup(query, variables)
65
- wanted = self.class.key(query, variables)
66
- @entries.find { |entry| entry["key"] == wanted }
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
- "key" => self.class.key(query, variables),
72
- "query" => query,
73
- "variables" => variables,
74
- "response" => response,
75
- }
76
- @entries.reject! { |existing| existing["key"] == entry["key"] }
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
- def self.key(query, variables)
95
- { "query" => query.gsub(/\s+/, " ").strip, "variables" => variables || {} }
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 (or anonymize: true), responses are
108
- # anonymized as they're recorded — and the anonymized version is what
109
- # the caller sees too, so assertions written now hold on replay.
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, anonymize: nil)
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 anonymize.nil? ? config.anonymize : anonymize
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
- raise MissingRecording.new(path: @cassette.path, query:) unless entry
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
- type_value(@schema.get_field(parent_type.graphql_name, node.name).type, node, value)
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", Failure.transport) # TransportError
13
- # PersonQuery.execute(id: "1", Failure.server(status: 502))
14
- # PersonQuery.execute(id: "1", Failure.throttled) # QueryError, code THROTTLED
15
- # PersonQuery.execute(id: "1", Failure.stale_schema) # schema_stale? => true
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
- # the casualty explicitly, or pass schema: to sample a real
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
- # Failure.stale_schema(schema: MySchema) # random real field
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