graph_weaver 0.7.4 → 0.7.6

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
@@ -161,7 +161,9 @@ module GraphWeaver
161
161
  # suite's WebMock setup is touched.
162
162
  def self.serve!
163
163
  webmock!
164
- wire_targets.map do |url, graph|
164
+ targets, above = wire_targets
165
+ above.each { |graph| disclose_above!(graph) }
166
+ targets.map do |url, graph|
165
167
  # built here, so a graph with nothing to serve refuses before the
166
168
  # example runs rather than from inside its first request
167
169
  disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
@@ -210,6 +212,17 @@ module GraphWeaver
210
212
  end
211
213
  end
212
214
 
215
+ # Say which graph ran above the wire. Its stand-in is built when one of
216
+ # its modules first runs, not here — a graph the example never touches
217
+ # must not refuse it — so this names the graph rather than what is
218
+ # behind it.
219
+ def self.disclose_above!(graph)
220
+ GraphWeaver::Internal::Log.log(:info) do
221
+ ":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
222
+ "its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
223
+ end
224
+ end
225
+
213
226
  # What the stand-in IS, read off the object rather than re-deciding —
214
227
  # one answer, and it can't drift from what was built. A fake of a dump
215
228
  # has no name to give: the dump loads as an anonymous class, and
@@ -254,21 +267,24 @@ module GraphWeaver
254
267
  # inside the cleanup.
255
268
  def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
256
269
 
257
- # Every endpoint an example's modules can post to, each with the graph
258
- # whose resolvers belong behind it: the client each graph names, or
259
- # GraphWeaver.client for a graph naming none. One graph per endpoint —
260
- # an app whose graphs all name clients needs no app default at all.
270
+ # What :wire does with each graph, in two lists: the endpoints to stub,
271
+ # each with the graph whose resolvers belong behind it, and the graphs
272
+ # there is no endpoint for. One graph per endpoint — an app whose graphs
273
+ # all name clients needs no app default at all.
274
+ #
275
+ # A graph whose client posts to no url has no wire to be served at, so
276
+ # it runs above one instead of refusing the example — including the
277
+ # examples that never touch it. An example where NO graph posts anywhere
278
+ # is refused, since a :wire that serves nothing tests no transport.
261
279
  def self.wire_targets
262
- targets = GraphWeaver.graphs.filter_map do |graph|
263
- client = graph.client || GraphWeaver.client
264
- [endpoint!(client, graph), graph] if client
265
- end
280
+ targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
266
281
  refuse_shared_endpoint!(targets)
267
- return targets if targets.any?
282
+ return [targets, above.map(&:last)] if targets.any?
268
283
 
269
- # nothing bakes a client and the app has none: the endpoint refusal
270
- # names the empty slot, which is the thing to fix
271
- endpoint!(GraphWeaver.client)
284
+ # the endpoint refusal names the client that posts to none, which is
285
+ # the thing to fix
286
+ graph = GraphWeaver.graphs.first
287
+ endpoint!(graph&.client || GraphWeaver.client, graph)
272
288
  end
273
289
 
274
290
  # One stub per url, so two graphs on one endpoint used to mean the
@@ -343,7 +359,8 @@ module GraphWeaver
343
359
  end
344
360
 
345
361
  private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
346
- :webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
362
+ :webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
363
+ :loaded_schemas
347
364
 
348
365
  # Included into every example group, so graphql_context is there
349
366
  # whether or not this example took a client from the hook.
@@ -570,10 +587,17 @@ module GraphWeaver
570
587
  raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
571
588
  "#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
572
589
  "#{TAG}: :router (or pin the data itself: " \
573
- "graphql_fake(overrides: { \"Person.name\" => \"Ada\" }))"
590
+ "graphql_fake(\"Person.name\" => \"Ada\"))"
574
591
  else
575
- raise GraphWeaver::Error, "graphql_context needs an example running against your " \
576
- "resolvers tag it #{TAG}: :in_process or #{TAG}: :router"
592
+ # :live, which is what an untagged example is. The app's own client
593
+ # stays in the slot holding the context it was built with, so there
594
+ # is nothing here to merge onto — and saying "you need resolvers"
595
+ # reads as false to an app whose own client is an InProcess, which
596
+ # is running them
597
+ raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
598
+ "and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
599
+ "context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
600
+ "which build a client per example."
577
601
  end
578
602
  end
579
603
 
@@ -592,7 +616,6 @@ module GraphWeaver
592
616
  "may itself be a proc, so it can vary per request."
593
617
  end
594
618
  private_class_method :wire_context!
595
-
596
619
  end
597
620
  end
598
621
  end
@@ -122,6 +122,8 @@ module GraphWeaver
122
122
  return change(name, "#{kind(old)} -> #{kind(new)}", breaking: true)
123
123
  end
124
124
 
125
+ compare_description(name, old, new)
126
+
125
127
  case new.kind.name
126
128
  when "OBJECT", "INTERFACE"
127
129
  compare_fields(name, old, new)
@@ -151,6 +153,7 @@ module GraphWeaver
151
153
  breaking: breaks_output?(old.type, new.type))
152
154
  end
153
155
  compare_deprecation(coordinate, old, new)
156
+ compare_description(coordinate, old, new)
154
157
  compare_arguments(coordinate, old.arguments, new.arguments)
155
158
  end
156
159
 
@@ -192,6 +195,7 @@ module GraphWeaver
192
195
  change(coordinate, "#{prefix}#{signature(old)} -> #{signature(new)}", breaking:)
193
196
  end
194
197
  compare_deprecation(coordinate, old, new)
198
+ compare_description(coordinate, old, new)
195
199
  end
196
200
 
197
201
  def compare_enum(name, old, new)
@@ -200,7 +204,10 @@ module GraphWeaver
200
204
 
201
205
  (before.keys - after.keys).each { |value| change("#{name}.#{value}", "enum value removed", breaking: true) }
202
206
  (after.keys - before.keys).each { |value| change("#{name}.#{value}", "enum value added") }
203
- (before.keys & after.keys).each { |value| compare_deprecation("#{name}.#{value}", before[value], after[value]) }
207
+ (before.keys & after.keys).each do |value|
208
+ compare_deprecation("#{name}.#{value}", before[value], after[value])
209
+ compare_description("#{name}.#{value}", before[value], after[value])
210
+ end
204
211
  end
205
212
 
206
213
  # A dropped member silently stops matching a `... on X` fragment, which
@@ -233,10 +240,22 @@ module GraphWeaver
233
240
  change(coordinate, now ? "deprecated: #{now}" : "no longer deprecated")
234
241
  end
235
242
 
236
- # The walk names what a client breaks on. A description, a directive
237
- # definition, an argument default moves the SDL without appearing
238
- # abovestill drift, and a gate that went green on it would be worse
239
- # than one that admits it can't name it.
243
+ # A description breaks nothing and reaches no generated code, but it is
244
+ # the likeliest thing to differ between a hand-maintained dump and the
245
+ # server — and unnamed it fell through to note_unnamed_drift, which is a
246
+ # permanent red with nothing to act on. The text itself isn't printed: it
247
+ # can be paragraphs, and the coordinate is what you go and look at.
248
+ def compare_description(coordinate, old, new)
249
+ return unless old.respond_to?(:description)
250
+ return if old.description == new.description
251
+
252
+ change(coordinate, "description changed")
253
+ end
254
+
255
+ # The walk names what a client breaks on. A directive definition or an
256
+ # argument default moves the SDL without appearing above — still drift,
257
+ # and a gate that went green on it would be worse than one that admits
258
+ # it can't name it.
240
259
  def note_unnamed_drift(before, after)
241
260
  return unless @changes.empty?
242
261
  return if before.to_definition == after.to_definition
@@ -199,10 +199,21 @@ module GraphWeaver::SchemaLoader
199
199
  COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
200
200
  private_constant :COMPOSITION_SPEC
201
201
 
202
- # A composed Fed2 supergraph is marked by @join__* directives (every merged
203
- # type carries them); a plain schema has none.
202
+ # A composed Fed2 supergraph is marked by @join__* directives APPLIED to
203
+ # its types (every merged type carries them); a plain schema applies none.
204
+ #
205
+ # Applied, not merely declared: introspect a router and graphql-ruby hands
206
+ # back the API schema with the @join__* directive DEFINITIONS still in it
207
+ # and every application gone — so a bare substring test answers "composed"
208
+ # about the one artifact whose routing table has been removed.
209
+ JOIN_DEFINITION = /\bdirective\s+@join__\w+/
210
+ private_constant :JOIN_DEFINITION
211
+
204
212
  def self.federation_sdl?(sdl)
205
- sdl.match?(/@join__\w/) || sdl.match?(COMPOSITION_SPEC)
213
+ return true if sdl.match?(COMPOSITION_SPEC)
214
+ return false unless sdl.match?(/@join__\w/)
215
+
216
+ sdl.gsub(JOIN_DEFINITION, "").match?(/@join__\w/)
206
217
  end
207
218
 
208
219
  # The federation spec a fed-2 subgraph @links, and the directives a fed-1
@@ -720,12 +731,13 @@ module GraphWeaver::SchemaLoader
720
731
  schema = build_introspection(result)
721
732
 
722
733
  if cache
723
- # the extension picks the format: .json is the verbatim wire
724
- # artifact; .graphql/.gql is SDL human-readable, PR-reviewable
725
- # diffs (both generate byte-identical code)
734
+ # the extension picks the format: .json is the wire artifact, pretty-
735
+ # printed so a refresh diffs line by line (graphql-client's dump is
736
+ # too); .graphql/.gql is SDL — human-readable, PR-reviewable diffs
737
+ # (both generate byte-identical code)
726
738
  meta = stamp(transport, auth_env)
727
739
  content = if cache.end_with?(".json")
728
- JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
740
+ JSON.pretty_generate(meta ? result.merge("graph_weaver" => meta) : result)
729
741
  else
730
742
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
731
743
  "#{header}#{schema.to_definition}"
@@ -887,9 +899,10 @@ module GraphWeaver::SchemaLoader
887
899
  return recompose_hint(path) if composed_dump?(path)
888
900
 
889
901
  missing = path ? "#{path} records no source url" : "no schema dump at #{GraphWeaver.schema_path}"
890
- "#{missing} — pass one: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql " \
891
- "(if this app serves the schema itself, point GraphWeaver.client at the class and the dump is " \
892
- "rebuilt from it see docs/getting_started.md#your-apps-own-schema-in-process)"
902
+ "#{missing} — pass one: rake graph_weaver:schema:refresh " \
903
+ "URL=https://api.example.com/graphql, or point the graph's client at the server an app " \
904
+ "that serves the schema itself points GraphWeaver.client at the class and the dump is built " \
905
+ "from that (docs/getting_started.md#your-apps-own-schema-in-process)"
893
906
  end
894
907
  private_class_method :refresh_hint
895
908
 
@@ -908,13 +921,14 @@ module GraphWeaver::SchemaLoader
908
921
  # Composition is the only thing that rebuilds a supergraph: introspection
909
922
  # answers with the API schema, which is the merged shape minus the routing
910
923
  # table, so refreshing one from a url replaces the contract with a strictly
911
- # smaller artifact and reports success.
924
+ # smaller artifact and reports success. Public so `schema:refresh` can step
925
+ # over such a graph in these words rather than reaching the overwrite and
926
+ # relying on the guard to catch it.
912
927
  def self.recompose_hint(path)
913
928
  "#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
914
929
  "the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
915
930
  "and check the result in, instead of refreshing it"
916
931
  end
917
- private_class_method :recompose_hint
918
932
 
919
933
  # A transport to the dump's recorded url, authenticated from whichever ENV
920
934
  # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
@@ -922,11 +936,9 @@ module GraphWeaver::SchemaLoader
922
936
  # ends up honoured in some places and not others.
923
937
  def self.source_transport(path)
924
938
  meta = provenance(path)
925
- unless meta&.key?("url")
926
- raise GraphWeaver::Error,
927
- "#{path} records no source url — it wasn't introspected from one. Pass transport:, " \
928
- "or rebuild it from the schema class that produced it."
929
- end
939
+ # the same sentence refresh! gives: this is reached by typing a rake task
940
+ # (schema:diff), and `transport:` is not something a rake user can pass
941
+ raise GraphWeaver::Error, refresh_hint(path) unless meta&.key?("url")
930
942
 
931
943
  GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
932
944
  end
@@ -170,6 +170,27 @@ module GraphWeaver
170
170
  }.filter_map { |kind, names| " #{kind}: #{names.sort.join(", ")}" if names.any? }
171
171
  end
172
172
 
173
+ # What `queries:check` just answered. It re-introspects the source behind
174
+ # a dump and asks a live class directly; a graph with neither it checks
175
+ # as it stands on disk, which is `verify`'s question — and both exited 0
176
+ # saying "against the schema".
177
+ def self.validated
178
+ dumps = GraphWeaver.graphs.filter_map { |graph| as_committed(graph) }
179
+ return "every query validates against the schema" if dumps.empty?
180
+
181
+ "every query validates against #{dumps.join(", ")} as committed — not the server " \
182
+ "(rake graph_weaver:schema:diff asks whether the server moved)"
183
+ end
184
+
185
+ # A graph whose queries were checked against the file rather than the
186
+ # server: nothing behind the dump to re-read, which is what a nil
187
+ # Graph#source says.
188
+ def self.as_committed(graph)
189
+ path = graph.dump_path
190
+ GraphWeaver::Internal::Util.relative(path) if path && graph.source.nil?
191
+ end
192
+ private_class_method :as_committed
193
+
173
194
  # Neither task that needs the committed dump can take one itself, so both
174
195
  # say which task can — the same sentence SchemaLoader gives on refresh.
175
196
  def self.no_dump
@@ -184,7 +205,25 @@ module GraphWeaver
184
205
  # rewrites it from the graph's source, `diff` says how far that source
185
206
  # has drifted from it, whichever the source is. A graph whose schema IS
186
207
  # a live class reads no dump at all, so it has neither.
187
- def self.dumps = GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source] }
208
+ #
209
+ # The dump's own record of where it came from first, then the client the
210
+ # graph's modules already call: a dump inherited with no provenance, or a
211
+ # schema kept by hand, still has a server behind it, and refusing one
212
+ # took every other graph's refresh down with it.
213
+ def self.dumps
214
+ GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.source] }
215
+ end
216
+
217
+ # A dump with no recorded url whose graph names no server: nothing
218
+ # behind the file to re-read, so the file is the schema. One shape, one
219
+ # diagnosis — :refresh steps over it and :diff can't assert anything
220
+ # about it, and they used to describe it in two different sentences and
221
+ # disagree about whose whole run it ended.
222
+ def self.no_source(path)
223
+ "#{GraphWeaver::Internal::Util.relative(path)} records no source url and the graph names " \
224
+ "no server behind it — no client posting to one, and no graphql-ruby schema class in " \
225
+ "this process — so the file is the schema and nothing here can re-read it"
226
+ end
188
227
 
189
228
  # A graph that generates straight from a schema class has no dump
190
229
  # between the code and the output — so there is nothing here to
@@ -340,19 +379,31 @@ namespace :graph_weaver do
340
379
 
341
380
  desc "Fail when the schema behind the dump has drifted from it"
342
381
  task diff: :own_schema do
343
- subjects = GraphWeaver::Internal::Tasks.dumps
344
- abort GraphWeaver::Internal::Tasks.no_dump if subjects.none? { |_, path, _| path }
345
-
346
- stale = subjects.filter_map do |graph, path, source|
382
+ # One rule per graph, and no graph's verdict depends on what the others
383
+ # have: the "no dump anywhere" abort used to run before the loop, so a
384
+ # lone live-class graph was refused naming a path it never mentions
385
+ # while the same graph passed as soon as a sibling had a dump.
386
+ ungated = []
387
+ stale = GraphWeaver::Internal::Tasks.dumps.filter_map do |graph, path, source|
347
388
  heading = GraphWeaver::Internal::Tasks.heading(graph)
348
389
  puts heading if heading
349
- # a graph that names a live class generates straight from it: no
350
- # dump between the code and the output, so nothing can be stale
351
- next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) unless path
390
+ unless path
391
+ # a graph that names a live class generates straight from it: no
392
+ # dump between the code and the output, so nothing can be stale.
393
+ # One whose dump is merely missing has something this gate can't see.
394
+ puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source)
395
+ ungated << graph if graph.named_dump_path || !source
396
+ next
397
+ end
398
+ unless source
399
+ # the shape :refresh steps over, in :refresh's own words — this task
400
+ # still fails on it, because a gate cannot assert nothing
401
+ puts GraphWeaver::Internal::Tasks.no_source(path)
402
+ ungated << graph
403
+ next
404
+ end
352
405
 
353
- # a schema class answers introspection itself; left nil, diff builds
354
- # the dump's own transport, auth and all
355
- diff = GraphWeaver::SchemaLoader.diff(path, transport: (source if source.is_a?(Module)))
406
+ diff = GraphWeaver::SchemaLoader.diff(path, transport: graph.source_transport)
356
407
  dump = GraphWeaver::Internal::Util.relative(path)
357
408
  next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
358
409
 
@@ -367,6 +418,12 @@ namespace :graph_weaver do
367
418
  abort "#{stale.join(", ")} is stale — the schema behind it has drifted " \
368
419
  "(rake graph_weaver:schema:refresh)"
369
420
  end
421
+ # a gate that passes on having compared nothing is worse than one that
422
+ # admits it: the sentence above says which graph and why
423
+ unless ungated.empty?
424
+ abort "#{GraphWeaver::Internal::Tasks.whose(ungated)}nothing here could be compared — " \
425
+ "this run gated nothing"
426
+ end
370
427
  rescue GraphWeaver::Error => e
371
428
  # e.g. a dump that is its own source — same clean exit as :refresh
372
429
  abort e.message
@@ -387,7 +444,10 @@ namespace :graph_weaver do
387
444
  next puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{source}"
388
445
  end
389
446
 
390
- GraphWeaver::Internal::Tasks.dumps.each do |graph, path, source|
447
+ # Every graph is its own job: one unreachable server used to end the run
448
+ # with the graphs after it never attempted, so an app learned about them
449
+ # one per run. The exit code is the only thing they share.
450
+ failed = GraphWeaver::Internal::Tasks.dumps.filter_map do |graph, path, source|
391
451
  heading = GraphWeaver::Internal::Tasks.heading(graph)
392
452
  puts heading if heading
393
453
  # a graph that names a live class generates straight from it — no
@@ -398,10 +458,28 @@ namespace :graph_weaver do
398
458
  # below and refresh! bootstraps its first dump (or says how).
399
459
  path ||= graph.named_dump_path
400
460
  next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
461
+ # composition is the only thing that rebuilds a supergraph, so this
462
+ # graph is one to step over however good its client is — introspecting
463
+ # the router answers with the API schema, which routes nothing
464
+ next puts GraphWeaver::SchemaLoader.recompose_hint(path) if graph.supergraph
465
+ next puts GraphWeaver::Internal::Tasks.no_source(path) if path && !source
466
+
467
+ begin
468
+ written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
469
+ schema: (source if source.is_a?(Module)), path:)
470
+ puts "refreshed #{GraphWeaver::Internal::Util.relative(written)} from #{from}"
471
+ nil
472
+ rescue GraphWeaver::Error => e
473
+ puts e.message
474
+ graph
475
+ end
476
+ end
401
477
 
402
- written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
403
- schema: (source if source.is_a?(Module)), path:)
404
- puts "refreshed #{GraphWeaver::Internal::Util.relative(written)} from #{from}"
478
+ # abort writes to unbuffered stderr; the reports above went to
479
+ # block-buffered stdout, so a piped CI log shows them first
480
+ $stdout.flush
481
+ unless failed.empty?
482
+ abort "#{GraphWeaver::Internal::Tasks.whose(failed)}not refreshed — the dump on disk is unchanged"
405
483
  end
406
484
  rescue GraphWeaver::Error => e
407
485
  abort e.message
@@ -425,7 +503,11 @@ namespace :graph_weaver do
425
503
  # block-buffered stdout, so a piped CI log shows the verdict first
426
504
  $stdout.flush
427
505
  abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
428
- puts "every query validates against the schema"
506
+ puts GraphWeaver::Internal::Tasks.validated
507
+ rescue GraphWeaver::Error => e
508
+ # this task reaches a server now, so an unreachable one is a condition to
509
+ # report — the same clean exit :diff and :refresh give
510
+ abort e.message
429
511
  end
430
512
  end
431
513
 
@@ -86,12 +86,14 @@ require_relative "../parsing"
86
86
  #
87
87
  # FakeClient.new(schema:, corrupt: "Person.birthday")
88
88
  #
89
- # null_chance: how often a nullable field comes back null — 0 by default,
90
- # and per fake only: "does this render with no email" is one example's
91
- # question, and a suite-wide answer would sprinkle nils through every other
92
- # example instead.
89
+ # null_chance: how often a nullable field comes back null — a number from 0
90
+ # to 1 for all of them, or a Hash per field keyed the way list_size: is. 0
91
+ # by default, and per fake only: "does this render with no email" is one
92
+ # example's question, and a suite-wide answer would sprinkle nils through
93
+ # every other example instead.
93
94
  #
94
95
  # FakeClient.new(schema:, null_chance: 1.0) # everything nullable, null
96
+ # FakeClient.new(schema:, null_chance: { "Person.nickname" => 1.0, default: 0 })
95
97
  #
96
98
  # list_size: how long an unbounded list is — an Integer exactly, a Range
97
99
  # randomized within it, and a Hash per list, keyed the way a pin is (a
@@ -137,12 +139,6 @@ class GraphWeaver::Testing::FakeClient
137
139
  null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
138
140
  }.freeze
139
141
 
140
- # JSON's own types are already on the wire: at a leaf they skip the
141
- # registry's serializer (Values#wire), and at a composite position (a Hash
142
- # aside, which is response keys) they pin the field as written — nil is
143
- # null, the rest is the corrupt payload the example asked for.
144
- WIRE = GraphWeaver::Internal::Values::WIRE
145
-
146
142
  # Methods every Ruby object answers aren't fields: a schema does have a
147
143
  # `hash` or a `count`, and a Struct answers both with plausible nonsense
148
144
  # where fabricating is right.
@@ -151,7 +147,7 @@ class GraphWeaver::Testing::FakeClient
151
147
  # The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
152
148
  # holds them.
153
149
  STRING_SCALARS = %w[ID String].freeze
154
- private_constant :OPTIONS, :WIRE, :RUBY_OWN, :STRING_SCALARS
150
+ private_constant :OPTIONS, :RUBY_OWN, :STRING_SCALARS
155
151
 
156
152
  def initialize(pins = {}, **options)
157
153
  config = GraphWeaver::Testing.config
@@ -173,10 +169,10 @@ class GraphWeaver::Testing::FakeClient
173
169
  @registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
174
170
  @values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
175
171
  pins: @overrides, schema: @schema, registry: @registry)
176
- @list_size = options[:list_size] || config.list_size
177
- @list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
172
+ @list_size = stringify(options[:list_size] || config.list_size)
178
173
  GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
179
- @null_chance = options[:null_chance] || 0.0
174
+ @null_chance = stringify(options[:null_chance] || 0.0)
175
+ GraphWeaver::Internal::Overrides.validate_null_chance!(@schema, @null_chance)
180
176
  # NOT Array(): it would explode a bare Hash into key/value pairs
181
177
  @extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
182
178
  @fail_at = wrap(options[:fail_at]).map { |spec| normalize_fail_spec(spec) }
@@ -467,7 +463,9 @@ class GraphWeaver::Testing::FakeClient
467
463
  # reads them off — a FactoryBot build, a model, a Struct. Either way it
468
464
  # MERGES: what it doesn't answer is fabricated.
469
465
  def pinned_object(type, selections, value, source)
470
- return value if !value.is_a?(Hash) && wire?(value)
466
+ # what JSON already holds is the pin as written — nil is null, and the rest
467
+ # is the corrupt payload the example asked for (a Hash is response keys)
468
+ return value if !value.is_a?(Hash) && GraphWeaver::Internal::Values.wire?(value)
471
469
 
472
470
  concrete = pinned_type(type, value, source)
473
471
  pins = value.is_a?(Hash) ? value : read_fields(concrete, selections, value)
@@ -497,8 +495,6 @@ class GraphWeaver::Testing::FakeClient
497
495
  object.respond_to?(name) && !RUBY_OWN.include?(object.method(name).owner)
498
496
  end
499
497
 
500
- def wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
501
-
502
498
  # An object pin holds Ruby values — a Time, a Money, a T::Enum — where the
503
499
  # wire holds what the registration says they serialize to. A value that
504
500
  # is already wire-shaped is taken as written.
@@ -618,7 +614,7 @@ class GraphWeaver::Testing::FakeClient
618
614
  return [capped, 0].max if capped.is_a?(Integer)
619
615
  return 0 if errors_list?(node.name)
620
616
 
621
- size = list_size_for(coordinate, node.name)
617
+ size = per_field(@list_size, coordinate, node.name, GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
622
618
  # an Integer list_size means exactly that many; a Range randomizes within it
623
619
  size.is_a?(Range) ? rng.rand(size) : size
624
620
  end
@@ -630,28 +626,29 @@ class GraphWeaver::Testing::FakeClient
630
626
  # until it is pinned. Pin it to fabricate the failure path.
631
627
  def errors_list?(name) = name.downcase.end_with?("errors")
632
628
 
633
- # How long an unbounded list is. A Hash says it per list, read most
634
- # specific first like a pin — which is what keeps nested lists from
635
- # multiplying: every list the walk reaches re-reads this, so one number
636
- # for all of them is n rows x n tags.
637
- def list_size_for(coordinate, name)
638
- return @list_size unless @list_size.is_a?(Hash)
639
-
640
- @list_size.fetch(coordinate) do
641
- @list_size.fetch(name) do
642
- @list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
643
- GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
644
- end
629
+ # What a per-field option (list_size:, null_chance:) says here. A Hash says
630
+ # it per field, read most specific first like a pin — which is what keeps
631
+ # nested lists from multiplying: every list the walk reaches re-reads
632
+ # list_size, so one number for all of them is n rows x n tags.
633
+ def per_field(option, coordinate, name, fallback)
634
+ return option unless option.is_a?(Hash)
635
+
636
+ option.fetch(coordinate) do
637
+ option.fetch(name) { option.fetch(GraphWeaver::Internal::Overrides::DEFAULT_KEY, fallback) }
645
638
  end
646
639
  end
647
640
 
641
+ # A per-field option's keys are GraphQL names, written as either a String
642
+ # or a Symbol (`default:` most of all).
643
+ def stringify(option) = option.is_a?(Hash) ? option.transform_keys(&:to_s) : option
644
+
648
645
  def type_value(type, node, selections, coordinate: nil, non_null: false)
649
646
  if type.kind.name == "NON_NULL"
650
647
  return type_value(type.of_type, node, selections, coordinate:, non_null: true)
651
648
  end
652
649
  # every nullable position, a list included — null_chance is about the
653
650
  # nilable props codegen emitted, and it emits one for `[Thing!]` too
654
- return if !non_null && rng.rand < @null_chance
651
+ return if !non_null && rng.rand < per_field(@null_chance, coordinate, node.name, 0.0)
655
652
 
656
653
  case type.kind.name
657
654
  when "LIST"