graph_weaver 0.7.5 → 0.7.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  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 +43 -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 +67 -6
  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 +23 -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/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. metadata +2 -2
@@ -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 client — left 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.7"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -219,9 +219,15 @@ module GraphWeaver
219
219
  # from where you put the files: set it globally, or pass types_module: per
220
220
  # generate!. A multi-schema layout names it in the same initializer that
221
221
  # sets its paths.
222
+ #
223
+ # Unset, the default is whichever name is free where the constants land:
224
+ # GraphQLTypes at the top level, where a bare ::Types would collide with
225
+ # graphql-ruby's own Types:: convention, and plain Types inside a graph's
226
+ # namespace, where it can't — which is what leaves types.rb a file Zeitwerk
227
+ # names correctly (see Graph#types_module).
222
228
  attr_writer :types_module
223
229
 
224
- def types_module = @types_module || "GraphQLTypes"
230
+ def types_module(default = "GraphQLTypes") = @types_module || default
225
231
 
226
232
  # Declare a second schema — and a third, and the rest. Everything a graph
227
233
  # knows is said inside its block, in call style:
@@ -614,8 +620,8 @@ module GraphWeaver
614
620
  # # "line" => 4, "column" => 5 }] }
615
621
  #
616
622
  # 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
623
+ # it is now — a FRESH introspection of the source behind each graph's
624
+ # dump, or the live schema class where that source IS one — and the dump
619
625
  # is left alone; pass schema: and nothing touches the network.
620
626
  #
621
627
  # When that dump is a composed supergraph, an error naming a type is
@@ -623,17 +629,25 @@ module GraphWeaver
623
629
  # (products, reviews)", plus a "subgraphs" key — since knowing whose
624
630
  # code to look at is half the answer. A plain schema is unaffected.
625
631
  #
626
- # One query you have as a *string* is Client#check_query — the same
627
- # entries, against that client's own schema.
632
+ # One query you have as a *string* is #check_query, which every client
633
+ # holding a schema answers — the same entries, against that schema.
628
634
  #
629
635
  # A different question from verify_generated!, which asks whether the
630
636
  # committed Ruby matches the committed schema. `rake
631
637
  # graph_weaver:queries:check` prints this and exits non-zero.
632
638
  def check_queries(schema: nil, queries: nil, fragments: fragments_paths)
633
639
  shared = Codegen.load_fragments(fragments)
634
-
635
- graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
636
- checked = checked_schema(graph)
640
+ # one graph's server being down is that graph's verdict: every graph is
641
+ # attempted, so a run names every server it couldn't reach rather than
642
+ # ending at the first
643
+ unreachable = []
644
+
645
+ failures = graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
646
+ begin
647
+ checked = checked_schema(graph)
648
+ rescue Error => e
649
+ next unreachable << unreachable_source(graph, e)
650
+ end
637
651
  table = checked_routing_table(graph)
638
652
  Internal::Util.query_files(graph.queries).each do |path|
639
653
  errors = Internal::QueryCheck.errors(checked, File.read(path), shared, table)
@@ -646,13 +660,39 @@ module GraphWeaver
646
660
  failures[key] = failures.key?(key) ? failures[key] | errors : errors
647
661
  end
648
662
  end
663
+
664
+ raise Error, unreachable.join("\n") unless unreachable.empty?
665
+
666
+ failures
649
667
  end
650
668
 
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
669
+ # Which graph couldn't be checked, and against what. The bare socket error
670
+ # named neither — in a multi-graph app it was the entire output, and the
671
+ # reader could not tell which of their servers was down.
672
+ def unreachable_source(graph, error)
673
+ whose = graph.name ? "graph #{graph.name.inspect}" : "this app"
674
+ dump = graph.dump_path || graph.named_dump_path
675
+ subject = dump ? Internal::Util.relative(dump) : "these queries"
676
+ "#{whose}: couldn't reach the schema behind #{subject} — #{error.message}"
677
+ end
678
+ private :unreachable_source
679
+
680
+ # What this graph is checked against: Graph#source, the rule `schema:refresh`
681
+ # and `schema:diff` follow too. A live class is asked directly — for an app
682
+ # that IS the server, a dump is a snapshot of its own code, and checking
683
+ # against it reports phantom errors about a field just added. A url is
684
+ # re-introspected, so no refresh step (and no rewritten dump) is needed
685
+ # first. A graph with neither — hand-written SDL nothing serves, a composed
686
+ # supergraph — has nothing to re-read, so it is checked as committed.
687
+ def checked_schema(graph)
688
+ source = graph.source
689
+ return source if source.is_a?(Module)
690
+ # Internal::Util.locate_schema! raises the conventional "no schema dump"
691
+ # message when the default graph has no dump either
692
+ return graph.schema unless source
693
+
694
+ SchemaLoader.introspect(graph.source_transport)
695
+ end
656
696
  private :checked_schema
657
697
 
658
698
  # The routing table behind the schema check_queries is about to use, when
@@ -663,40 +703,19 @@ module GraphWeaver
663
703
  end
664
704
  private :checked_routing_table
665
705
 
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
706
  # Load the generated modules — one line in an initializer or spec
689
707
  # helper (loading happens only when you call this; skip it and
690
708
  # require files yourself if you'd rather):
691
709
  #
692
710
  # GraphWeaver.load_generated!
693
711
  #
694
- # In Rails, prefer this over autoloading: Zeitwerk would expect
695
- # Generated::PersonQuery from generated/person_query.rb, and
696
- # generated code only changes on regeneration anyway (restart, like
697
- # a schema migration).
712
+ # In Rails this is automatic, and a tree Zeitwerk already names correctly
713
+ # is skipped: it autoloads on first reference (see the Railtie). Everything
714
+ # else is required — Zeitwerk would expect Generated::PersonQuery from
715
+ # generated/person_query.rb, and generated code only changes on
716
+ # regeneration anyway (restart, like a schema migration).
698
717
  def load_generated!(path = nil)
699
- paths = path ? [path] : Internal::Util.generated_dirs
718
+ paths = path ? [path] : Internal::Util.required_dirs
700
719
  files = paths.flat_map { |dir| Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].sort }.uniq
701
720
  files.each do |file|
702
721
  require file
@@ -749,7 +768,9 @@ module GraphWeaver
749
768
  # whose query was just deleted keeps its old constant until restart —
750
769
  # nothing on disk says what it was called any more.
751
770
  def reload_generated!
752
- names = graphs.flat_map do |graph|
771
+ # a graph Zeitwerk owns unloads and reloads itself; undefining its
772
+ # constants here would only take them out from under it
773
+ names = graphs.reject { |graph| Internal::Util.left_to_zeitwerk?(graph.output) }.flat_map do |graph|
753
774
  modules = Internal::Util.query_files(graph.queries).map do |path|
754
775
  graph.generated_names(path, File.read(path)).first
755
776
  end
@@ -757,7 +778,7 @@ module GraphWeaver
757
778
  end
758
779
  names.each { |name| undefine(name) }
759
780
 
760
- Internal::Util.generated_dirs.each do |dir|
781
+ Internal::Util.required_dirs.each do |dir|
761
782
  Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].each do |file|
762
783
  # require stores the realpath; the path load_generated! passed is
763
784
  # the other one under a symlinked checkout
@@ -844,7 +865,7 @@ module GraphWeaver
844
865
  rescue GraphWeaver::Error => e
845
866
  # collected, not raised: nothing is written either way, and an adopter
846
867
  # aiming generate! at an existing query directory wants the list
847
- refusals << e
868
+ refusals << name_query_file(e, path)
848
869
  nil
849
870
  end
850
871
  refuse_all!(refusals, paths.size)
@@ -907,6 +928,18 @@ module GraphWeaver
907
928
  # itself, so a single bad file reads exactly as it always has — class,
908
929
  # message and all; several become one list, because clearing them a file
909
930
  # per run is the slowest way there is to adopt this.
931
+ # Almost every codegen refusal is about the query in front of it and never
932
+ # says which file that was — so a list of them named nothing, which is the
933
+ # one thing a list is for. This rescue is the only place holding both.
934
+ # #exception keeps the class and its fields and swaps only the message.
935
+ def name_query_file(error, path)
936
+ reported = Internal::Util.relative(path)
937
+ return error if error.message.include?(reported)
938
+
939
+ error.exception("#{reported}: #{error.message}")
940
+ end
941
+ private :name_query_file
942
+
910
943
  def refuse_all!(refusals, considered)
911
944
  return if refusals.empty?
912
945
  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.7
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.7/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