graph_weaver 0.7.5 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +10 -6
  5. data/docs/getting_started.md +34 -17
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +25 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +55 -5
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +6 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/rspec.rb +41 -17
  39. data/lib/graph_weaver/schema_diff.rb +24 -5
  40. data/lib/graph_weaver/schema_loader.rb +22 -9
  41. data/lib/graph_weaver/tasks.rb +71 -44
  42. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  43. data/lib/graph_weaver/testing.rb +27 -7
  44. data/lib/graph_weaver/transport.rb +1 -1
  45. data/lib/graph_weaver/version.rb +1 -1
  46. data/lib/graph_weaver.rb +59 -35
  47. metadata +2 -2
@@ -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}"
@@ -909,13 +921,14 @@ module GraphWeaver::SchemaLoader
909
921
  # Composition is the only thing that rebuilds a supergraph: introspection
910
922
  # answers with the API schema, which is the merged shape minus the routing
911
923
  # table, so refreshing one from a url replaces the contract with a strictly
912
- # 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.
913
927
  def self.recompose_hint(path)
914
928
  "#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
915
929
  "the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
916
930
  "and check the result in, instead of refreshing it"
917
931
  end
918
- private_class_method :recompose_hint
919
932
 
920
933
  # A transport to the dump's recorded url, authenticated from whichever ENV
921
934
  # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
@@ -170,9 +170,9 @@ 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 url a dump
174
- # records and asks a live class directly; anything else it checks as it
175
- # stands on disk, which is `verify`'s question — and both exited 0
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
176
  # saying "against the schema".
177
177
  def self.validated
178
178
  dumps = GraphWeaver.graphs.filter_map { |graph| as_committed(graph) }
@@ -183,14 +183,11 @@ module GraphWeaver
183
183
  end
184
184
 
185
185
  # A graph whose queries were checked against the file rather than the
186
- # server: a graph that names its own schema is checked against exactly
187
- # that, and a dump with no recorded url has nothing to re-read.
186
+ # server: nothing behind the dump to re-read, which is what a nil
187
+ # Graph#source says.
188
188
  def self.as_committed(graph)
189
189
  path = graph.dump_path
190
- return unless path && graph.live_schema.nil?
191
- return if !graph.named_schema? && GraphWeaver::SchemaLoader.provenance(path)&.key?("url")
192
-
193
- GraphWeaver::Internal::Util.relative(path)
190
+ GraphWeaver::Internal::Util.relative(path) if path && graph.source.nil?
194
191
  end
195
192
  private_class_method :as_committed
196
193
 
@@ -214,16 +211,18 @@ module GraphWeaver
214
211
  # schema kept by hand, still has a server behind it, and refusing one
215
212
  # took every other graph's refresh down with it.
216
213
  def self.dumps
217
- GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source || graph.client_url] }
214
+ GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.source] }
218
215
  end
219
216
 
220
- # A dump with no recorded url whose graph names no client: nothing
221
- # behind the file to re-read, so the file is the schema. Said and
222
- # stepped over rather than refused the task's job is the graphs it
223
- # CAN refresh.
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.
224
222
  def self.no_source(path)
225
223
  "#{GraphWeaver::Internal::Util.relative(path)} records no source url and the graph names " \
226
- "no clientleft as checked in"
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"
227
226
  end
228
227
 
229
228
  # A graph that generates straight from a schema class has no dump
@@ -242,19 +241,6 @@ module GraphWeaver
242
241
  "#{whose} generates from #{source_name(source)} directly — no dump to keep in step"
243
242
  end
244
243
 
245
- # What `diff` re-introspects for one graph. A schema class answers
246
- # introspection itself. A url the dump recorded is left to
247
- # SchemaLoader.diff, which builds the transport and so honours the
248
- # auth_env the dump named. A source that came from the graph's client
249
- # instead is that client's own transport — headers, auth and all.
250
- def self.diff_transport(graph, source)
251
- return source if source.is_a?(Module)
252
- return if graph.dump_source
253
-
254
- client = graph.client || GraphWeaver.client
255
- client.respond_to?(:transport) ? client.transport : client
256
- end
257
-
258
244
  # How a dump's source reads in a report: a url as itself, a schema
259
245
  # class by name.
260
246
  def self.source_name(source) = source.is_a?(Module) ? GraphWeaver::SchemaLoader.endpoint(source) : source
@@ -393,18 +379,31 @@ namespace :graph_weaver do
393
379
 
394
380
  desc "Fail when the schema behind the dump has drifted from it"
395
381
  task diff: :own_schema do
396
- subjects = GraphWeaver::Internal::Tasks.dumps
397
- abort GraphWeaver::Internal::Tasks.no_dump if subjects.none? { |_, path, _| path }
398
-
399
- 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|
400
388
  heading = GraphWeaver::Internal::Tasks.heading(graph)
401
389
  puts heading if heading
402
- # a graph that names a live class generates straight from it: no
403
- # dump between the code and the output, so nothing can be stale
404
- 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
405
405
 
406
- diff = GraphWeaver::SchemaLoader.diff(path,
407
- transport: GraphWeaver::Internal::Tasks.diff_transport(graph, source))
406
+ diff = GraphWeaver::SchemaLoader.diff(path, transport: graph.source_transport)
408
407
  dump = GraphWeaver::Internal::Util.relative(path)
409
408
  next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
410
409
 
@@ -419,6 +418,12 @@ namespace :graph_weaver do
419
418
  abort "#{stale.join(", ")} is stale — the schema behind it has drifted " \
420
419
  "(rake graph_weaver:schema:refresh)"
421
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
422
427
  rescue GraphWeaver::Error => e
423
428
  # e.g. a dump that is its own source — same clean exit as :refresh
424
429
  abort e.message
@@ -439,7 +444,10 @@ namespace :graph_weaver do
439
444
  next puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{source}"
440
445
  end
441
446
 
442
- 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|
443
451
  heading = GraphWeaver::Internal::Tasks.heading(graph)
444
452
  puts heading if heading
445
453
  # a graph that names a live class generates straight from it — no
@@ -450,13 +458,28 @@ namespace :graph_weaver do
450
458
  # below and refresh! bootstraps its first dump (or says how).
451
459
  path ||= graph.named_dump_path
452
460
  next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
453
- # a supergraph has no source by construction nothing serves one —
454
- # and refresh! answers that with "recompose", which is not a skip
455
- next puts GraphWeaver::Internal::Tasks.no_source(path) if path && !source && !graph.supergraph
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
456
477
 
457
- written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
458
- schema: (source if source.is_a?(Module)), path:)
459
- 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"
460
483
  end
461
484
  rescue GraphWeaver::Error => e
462
485
  abort e.message
@@ -481,6 +504,10 @@ namespace :graph_weaver do
481
504
  $stdout.flush
482
505
  abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
483
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
484
511
  end
485
512
  end
486
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
@@ -167,10 +169,10 @@ class GraphWeaver::Testing::FakeClient
167
169
  @registry = options[:registry] || GraphWeaver::Internal::Util.registry_for(@schema)
168
170
  @values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
169
171
  pins: @overrides, schema: @schema, registry: @registry)
170
- @list_size = options[:list_size] || config.list_size
171
- @list_size = @list_size.transform_keys(&:to_s) if @list_size.is_a?(Hash)
172
+ @list_size = stringify(options[:list_size] || config.list_size)
172
173
  GraphWeaver::Internal::Overrides.validate_list_size!(@schema, @list_size)
173
- @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)
174
176
  # NOT Array(): it would explode a bare Hash into key/value pairs
175
177
  @extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
176
178
  @fail_at = wrap(options[:fail_at]).map { |spec| normalize_fail_spec(spec) }
@@ -612,7 +614,7 @@ class GraphWeaver::Testing::FakeClient
612
614
  return [capped, 0].max if capped.is_a?(Integer)
613
615
  return 0 if errors_list?(node.name)
614
616
 
615
- size = list_size_for(coordinate, node.name)
617
+ size = per_field(@list_size, coordinate, node.name, GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
616
618
  # an Integer list_size means exactly that many; a Range randomizes within it
617
619
  size.is_a?(Range) ? rng.rand(size) : size
618
620
  end
@@ -624,28 +626,29 @@ class GraphWeaver::Testing::FakeClient
624
626
  # until it is pinned. Pin it to fabricate the failure path.
625
627
  def errors_list?(name) = name.downcase.end_with?("errors")
626
628
 
627
- # How long an unbounded list is. A Hash says it per list, read most
628
- # specific first like a pin — which is what keeps nested lists from
629
- # multiplying: every list the walk reaches re-reads this, so one number
630
- # for all of them is n rows x n tags.
631
- def list_size_for(coordinate, name)
632
- return @list_size unless @list_size.is_a?(Hash)
633
-
634
- @list_size.fetch(coordinate) do
635
- @list_size.fetch(name) do
636
- @list_size.fetch(GraphWeaver::Internal::Overrides::LIST_SIZE_DEFAULT,
637
- GraphWeaver::Testing::Config::DEFAULT_LIST_SIZE)
638
- 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) }
639
638
  end
640
639
  end
641
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
+
642
645
  def type_value(type, node, selections, coordinate: nil, non_null: false)
643
646
  if type.kind.name == "NON_NULL"
644
647
  return type_value(type.of_type, node, selections, coordinate:, non_null: true)
645
648
  end
646
649
  # every nullable position, a list included — null_chance is about the
647
650
  # nilable props codegen emitted, and it emits one for `[Thing!]` too
648
- return if !non_null && rng.rand < @null_chance
651
+ return if !non_null && rng.rand < per_field(@null_chance, coordinate, node.name, 0.0)
649
652
 
650
653
  case type.kind.name
651
654
  when "LIST"
@@ -340,10 +340,14 @@ module GraphWeaver
340
340
  "Declare it with the class: GraphWeaver.graph(#{graph.name.inspect}) " \
341
341
  "{ schema -> { MySchema } }."
342
342
  else
343
+ # the helper needs no tag, which is what makes it reachable from
344
+ # BOTH paths into here: a tagged example is already past its own
345
+ # before hook, so "name it in the example" only works if the tag goes
343
346
  "GraphWeaver.client isn't running one in-process to borrow. Name it in the example — " \
344
- "graphql_in_process(MySchema) — or set GraphWeaver::Testing.config.schema = MySchema " \
345
- "for the whole suite. A federated app names the subgraph it means, per example; " \
346
- "graphql: :router runs the graph stitched."
347
+ "graphql_in_process(MySchema) — instead of the graphql: :in_process tag, which " \
348
+ "builds this client in a `before` hook of its own and so is already past. Or set " \
349
+ "GraphWeaver::Testing.config.schema = MySchema for the whole suite. A federated app " \
350
+ "names the subgraph it means, per example; graphql: :router runs the graph stitched."
347
351
  end
348
352
  end
349
353
 
@@ -411,7 +415,7 @@ module GraphWeaver
411
415
  # the precision case gets exercised at all.
412
416
  def check_scalars!(schema)
413
417
  registry = Internal::Util.registry_for(schema)
414
- values = Internal::Values.new(seed: 0, schema:, registry:)
418
+ values = Internal::Values.new(seed: 0, schema:, registry:, pin_advice: method(:pin_for_check))
415
419
  context = GraphQL::Query.new(schema, "{ __typename }").context
416
420
 
417
421
  disagreed = schema.types.values.sort_by(&:graphql_name).filter_map do |type|
@@ -431,6 +435,14 @@ module GraphWeaver
431
435
 
432
436
  private
433
437
 
438
+ # check_scalars! takes the schema and nothing else, and runs outside
439
+ # every fake — so of the doors onto a pin only the suite-wide one is a
440
+ # door here. graphql_fake is a no-op, and there is no second argument.
441
+ def pin_for_check(name)
442
+ "Pin the form this server sends: GraphWeaver::Testing.config.overrides = " \
443
+ "{ #{name.inspect} => ... }."
444
+ end
445
+
434
446
  # One scalar's verdict, or nil when the two halves agree. Each step is
435
447
  # a different mistake, so each says which.
436
448
  def disagreement(scalar, type, values, context)
@@ -448,8 +460,8 @@ module GraphWeaver
448
460
  begin
449
461
  sample = cast.call(wire)
450
462
  rescue StandardError => e
451
- return "#{name}: cast: can't read #{wire.inspect}, the value fabricated for it (#{e.message}) " \
452
- "— pin the form this server sends: overrides: { #{name.inspect} => ... }"
463
+ return "#{name}: cast: can't read #{wire.inspect}, the value fabricated for it " \
464
+ "(#{e.message}). #{pin_for_check(name)}"
453
465
  end
454
466
 
455
467
  if scalar.serialize? && !scalar.serialize_value?
@@ -475,7 +487,15 @@ module GraphWeaver
475
487
  end
476
488
  return if back == sample
477
489
 
478
- "#{name}: round-trips lossily — sent #{Internal::Redact.spell(sample)}, got back #{Internal::Redact.spell(back)}"
490
+ sent = Internal::Redact.spell(sample)
491
+ got = Internal::Redact.spell(back)
492
+ return "#{name}: round-trips lossily — sent #{sent}, got back #{got}" unless sent == got
493
+
494
+ # two identical spellings for two unequal values: the class inherits
495
+ # Object#==, which is identity, so this can't tell a lossy round trip
496
+ # from a faithful one and must not claim either
497
+ "#{name}: #{sample.class} defines no ==, so a round trip can't be checked — sent " \
498
+ "#{sent} and got back #{got}, and nothing here can tell those apart"
479
499
  end
480
500
 
481
501
  # The registration's `cast:`, RUN rather than emitted. A cast builds
@@ -99,7 +99,7 @@ class GraphWeaver::Transport
99
99
  payload = { url: safe_url, operation: operation_name, client: self.class,
100
100
  kind: GraphWeaver::Internal::Wire.kind(query) }
101
101
 
102
- GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
102
+ GraphWeaver::Internal::Log.instrument_request(payload) do
103
103
  perform(query, variables, operation_name, payload)
104
104
  end
105
105
  end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.7.5"
2
+ VERSION = "0.7.6"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -614,8 +614,8 @@ module GraphWeaver
614
614
  # # "line" => 4, "column" => 5 }] }
615
615
  #
616
616
  # Empty means every query validates. schema: defaults to the server as
617
- # it is now — a FRESH introspection of the url the dump records, or the
618
- # live schema class when the app default runs in-process — and the dump
617
+ # it is now — a FRESH introspection of the source behind each graph's
618
+ # dump, or the live schema class where that source IS one — and the dump
619
619
  # is left alone; pass schema: and nothing touches the network.
620
620
  #
621
621
  # When that dump is a composed supergraph, an error naming a type is
@@ -623,17 +623,25 @@ module GraphWeaver
623
623
  # (products, reviews)", plus a "subgraphs" key — since knowing whose
624
624
  # code to look at is half the answer. A plain schema is unaffected.
625
625
  #
626
- # One query you have as a *string* is Client#check_query the same
627
- # entries, against that client's own schema.
626
+ # One query you have as a *string* is #check_query, which every client
627
+ # holding a schema answers — the same entries, against that schema.
628
628
  #
629
629
  # A different question from verify_generated!, which asks whether the
630
630
  # committed Ruby matches the committed schema. `rake
631
631
  # graph_weaver:queries:check` prints this and exits non-zero.
632
632
  def check_queries(schema: nil, queries: nil, fragments: fragments_paths)
633
633
  shared = Codegen.load_fragments(fragments)
634
-
635
- graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
636
- checked = checked_schema(graph)
634
+ # one graph's server being down is that graph's verdict: every graph is
635
+ # attempted, so a run names every server it couldn't reach rather than
636
+ # ending at the first
637
+ unreachable = []
638
+
639
+ failures = graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
640
+ begin
641
+ checked = checked_schema(graph)
642
+ rescue Error => e
643
+ next unreachable << unreachable_source(graph, e)
644
+ end
637
645
  table = checked_routing_table(graph)
638
646
  Internal::Util.query_files(graph.queries).each do |path|
639
647
  errors = Internal::QueryCheck.errors(checked, File.read(path), shared, table)
@@ -646,13 +654,39 @@ module GraphWeaver
646
654
  failures[key] = failures.key?(key) ? failures[key] | errors : errors
647
655
  end
648
656
  end
657
+
658
+ raise Error, unreachable.join("\n") unless unreachable.empty?
659
+
660
+ failures
649
661
  end
650
662
 
651
- # What this graph is checked against. A graph that names its schema is
652
- # checked against exactly that, so nothing touches the network; the default
653
- # graph names none, so its dump is re-introspected first (see
654
- # refreshed_schema).
655
- def checked_schema(graph) = graph.named_schema? ? graph.schema : refreshed_schema
663
+ # Which graph couldn't be checked, and against what. The bare socket error
664
+ # named neither in a multi-graph app it was the entire output, and the
665
+ # reader could not tell which of their servers was down.
666
+ def unreachable_source(graph, error)
667
+ whose = graph.name ? "graph #{graph.name.inspect}" : "this app"
668
+ dump = graph.dump_path || graph.named_dump_path
669
+ subject = dump ? Internal::Util.relative(dump) : "these queries"
670
+ "#{whose}: couldn't reach the schema behind #{subject} — #{error.message}"
671
+ end
672
+ private :unreachable_source
673
+
674
+ # What this graph is checked against: Graph#source, the rule `schema:refresh`
675
+ # and `schema:diff` follow too. A live class is asked directly — for an app
676
+ # that IS the server, a dump is a snapshot of its own code, and checking
677
+ # against it reports phantom errors about a field just added. A url is
678
+ # re-introspected, so no refresh step (and no rewritten dump) is needed
679
+ # first. A graph with neither — hand-written SDL nothing serves, a composed
680
+ # supergraph — has nothing to re-read, so it is checked as committed.
681
+ def checked_schema(graph)
682
+ source = graph.source
683
+ return source if source.is_a?(Module)
684
+ # Internal::Util.locate_schema! raises the conventional "no schema dump"
685
+ # message when the default graph has no dump either
686
+ return graph.schema unless source
687
+
688
+ SchemaLoader.introspect(graph.source_transport)
689
+ end
656
690
  private :checked_schema
657
691
 
658
692
  # The routing table behind the schema check_queries is about to use, when
@@ -663,28 +697,6 @@ module GraphWeaver
663
697
  end
664
698
  private :checked_routing_table
665
699
 
666
- # The schema check_queries defaults to: the server as it is now. Over a
667
- # socket that's a fresh introspection of the url the local dump recorded,
668
- # so no refresh step (and no rewritten dump) is needed first. In-process
669
- # it's the live schema class — for an app that IS the server, a dump is a
670
- # snapshot of its own code, and checking against it reports phantom
671
- # errors about a field you just added. Dumps with no url and no live
672
- # class — hand-written SDL, a composed supergraph — have nothing to
673
- # re-read, so they're checked as they are.
674
- def refreshed_schema
675
- live = Internal::Util.live_schema
676
- return live if live
677
-
678
- # Internal::Util.locate_schema! raises the conventional "no schema dump" message
679
- path = SchemaLoader.locate_path or Internal::Util.locate_schema!
680
- return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
681
-
682
- # source_transport rather than one built here: it reads the auth ENV var
683
- # the dump named, so `--auth MY_TOKEN` reaches this path too
684
- SchemaLoader.introspect(SchemaLoader.source_transport(path))
685
- end
686
- private :refreshed_schema
687
-
688
700
  # Load the generated modules — one line in an initializer or spec
689
701
  # helper (loading happens only when you call this; skip it and
690
702
  # require files yourself if you'd rather):
@@ -844,7 +856,7 @@ module GraphWeaver
844
856
  rescue GraphWeaver::Error => e
845
857
  # collected, not raised: nothing is written either way, and an adopter
846
858
  # aiming generate! at an existing query directory wants the list
847
- refusals << e
859
+ refusals << name_query_file(e, path)
848
860
  nil
849
861
  end
850
862
  refuse_all!(refusals, paths.size)
@@ -907,6 +919,18 @@ module GraphWeaver
907
919
  # itself, so a single bad file reads exactly as it always has — class,
908
920
  # message and all; several become one list, because clearing them a file
909
921
  # per run is the slowest way there is to adopt this.
922
+ # Almost every codegen refusal is about the query in front of it and never
923
+ # says which file that was — so a list of them named nothing, which is the
924
+ # one thing a list is for. This rescue is the only place holding both.
925
+ # #exception keeps the class and its fields and swaps only the message.
926
+ def name_query_file(error, path)
927
+ reported = Internal::Util.relative(path)
928
+ return error if error.message.include?(reported)
929
+
930
+ error.exception("#{reported}: #{error.message}")
931
+ end
932
+ private :name_query_file
933
+
910
934
  def refuse_all!(refusals, considered)
911
935
  return if refusals.empty?
912
936
  raise refusals.first if refusals.one?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.5
4
+ version: 0.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -364,7 +364,7 @@ licenses:
364
364
  - MIT
365
365
  metadata:
366
366
  bug_tracker_uri: https://github.com/dpep/graph_weaver/issues
367
- changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.5/CHANGELOG.md
367
+ changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.6/CHANGELOG.md
368
368
  documentation_uri: https://github.com/dpep/graph_weaver/tree/main/docs
369
369
  rubygems_mfa_required: 'true'
370
370
  source_code_uri: https://github.com/dpep/graph_weaver