graph_weaver 0.7.3 → 0.7.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +5 -2
  5. data/docs/federation.md +3 -2
  6. data/docs/generated_modules.md +176 -22
  7. data/docs/getting_started.md +174 -14
  8. data/docs/i18n.md +4 -4
  9. data/docs/migrating.md +119 -0
  10. data/docs/scalars.md +161 -35
  11. data/docs/testing.md +24 -3
  12. data/docs/upgrading.md +51 -5
  13. data/examples/github/generated/star_mutation.rb +24 -2
  14. data/examples/github/generated/stargazers_query.rb +61 -5
  15. data/examples/github/generated/starred_query.rb +33 -3
  16. data/lib/generators/graph_weaver/install_generator.rb +32 -3
  17. data/lib/graph_weaver/client.rb +23 -0
  18. data/lib/graph_weaver/codegen/aliases.rb +23 -2
  19. data/lib/graph_weaver/codegen/emit.rb +35 -16
  20. data/lib/graph_weaver/codegen/enum_type.rb +149 -19
  21. data/lib/graph_weaver/codegen/nodes.rb +72 -37
  22. data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
  23. data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
  24. data/lib/graph_weaver/codegen.rb +259 -106
  25. data/lib/graph_weaver/coerce.rb +25 -6
  26. data/lib/graph_weaver/federation.rb +1 -6
  27. data/lib/graph_weaver/graph.rb +4 -1
  28. data/lib/graph_weaver/hints.rb +23 -5
  29. data/lib/graph_weaver/in_process.rb +1 -3
  30. data/lib/graph_weaver/input_struct.rb +31 -10
  31. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  32. data/lib/graph_weaver/internal/unused.rb +32 -7
  33. data/lib/graph_weaver/internal/values.rb +12 -4
  34. data/lib/graph_weaver/internal.rb +84 -0
  35. data/lib/graph_weaver/logging.rb +26 -29
  36. data/lib/graph_weaver/query_module.rb +20 -5
  37. data/lib/graph_weaver/railtie.rb +7 -2
  38. data/lib/graph_weaver/rspec.rb +0 -1
  39. data/lib/graph_weaver/schema_loader.rb +7 -8
  40. data/lib/graph_weaver/tasks.rb +60 -5
  41. data/lib/graph_weaver/testing/fake_client.rb +4 -10
  42. data/lib/graph_weaver/testing/router.rb +26 -25
  43. data/lib/graph_weaver/testing.rb +101 -1
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +80 -74
  46. metadata +3 -2
@@ -887,9 +887,10 @@ module GraphWeaver::SchemaLoader
887
887
  return recompose_hint(path) if composed_dump?(path)
888
888
 
889
889
  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)"
890
+ "#{missing} — pass one: rake graph_weaver:schema:refresh " \
891
+ "URL=https://api.example.com/graphql, or point the graph's client at the server an app " \
892
+ "that serves the schema itself points GraphWeaver.client at the class and the dump is built " \
893
+ "from that (docs/getting_started.md#your-apps-own-schema-in-process)"
893
894
  end
894
895
  private_class_method :refresh_hint
895
896
 
@@ -922,11 +923,9 @@ module GraphWeaver::SchemaLoader
922
923
  # ends up honoured in some places and not others.
923
924
  def self.source_transport(path)
924
925
  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
926
+ # the same sentence refresh! gives: this is reached by typing a rake task
927
+ # (schema:diff), and `transport:` is not something a rake user can pass
928
+ raise GraphWeaver::Error, refresh_hint(path) unless meta&.key?("url")
930
929
 
931
930
  GraphWeaver.new(meta["url"], auth: ENV[auth_env(path)]).transport
932
931
  end
@@ -170,6 +170,30 @@ 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
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: 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.
188
+ def self.as_committed(graph)
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)
194
+ end
195
+ private_class_method :as_committed
196
+
173
197
  # Neither task that needs the committed dump can take one itself, so both
174
198
  # say which task can — the same sentence SchemaLoader gives on refresh.
175
199
  def self.no_dump
@@ -184,7 +208,23 @@ module GraphWeaver
184
208
  # rewrites it from the graph's source, `diff` says how far that source
185
209
  # has drifted from it, whichever the source is. A graph whose schema IS
186
210
  # 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] }
211
+ #
212
+ # The dump's own record of where it came from first, then the client the
213
+ # graph's modules already call: a dump inherited with no provenance, or a
214
+ # schema kept by hand, still has a server behind it, and refusing one
215
+ # took every other graph's refresh down with it.
216
+ def self.dumps
217
+ GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source || graph.client_url] }
218
+ end
219
+
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.
224
+ def self.no_source(path)
225
+ "#{GraphWeaver::Internal::Util.relative(path)} records no source url and the graph names " \
226
+ "no client — left as checked in"
227
+ end
188
228
 
189
229
  # A graph that generates straight from a schema class has no dump
190
230
  # between the code and the output — so there is nothing here to
@@ -202,6 +242,19 @@ module GraphWeaver
202
242
  "#{whose} generates from #{source_name(source)} directly — no dump to keep in step"
203
243
  end
204
244
 
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
+
205
258
  # How a dump's source reads in a report: a url as itself, a schema
206
259
  # class by name.
207
260
  def self.source_name(source) = source.is_a?(Module) ? GraphWeaver::SchemaLoader.endpoint(source) : source
@@ -350,9 +403,8 @@ namespace :graph_weaver do
350
403
  # dump between the code and the output, so nothing can be stale
351
404
  next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) unless path
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,
407
+ transport: GraphWeaver::Internal::Tasks.diff_transport(graph, source))
356
408
  dump = GraphWeaver::Internal::Util.relative(path)
357
409
  next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
358
410
 
@@ -398,6 +450,9 @@ namespace :graph_weaver do
398
450
  # below and refresh! bootstraps its first dump (or says how).
399
451
  path ||= graph.named_dump_path
400
452
  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
401
456
 
402
457
  written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
403
458
  schema: (source if source.is_a?(Module)), path:)
@@ -425,7 +480,7 @@ namespace :graph_weaver do
425
480
  # block-buffered stdout, so a piped CI log shows the verdict first
426
481
  $stdout.flush
427
482
  abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
428
- puts "every query validates against the schema"
483
+ puts GraphWeaver::Internal::Tasks.validated
429
484
  end
430
485
  end
431
486
 
@@ -137,12 +137,6 @@ class GraphWeaver::Testing::FakeClient
137
137
  null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
138
138
  }.freeze
139
139
 
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
140
  # Methods every Ruby object answers aren't fields: a schema does have a
147
141
  # `hash` or a `count`, and a Struct answers both with plausible nonsense
148
142
  # where fabricating is right.
@@ -151,7 +145,7 @@ class GraphWeaver::Testing::FakeClient
151
145
  # The scalars the GraphQL spec serializes as JSON strings, whatever Ruby
152
146
  # holds them.
153
147
  STRING_SCALARS = %w[ID String].freeze
154
- private_constant :OPTIONS, :WIRE, :RUBY_OWN, :STRING_SCALARS
148
+ private_constant :OPTIONS, :RUBY_OWN, :STRING_SCALARS
155
149
 
156
150
  def initialize(pins = {}, **options)
157
151
  config = GraphWeaver::Testing.config
@@ -467,7 +461,9 @@ class GraphWeaver::Testing::FakeClient
467
461
  # reads them off — a FactoryBot build, a model, a Struct. Either way it
468
462
  # MERGES: what it doesn't answer is fabricated.
469
463
  def pinned_object(type, selections, value, source)
470
- return value if !value.is_a?(Hash) && wire?(value)
464
+ # what JSON already holds is the pin as written — nil is null, and the rest
465
+ # is the corrupt payload the example asked for (a Hash is response keys)
466
+ return value if !value.is_a?(Hash) && GraphWeaver::Internal::Values.wire?(value)
471
467
 
472
468
  concrete = pinned_type(type, value, source)
473
469
  pins = value.is_a?(Hash) ? value : read_fields(concrete, selections, value)
@@ -497,8 +493,6 @@ class GraphWeaver::Testing::FakeClient
497
493
  object.respond_to?(name) && !RUBY_OWN.include?(object.method(name).owner)
498
494
  end
499
495
 
500
- def wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
501
-
502
496
  # An object pin holds Ruby values — a Time, a Money, a T::Enum — where the
503
497
  # wire holds what the registration says they serialize to. A value that
504
498
  # is already wire-shaped is taken as written.
@@ -326,11 +326,14 @@ module GraphWeaver
326
326
 
327
327
  plan = @planner.plan(document, operation_name:)
328
328
  return introspect(query, variables, plan.operation_name) if plan.introspection
329
+ # read once, so every hop runs as the identity the query started
330
+ # with, whatever writes #context= while it is in flight
331
+ context = Internal::Util.context!(@context)
329
332
  # one subgraph answers the whole thing: hand it the document as
330
333
  # written, so nothing is rewritten that didn't have to be
331
- return fetch(plan.entry, query, variables, plan.operation_name) if plan.verbatim
334
+ return fetch(plan.entry, query, variables, plan.operation_name, context) if plan.verbatim
332
335
 
333
- run(plan, variables)
336
+ run(plan, variables, context)
334
337
  end
335
338
 
336
339
  # never leak the context (tokens, current_user) through logs or errors
@@ -374,7 +377,7 @@ module GraphWeaver
374
377
 
375
378
  # ---- execution ----------------------------------------------------
376
379
 
377
- def run(plan, variables)
380
+ def run(plan, variables, context)
378
381
  errors = []
379
382
  data = {}
380
383
  # An operation's declared defaults are part of the variables, and
@@ -384,7 +387,7 @@ module GraphWeaver
384
387
  .merge(variables.to_h { |name, value| [name.to_s, value] })
385
388
 
386
389
  plan.steps.each do |step|
387
- result = fetch_step(step, plan.operation, given)
390
+ result = fetch_step(step, plan.operation, given, context)
388
391
  Array(result["errors"]).each { |error| errors << rewrite(error, step.subgraph) }
389
392
  payload = result["data"]
390
393
  if payload.nil?
@@ -397,7 +400,7 @@ module GraphWeaver
397
400
  end
398
401
  end
399
402
 
400
- plan.steps.each { |step| stitch(step, [[data, []]], plan.operation, given, errors) }
403
+ plan.steps.each { |step| stitch(step, [[data, []]], plan.operation, given, context, errors) }
401
404
 
402
405
  # A stitched fetch can leave a null where the composed schema says
403
406
  # non-null, and nothing re-applies GraphQL's propagation rules over a
@@ -443,7 +446,7 @@ module GraphWeaver
443
446
  # Everything the plan applies at this level: one _entities fetch per
444
447
  # subgraph the level defers to (all nodes at once — _entities answers
445
448
  # in representation order), then the same again one level down.
446
- def stitch(step, nodes, operation, variables, errors)
449
+ def stitch(step, nodes, operation, variables, context, errors)
447
450
  return if nodes.empty?
448
451
 
449
452
  # An abstract position: the plan holds one branch per concrete type
@@ -454,12 +457,12 @@ module GraphWeaver
454
457
  if step.is_a?(Internal::Planner::Branches)
455
458
  step.steps.each do |type_name, branch|
456
459
  stitch(branch, nodes.select { |(node, _)| node[TYPENAME] == type_name },
457
- operation, variables, errors)
460
+ operation, variables, context, errors)
458
461
  end
459
462
  return
460
463
  end
461
464
 
462
- blocked = prefetch(step, nodes, operation, variables, errors)
465
+ blocked = prefetch(step, nodes, operation, variables, context, errors)
463
466
 
464
467
  # A fetch for a selection the operation excluded is a fetch a real
465
468
  # router never makes, and `trace` is something specs assert on. The
@@ -478,7 +481,8 @@ module GraphWeaver
478
481
 
479
482
  entities = []
480
483
  if fetched.any?
481
- result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation, variables)
484
+ result = entities_fetch(target, step.type_name, deferrals.map(&:node), representations, operation,
485
+ variables, context)
482
486
  entities = result.dig("data", "_entities") || []
483
487
  Array(result["errors"]).each { |error| errors << rewrite(error, target, fetched) }
484
488
  end
@@ -502,12 +506,12 @@ module GraphWeaver
502
506
  deferrals.each do |deferral|
503
507
  next unless deferral.step
504
508
 
505
- stitch(deferral.step, descend(nodes, deferral.response_key), operation, variables, errors)
509
+ stitch(deferral.step, descend(nodes, deferral.response_key), operation, variables, context, errors)
506
510
  end
507
511
  end
508
512
 
509
513
  step.children.each do |key, child|
510
- stitch(child, descend(nodes, key), operation, variables, errors)
514
+ stitch(child, descend(nodes, key), operation, variables, context, errors)
511
515
  end
512
516
 
513
517
  nodes.each { |(node, _)| strip!(node, step) }
@@ -517,7 +521,7 @@ module GraphWeaver
517
521
  # keys before the fetch whose representation carries them. Returns the
518
522
  # nodes the holding subgraph didn't recognize: their required fields
519
523
  # don't exist, so nothing depending on them can resolve.
520
- def prefetch(step, nodes, operation, variables, errors)
524
+ def prefetch(step, nodes, operation, variables, context, errors)
521
525
  blocked = []
522
526
  # the field it feeds was excluded, so this is a fetch a real router
523
527
  # never makes — and a test double that runs a resolver production
@@ -533,7 +537,7 @@ module GraphWeaver
533
537
  selections = Internal::Planner.injected_selections(group.flat_map(&:paths).uniq)
534
538
  roots = selections.map(&:alias)
535
539
 
536
- result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables)
540
+ result = entities_fetch(subgraph, step.type_name, selections, representations, operation, variables, context)
537
541
  entities = result.dig("data", "_entities") || []
538
542
  Array(result["errors"]).each { |error| errors << rewrite(error, subgraph, nodes) }
539
543
 
@@ -621,16 +625,16 @@ module GraphWeaver
621
625
  Array(path).map { |segment| segment.is_a?(String) ? segment.delete_prefix(PREFIX) : segment }
622
626
  end
623
627
 
624
- def fetch_step(step, operation, variables)
628
+ def fetch_step(step, operation, variables, context)
625
629
  document = GraphQL::Language::Nodes::OperationDefinition.new(
626
630
  operation_type: operation.operation_type || "query",
627
631
  variables: used_variables(step.selections, operation),
628
632
  selections: step.selections,
629
633
  )
630
- run_subgraph(step.subgraph, document, variables)
634
+ run_subgraph(step.subgraph, document, variables, context)
631
635
  end
632
636
 
633
- def entities_fetch(subgraph, type_name, nodes, representations, operation, variables)
637
+ def entities_fetch(subgraph, type_name, nodes, representations, operation, variables, context)
634
638
  entities = GraphQL::Language::Nodes::Field.new(
635
639
  name: "_entities",
636
640
  arguments: [GraphQL::Language::Nodes::Argument.new(
@@ -647,7 +651,7 @@ module GraphWeaver
647
651
  variables: [REPRESENTATIONS_DEFINITION] + used_variables(nodes, operation),
648
652
  selections: [entities],
649
653
  )
650
- run_subgraph(subgraph, document, variables.merge(REPRESENTATIONS => representations))
654
+ run_subgraph(subgraph, document, variables.merge(REPRESENTATIONS => representations), context)
651
655
  end
652
656
 
653
657
  REPRESENTATIONS = "representations"
@@ -682,12 +686,12 @@ module GraphWeaver
682
686
  end
683
687
  end
684
688
 
685
- def run_subgraph(subgraph, document, variables)
689
+ def run_subgraph(subgraph, document, variables, context)
686
690
  declared = document.variables.map(&:name)
687
- fetch(subgraph, document.to_query_string, variables.slice(*declared), nil)
691
+ fetch(subgraph, document.to_query_string, variables.slice(*declared), nil, context)
688
692
  end
689
693
 
690
- def fetch(name, query, variables, operation_name)
694
+ def fetch(name, query, variables, operation_name, context)
691
695
  faked = @faked.include?(name)
692
696
  entry = { subgraph: name, query:, variables: variables.to_h }
693
697
  entry[:faked] = true if faked
@@ -701,14 +705,12 @@ module GraphWeaver
701
705
  end
702
706
 
703
707
  GraphWeaver::Internal::Log.log(:debug) do
704
- "router -> #{name} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}
705
- " \
708
+ "router -> #{name} #{tag} variables=#{JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))}\n" \
706
709
  "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
707
710
  end
708
711
 
709
712
  GraphWeaver::Internal::Log.log_timed(:debug, "router -> #{name} #{tag} completed") do
710
- @subgraphs.fetch(name).execute(query, variables:, operation_name:,
711
- context: Internal::Util.context!(@context)).to_h
713
+ @subgraphs.fetch(name).execute(query, variables:, operation_name:, context:).to_h
712
714
  end
713
715
  end
714
716
 
@@ -783,7 +785,6 @@ module GraphWeaver
783
785
  end
784
786
  ordered
785
787
  end
786
-
787
788
  end
788
789
  end
789
790
  end
@@ -352,7 +352,6 @@ module GraphWeaver
352
352
  def runnable(schema)
353
353
  schema if schema.is_a?(Class) && schema <= GraphQL::Schema
354
354
  end
355
-
356
355
  end
357
356
 
358
357
  class << self
@@ -388,6 +387,107 @@ module GraphWeaver
388
387
  # The configured directory as a real path — a rake task or an rspec run
389
388
  # starts from wherever it starts from; the cassettes don't move.
390
389
  def cassette_dir = Internal::Util.resolve(config.cassette_dir)
390
+
391
+ # Whether a scalar's two definitions agree: the server's
392
+ # coerce_input/coerce_result, and your register_scalar. No schema
393
+ # carries the server's half — a scalar's SDL is its name and a url —
394
+ # so nothing `verify`, `schema:diff` or `generate` reads can say. A
395
+ # schema CLASS carries both, and this runs them against each other.
396
+ #
397
+ # Per scalar the schema declares and your app registered: fabricate a
398
+ # value the way :fake does, cast it, send it back out through
399
+ # `serialize:`, through the server's `coerce_input` and `coerce_result`,
400
+ # and back through `cast:`. Raises naming every scalar that disagreed
401
+ # and how; silent when they all agree.
402
+ #
403
+ # Pass the schema CLASS. A dump's scalars pass values through, so
404
+ # against one this checks only that a registration's `cast:` accepts
405
+ # what its own `serialize:` writes — which is worth knowing, and is not
406
+ # the same question.
407
+ #
408
+ # The fabricated value is what the check has to work with, so pin the
409
+ # one that matters where it matters —
410
+ # `config.overrides = { "Decimal" => "123456789.123456789" }` is how
411
+ # the precision case gets exercised at all.
412
+ def check_scalars!(schema)
413
+ registry = Internal::Util.registry_for(schema)
414
+ values = Internal::Values.new(seed: 0, schema:, registry:)
415
+ context = GraphQL::Query.new(schema, "{ __typename }").context
416
+
417
+ disagreed = schema.types.values.sort_by(&:graphql_name).filter_map do |type|
418
+ next unless type.kind.name == "SCALAR"
419
+ # the built-in entries are the library's own; it is your
420
+ # registration that can be wrong about this server
421
+ next if registry.builtin_scalar?(type.graphql_name) ||
422
+ !registry.scalar_registry.key?(type.graphql_name)
423
+
424
+ disagreement(registry.scalar(type.graphql_name), type, values, context)
425
+ end
426
+ return if disagreed.empty?
427
+
428
+ raise GraphWeaver::Error, "#{disagreed.size} scalar(s) disagree with #{schema}:\n" +
429
+ disagreed.map { |line| " #{line}" }.join("\n")
430
+ end
431
+
432
+ private
433
+
434
+ # One scalar's verdict, or nil when the two halves agree. Each step is
435
+ # a different mistake, so each says which.
436
+ def disagreement(scalar, type, values, context)
437
+ name = type.graphql_name
438
+ # a scalar registered as your own class has no fabricable value, and
439
+ # it says how to pin one — reported here rather than raised, so one
440
+ # unpinned scalar doesn't hide the verdict on all the others
441
+ begin
442
+ wire = values.scalar(name, name)
443
+ rescue GraphWeaver::Error => e
444
+ return "#{name}: #{e.message}"
445
+ end
446
+
447
+ cast = cast_proc(scalar)
448
+ begin
449
+ sample = cast.call(wire)
450
+ 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} => ... }"
453
+ end
454
+
455
+ if scalar.serialize? && !scalar.serialize_value?
456
+ return "#{name}: serialize: is a Proc, which builds source for the generated file rather " \
457
+ "than converting a value, so there is nothing here to run it against"
458
+ end
459
+
460
+ out = scalar.serialize_value(sample)
461
+ refused = "#{name}: the server refused #{out.inspect}, the wire form serialize: writes"
462
+ begin
463
+ received = type.coerce_input(out, context)
464
+ rescue StandardError => e
465
+ return "#{refused} (#{e.message})"
466
+ end
467
+ return "#{refused} (coerce_input returned nil)" if received.nil? && !out.nil?
468
+
469
+ result = type.coerce_result(received, context)
470
+ begin
471
+ back = cast.call(result)
472
+ rescue StandardError => e
473
+ return "#{name}: cast: refused #{result.inspect}, the result form the server's " \
474
+ "coerce_result writes (#{e.message})"
475
+ end
476
+ return if back == sample
477
+
478
+ "#{name}: round-trips lossily — sent #{Internal::Redact.spell(sample)}, got back #{Internal::Redact.spell(back)}"
479
+ end
480
+
481
+ # The registration's `cast:`, RUN rather than emitted. A cast builds
482
+ # SOURCE for the generated file, so evaluating it is the only way to
483
+ # run one — at the top level, where a generated file's own constants
484
+ # resolve from.
485
+ def cast_proc(scalar)
486
+ source = scalar.cast("wire")
487
+ return ->(wire) { wire } if source.nil?
488
+
489
+ eval("->(wire) { #{source} }", TOPLEVEL_BINDING, __FILE__, __LINE__) # rubocop:disable Security/Eval
490
+ end
391
491
  end
392
492
  end
393
493
  end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.7.3"
2
+ VERSION = "0.7.5"
3
3
  end