graph_weaver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -15,34 +15,43 @@ require_relative "../parsing"
15
15
  #
16
16
  # Values are type-correct by construction (real enum values, valid
17
17
  # __typename members for unions/interfaces, iso8601 for date scalars), so
18
- # every fake response casts cleanly through the generated structs. See
19
- # Values for value fabrication (mode: :faker / :literal).
18
+ # every fake response casts cleanly through the generated structs.
20
19
  #
21
- # overrides: pin fields by GraphQL name schema vocabulary, so keys
22
- # survive query refactors. "Type.field" beats "field"; values are
23
- # literals or zero-arg procs. Keys are checked against the schema, since
24
- # a typo'd one would pin nothing and leave the test green. (An override
25
- # with a wrong-typed value is also the way to simulate a corrupt
26
- # payload — casting raises GraphWeaver::TypeError.)
20
+ # values: how they're written:faker (semantic, matched on the field
21
+ # name), :literal (plain type-derived), or nil to use faker when the gem is
22
+ # loaded. Per fake: which reads better is one example's question.
27
23
  #
28
- # FakeClient.new(schema:, overrides: {
29
- # "Person.name" => "Daniel",
30
- # "email" => -> { "test@example.com" },
31
- # })
24
+ # pins: what the fake uses instead of inventing a value, keyed by GraphQL
25
+ # name schema vocabulary, so keys survive query refactors. A scalar type
26
+ # ("Money"), an object type ("Person"), or a field ("Order.total", or a
27
+ # bare field name on any type). The value is a wire value, an object the
28
+ # fake reads the selected fields off — a FactoryBot build, a model, a
29
+ # Struct — or a proc returning either, handed the seeded Random when it
30
+ # takes one. Keys are checked against the schema, since a typo'd one would
31
+ # pin nothing and leave the test green. (A pin with a wrong-typed value is
32
+ # also the way to simulate a corrupt payload — casting raises
33
+ # GraphWeaver::TypeError.)
32
34
  #
33
- # An override pins a whole subtree as readily as a leaf, and **merges**
34
- # rather than replaces: name the fields the test is about and the rest of
35
- # the selection is still fabricated. A list pins its own length, so "two
35
+ # FakeClient.new({ "Money" => "12.00", "Person" => build(:person),
36
+ # "email" => -> { "test@example.com" } }, schema:)
37
+ #
38
+ # Options are lowercase words, so a key with a dot or a leading capital is
39
+ # a pin wherever it is written — `overrides:` is the same hash by keyword,
40
+ # and the leading one wins where both name a key.
41
+ #
42
+ # A pin covers a whole subtree as readily as a leaf, and **merges** rather
43
+ # than replaces: name the fields the test is about and the rest of the
44
+ # selection is still fabricated. A list pins its own length, so "two
36
45
  # orders, the first one paid" is the literal thing you write:
37
46
  #
38
- # FakeClient.new(schema:, overrides: {
39
- # "Reader.name" => "Ada",
40
- # "Reader.orders" => [{ "status" => "PAID" }, {}],
41
- # })
47
+ # FakeClient.new({ "Reader.name" => "Ada",
48
+ # "Reader.orders" => [{ "status" => "PAID" }, {}] }, schema:)
42
49
  #
43
- # Keys inside a pinned subtree are response keys — what comes back on the
50
+ # Keys inside a pinned Hash are response keys — what comes back on the
44
51
  # wire, aliases included — and one the query doesn't select is refused,
45
- # for the same reason a typo'd coordinate is.
52
+ # for the same reason a typo'd coordinate is. An object pin is read the
53
+ # other way round: the reader is the snake_cased field name, not the alias,
54
+ # and a field it doesn't answer is fabricated.
46
55
  #
47
56
  # requests: every execute, in order ({ query:, variables:, operation_name: })
48
57
  # — "did we send the right variables", and "did we call it at all".
@@ -65,15 +74,23 @@ require_relative "../parsing"
65
74
  #
66
75
  # FakeClient.new(schema:, corrupt: "Person.birthday")
67
76
  #
68
- # seed: makes a run reproducible (also seeds faker). Every option,
69
- # schema: included, falls back to GraphWeaver::Testing.config and the
77
+ # null_chance: how often a nullable field comes back null 0 by default,
78
+ # and per fake only: "does this render with no email" is one example's
79
+ # question, and a suite-wide answer would sprinkle nils through every other
80
+ # example instead.
81
+ #
82
+ # FakeClient.new(schema:, null_chance: 1.0) # everything nullable, null
83
+ #
84
+ # seed: makes a run reproducible (also seeds faker). schema:, overrides:
85
+ # and list_size: fall back to GraphWeaver::Testing.config — and the
70
86
  # config's schema falls back to the committed dump.
71
87
  class GraphWeaver::Testing::FakeClient
72
88
  include GraphWeaver::Parsing
73
- include GraphWeaver::Selection
89
+ include GraphWeaver::Internal::Selection
74
90
 
75
91
  # sentinel: a simulated failure bubbling up to the nearest nullable spot
76
92
  NULL_BUBBLE = Object.new.freeze
93
+ private_constant :NULL_BUBBLE
77
94
 
78
95
  # sentinel: no override here — distinct from an override OF nil, which
79
96
  # pins the field null
@@ -90,22 +107,57 @@ class GraphWeaver::Testing::FakeClient
90
107
  # expect(fake.requests.last[:variables]).to eq({ "id" => "1" })
91
108
  attr_reader :requests
92
109
 
93
- def initialize(schema: nil, overrides: {}, seed: nil, mode: nil, list_size: nil, null_chance: nil,
94
- errors: nil, fail_at: nil, corrupt: nil)
110
+ # Everything a fake takes, with its default. This IS the signature — a
111
+ # keyword list can only refuse what reaches it, and two of the three doors
112
+ # onto a fake (graphql_fake, a router's fake:) forward a hash, so a
113
+ # misspelled key arrived as a bare "unknown keyword" from inside the
114
+ # fabricator, naming neither the accepted options nor the one you meant.
115
+ OPTIONS = {
116
+ schema: nil, overrides: {}, seed: nil, values: nil, list_size: nil,
117
+ null_chance: nil, errors: nil, fail_at: nil, corrupt: nil,
118
+ }.freeze
119
+
120
+ # One rule tells a pin from an option: options are lowercase words, and
121
+ # anything with a dot or a leading capital names something in the schema.
122
+ # Ruby 3 hands every braceless pair to **options — String keys included —
123
+ # so `FakeClient.new("Order.total" => "9", seed: 1)` arrives whole and is
124
+ # split here, as is a quoted symbol (`"Order.total":`) or a hash forwarded
125
+ # by a router's fake:.
126
+ PIN_KEY = /\A[A-Z]|\./
127
+
128
+ # JSON's own types are already on the wire: at a leaf they skip the
129
+ # registry's serializer, and at a composite position (a Hash aside, which
130
+ # is response keys) they pin the field as written — nil is null, the rest
131
+ # is the corrupt payload the example asked for.
132
+ WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
133
+
134
+ # Methods every Ruby object answers aren't fields: a schema does have a
135
+ # `hash` or a `count`, and a Struct answers both with plausible nonsense
136
+ # where fabricating is right.
137
+ RUBY_OWN = [BasicObject, Kernel, Object, Comparable, Enumerable, Struct, Data].freeze
138
+ private_constant :OPTIONS, :PIN_KEY, :WIRE, :RUBY_OWN
139
+
140
+ def initialize(pins = {}, **options)
141
+ pins, options = check_options!(pins, options)
95
142
  config = GraphWeaver::Testing.config
96
- @schema = schema || config.schema || raise(GraphWeaver::Error,
143
+ @schema = options[:schema] || config.schema || raise(GraphWeaver::Error,
97
144
  "no schema to fake against — set GraphWeaver::Testing.config.schema, pass schema:, " \
98
145
  "or commit a schema dump at #{GraphWeaver.schema_path}")
99
- @overrides = config.overrides.merge(overrides)
100
- GraphWeaver::Testing.validate_overrides!(@schema, @overrides)
101
- @values = GraphWeaver::Testing::Values.new(seed:, mode:)
102
- @list_size = list_size || config.list_size
103
- @null_chance = null_chance || config.null_chance
146
+ # last wins, narrowest last: the suite's, then overrides:, then the pins
147
+ # this fake was handed outright
148
+ @overrides = [config.overrides, options[:overrides], pins]
149
+ .map { |hash| hash.transform_keys(&:to_s) }.reduce(:merge)
150
+ GraphWeaver::Internal::Overrides.validate!(@schema, @overrides)
151
+ @values = GraphWeaver::Internal::Values.new(seed: options[:seed], values: options[:values],
152
+ pins: @overrides)
153
+ @list_size = options[:list_size] || config.list_size
154
+ @null_chance = options[:null_chance] || 0.0
104
155
  # NOT Array(): it would explode a bare Hash into key/value pairs
105
- @extra_errors = wrap(errors).map { |error| normalize_error(error) }
106
- @fail_at = wrap(fail_at).map { |spec| normalize_fail_spec(spec) }
107
- @corrupt = wrap(corrupt)
156
+ @extra_errors = wrap(options[:errors]).map { |error| normalize_error(error) }
157
+ @fail_at = wrap(options[:fail_at]).map { |spec| normalize_fail_spec(spec) }
158
+ @corrupt = wrap(options[:corrupt])
108
159
  @requests = []
160
+ @variables = nil # unknown until an execute says; see #object
109
161
  end
110
162
 
111
163
  # operation_name: is accepted for contract parity and ignored — one
@@ -126,6 +178,7 @@ class GraphWeaver::Testing::FakeClient
126
178
 
127
179
  operation = load_operation(query)
128
180
  root_type = operation_root_type(operation)
181
+ @variables = variable_values(operation, variables)
129
182
 
130
183
  @path = []
131
184
  @failures = []
@@ -144,14 +197,27 @@ class GraphWeaver::Testing::FakeClient
144
197
  # {FakeSubgraph} answers a federation `_entities` fetch through, where the
145
198
  # representation names the type and the document only ever reached it
146
199
  # through an inline fragment.
147
- def object(type_name, selections, fragments: {})
200
+ #
201
+ # variables: are what @skip/@include read. Left unsaid they are *unknown*,
202
+ # not empty, and the directives go unevaluated: the router has already
203
+ # decided them for the fetch it is sending, and reading an unpassed
204
+ # variable as absent would drop the field it just asked for.
205
+ # operation: is the definition the selections came from — a faked subgraph
206
+ # passes it so @skip/@include see the defaults it declares, as graphql-ruby
207
+ # would. Without it, or variables:, directives stay unevaluated.
208
+ # failures: an array any simulated field error (fail_at:) is appended to,
209
+ # paths relative to this object. Left unsaid, a fail_at here only nulls the
210
+ # field — a null with no error is a response no server gives.
211
+ def object(type_name, selections, fragments: {}, variables: nil, operation: nil, failures: nil)
148
212
  type = @schema.get_type(type_name) or
149
213
  raise GraphWeaver::Error, "#{type_name} is not a type of this schema"
150
214
 
151
215
  @fragments = fragments
216
+ @variables = variables && variable_values(operation, variables)
152
217
  @path = []
153
218
  @failures = []
154
219
  value = object_value(type, selections)
220
+ failures&.concat(@failures)
155
221
  value.equal?(NULL_BUBBLE) ? nil : value
156
222
  end
157
223
 
@@ -163,8 +229,62 @@ class GraphWeaver::Testing::FakeClient
163
229
 
164
230
  private
165
231
 
232
+ # A misspelled option pins nothing and leaves the example green — the same
233
+ # silent pass a typo'd override key is refused for.
234
+ def check_options!(pins, options)
235
+ options, keyed_pins = options.partition { |key, _| !PIN_KEY.match?(key.to_s) }.map(&:to_h)
236
+ # what was written as a leading hash wins: it is the one form that can
237
+ # only ever be a pin
238
+ pins = keyed_pins.merge(pins.to_h)
239
+
240
+ unknown = options.keys - OPTIONS.keys
241
+ return [pins, OPTIONS.merge(options)] if unknown.empty?
242
+
243
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(OPTIONS.keys.map(&:to_s), unknown.first.to_s)
244
+ hint = suggestion ? " — did you mean #{suggestion}:?" : "."
245
+ raise ArgumentError, "a fake doesn't take #{unknown.first}:#{hint} It takes " \
246
+ "#{OPTIONS.keys.map { |name| "#{name}:" }.join(", ")}"
247
+ end
248
+
166
249
  def rng = @values.rng
167
250
 
251
+ # Codegen has to type a @skip/@include field as maybe-absent because it
252
+ # can't know the variable; a fake was handed it, so it can answer the way
253
+ # the server would — and the way {Router} already does, or one query would
254
+ # carry a key under :fake and not under :router. Selection's walk recurses
255
+ # through this method, so filtering here filters at every depth.
256
+ def each_field(type, selections, visiting = Set.new, conditional: false, &block)
257
+ selections = selections.reject { |selection| omitted?(selection) } if @variables
258
+ super(type, selections, visiting, conditional:, &block)
259
+ end
260
+
261
+ # A variable with neither a value nor a declared default reads as absent,
262
+ # which excludes under @include and includes under @skip — as graphql-ruby
263
+ # resolves it.
264
+ def omitted?(selection)
265
+ selection.directives.any? do |directive|
266
+ next false unless GraphWeaver::Internal::Selection::CONDITIONAL_DIRECTIVES.include?(directive.name)
267
+
268
+ argument = directive.arguments.find { |arg| arg.name == "if" } or next false
269
+ value = argument_value(argument)
270
+ (directive.name == "skip") ? !!value : value.nil? || value == false
271
+ end
272
+ end
273
+
274
+ def argument_value(argument)
275
+ value = argument.value
276
+ value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) ? @variables&.[](value.name) : value
277
+ end
278
+
279
+ # An operation's declared defaults are part of the variables graphql-ruby
280
+ # runs with, so @skip/@include and a `first:` have to see them too.
281
+ def variable_values(operation, variables)
282
+ defaults = (operation&.variables || []).each_with_object({}) do |definition, out|
283
+ out[definition.name] = definition.default_value unless definition.default_value.nil?
284
+ end
285
+ defaults.merge(variables.to_h { |name, value| [name.to_s, value] })
286
+ end
287
+
168
288
  def wrap(value)
169
289
  case value
170
290
  when nil then []
@@ -241,7 +361,7 @@ class GraphWeaver::Testing::FakeClient
241
361
  return pinned_value(field_type, node, selections, pin, source) unless pin.equal?(UNPINNED)
242
362
  return corrupt_value(field_type) if @corrupt.include?(coordinate)
243
363
 
244
- type_value(field_type, node, selections)
364
+ type_value(field_type, node, selections, coordinate:)
245
365
  end
246
366
 
247
367
  # What an override pins here. A leaf takes the value outright; a composite
@@ -249,7 +369,7 @@ class GraphWeaver::Testing::FakeClient
249
369
  # fabricated, so pinning one nested field never means hand-writing the
250
370
  # subtree around it. A pinned list is exactly as long as it is written.
251
371
  def pinned_value(type, node, selections, value, source)
252
- value = value.call if value.is_a?(Proc)
372
+ value = GraphWeaver::Internal::Overrides.resolve(value, rng)
253
373
 
254
374
  case type.kind.name
255
375
  when "NON_NULL" then pinned_value(type.of_type, node, selections, value, source)
@@ -264,30 +384,83 @@ class GraphWeaver::Testing::FakeClient
264
384
  @path.pop
265
385
  end
266
386
  end
267
- when "OBJECT", "UNION", "INTERFACE"
268
- return value unless value.is_a?(Hash)
269
-
270
- object_value(pinned_type(type, value, source), selections, pins: value, source:)
387
+ when "OBJECT", "UNION", "INTERFACE" then pinned_object(type, selections, value, source)
271
388
  else
272
389
  value
273
390
  end
274
391
  end
275
392
 
393
+ # A pinned composite is a Hash of response keys, or an object the fake
394
+ # reads them off — a FactoryBot build, a model, a Struct. Either way it
395
+ # MERGES: what it doesn't answer is fabricated.
396
+ def pinned_object(type, selections, value, source)
397
+ return value if !value.is_a?(Hash) && wire?(value)
398
+
399
+ concrete = pinned_type(type, value, source)
400
+ pins = value.is_a?(Hash) ? value : read_fields(concrete, selections, value)
401
+ object_value(concrete, selections, pins:, source:)
402
+ end
403
+
404
+ # The response keys an object pin answers, read off it. The reader is the
405
+ # snake_cased FIELD name, not the alias — the object belongs to the domain,
406
+ # not to this query — and its Ruby values go through the scalar registry on
407
+ # the way to the wire.
408
+ def read_fields(type, selections, object)
409
+ gather(type, selections).each_with_object({}) do |(key, nodes), pins|
410
+ name = nodes.first.name
411
+ next if name == "__typename"
412
+
413
+ reader = GraphWeaver::Inflect.underscore(name)
414
+ next unless reader?(object, reader)
415
+
416
+ field_type = @schema.get_field(type.graphql_name, name).type
417
+ pins[key] = wire_value(field_type, object.public_send(reader),
418
+ "#{type.graphql_name}.#{name}")
419
+ end
420
+ end
421
+
422
+ # what the object itself answers — see RUBY_OWN
423
+ def reader?(object, name)
424
+ object.respond_to?(name) && !RUBY_OWN.include?(object.method(name).owner)
425
+ end
426
+
427
+ def wire?(value) = WIRE.any? { |klass| value.is_a?(klass) }
428
+
429
+ # An object pin holds Ruby values — a Time, a Money, a T::Enum — where the
430
+ # wire holds what the registration says they serialize to. A value that
431
+ # is already wire-shaped is taken as written.
432
+ def wire_value(type, value, coordinate)
433
+ case type.kind.name
434
+ when "NON_NULL" then wire_value(type.of_type, value, coordinate)
435
+ when "LIST"
436
+ value.is_a?(Array) ? value.map { |element| wire_value(type.of_type, element, coordinate) } : value
437
+ when "SCALAR"
438
+ return value if wire?(value)
439
+
440
+ GraphWeaver::Codegen.scalar(type.graphql_name, coordinate).serialize_value(value)
441
+ when "ENUM" then value.is_a?(T::Enum) ? value.serialize : value
442
+ else value # a composite: pinned_object reads it, one level down
443
+ end
444
+ end
445
+
276
446
  # The concrete type a pinned object is fabricated as. At a union or
277
447
  # interface the pin has to say: picking a member at random would fabricate
278
448
  # a shape the pinned keys don't fit, in whichever fraction of runs the
279
449
  # seed lands there.
280
450
  def pinned_type(type, value, source)
281
- named = value["__typename"]
451
+ named = value["__typename"] if value.is_a?(Hash)
282
452
  return type if named == type.graphql_name
283
453
 
284
454
  members = (type.kind.name == "OBJECT") ? [type] : @schema.possible_types(type)
285
455
  if named.nil?
286
456
  return type if members.one?
287
457
 
458
+ # an object pin has no "__typename" to carry, so it says which by
459
+ # being keyed on the type it is
460
+ hint = value.is_a?(Hash) ? "name the one you mean with \"__typename\"" :
461
+ "pin it by type instead — { #{members.first.graphql_name.inspect} => ... }"
288
462
  raise GraphWeaver::Error, "override #{source.inspect} pins an object at #{location}, where " \
289
- "the query can return #{members.map(&:graphql_name).sort.join(" or ")} — name the one you " \
290
- "mean with \"__typename\"."
463
+ "the query can return #{members.map(&:graphql_name).sort.join(" or ")} — #{hint}."
291
464
  end
292
465
 
293
466
  found = members.find { |member| member.graphql_name == named }
@@ -306,7 +479,7 @@ class GraphWeaver::Testing::FakeClient
306
479
  unknown = pins.keys.reject { |key| key == "__typename" || fields.key?(key) }
307
480
  return if unknown.empty?
308
481
 
309
- suggestion = GraphWeaver.did_you_mean(fields.keys, unknown.first.to_s)
482
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(fields.keys, unknown.first.to_s)
310
483
  hint = suggestion ? " — did you mean #{suggestion.inspect}?" : "."
311
484
  raise GraphWeaver::Error, "override #{source.inspect} supplies #{unknown.first.inspect} at " \
312
485
  "#{location}, which this query doesn't select#{hint} An override's keys are response keys, " \
@@ -340,24 +513,32 @@ class GraphWeaver::Testing::FakeClient
340
513
  @fail_at.find { |spec| !spec["triggered"] && spec["path"] == chain }
341
514
  end
342
515
 
343
- # honor pagination-ish arg semantics: first/last/limit with a literal
344
- # int caps the fabricated list length
516
+ # honor pagination-ish arg semantics: first/last/limit caps the fabricated
517
+ # list length, whether it arrives as a literal or as a variable
345
518
  def list_length(node)
346
519
  argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
347
- return argument.value if argument && argument.value.is_a?(Integer)
520
+ capped = argument && argument_value(argument)
521
+ # Array.new(-1) is "negative array size" out of the fabricator's guts; a
522
+ # cap below zero asks for nothing, which is what a page of none is
523
+ return [capped, 0].max if capped.is_a?(Integer)
348
524
 
349
525
  # an Integer list_size means exactly that many; a Range randomizes within it
350
526
  @list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
351
527
  end
352
528
 
353
- def type_value(type, node, selections, non_null: false)
529
+ def type_value(type, node, selections, coordinate: nil, non_null: false)
530
+ if type.kind.name == "NON_NULL"
531
+ return type_value(type.of_type, node, selections, coordinate:, non_null: true)
532
+ end
533
+ # every nullable position, a list included — null_chance is about the
534
+ # nilable props codegen emitted, and it emits one for `[Thing!]` too
535
+ return if !non_null && rng.rand < @null_chance
536
+
354
537
  case type.kind.name
355
- when "NON_NULL"
356
- type_value(type.of_type, node, selections, non_null: true)
357
538
  when "LIST"
358
539
  elements = Array.new(list_length(node)) do |index|
359
540
  @path.push(index)
360
- element = type_value(type.of_type, node, selections)
541
+ element = type_value(type.of_type, node, selections, coordinate:)
361
542
  @path.pop
362
543
  element
363
544
  end
@@ -370,23 +551,35 @@ class GraphWeaver::Testing::FakeClient
370
551
  end
371
552
  elements
372
553
  else
373
- return if !non_null && rng.rand < @null_chance
374
-
375
- core_value(type, node, selections)
554
+ core_value(type, node, selections, coordinate)
376
555
  end
377
556
  end
378
557
 
379
- def core_value(type, node, selections)
558
+ # A pin keyed by a concrete type name says what an object of that type is,
559
+ # wherever the query reaches one — at a union or interface, that is the
560
+ # member the walk landed on, so the pin never has to disambiguate.
561
+ def composite_value(type, selections)
562
+ name = type.graphql_name
563
+ return object_value(type, selections) unless @overrides.key?(name)
564
+
565
+ pinned_object(type, selections, GraphWeaver::Internal::Overrides.resolve(@overrides[name], rng), name)
566
+ end
567
+
568
+ # the scalar-type pin lives in Values, which the cassette anonymizer
569
+ # shares; the enum one has no second reader
570
+ def core_value(type, node, selections, coordinate = nil)
380
571
  case type.kind.name
381
572
  when "SCALAR"
382
- @values.scalar(type.graphql_name, node.name)
573
+ @values.scalar(type.graphql_name, node.name, coordinate, at: location)
383
574
  when "ENUM"
575
+ return GraphWeaver::Internal::Overrides.resolve(@overrides[type.graphql_name], rng) if @overrides.key?(type.graphql_name)
576
+
384
577
  type.values.keys.sort.sample(random: rng)
385
578
  when "OBJECT"
386
- object_value(type, selections)
579
+ composite_value(type, selections)
387
580
  when "UNION", "INTERFACE"
388
581
  member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
389
- object_value(member, selections)
582
+ composite_value(member, selections)
390
583
  else
391
584
  raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
392
585
  end
@@ -46,17 +46,21 @@ module GraphWeaver
46
46
  # resolvers here to receive one.
47
47
  def execute(query, variables: {}, operation_name: nil, context: nil)
48
48
  document = GraphQL.parse(query)
49
- entities = entities_field(document)
49
+ operation = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
50
+ entities = entities_field(operation)
50
51
  return @client.execute(query, variables:, operation_name:) unless entities
51
52
 
52
- { "data" => { "_entities" => entities_value(entities, document, variables) } }
53
+ failures = []
54
+ value = entities_value(entities, document, operation, variables, failures)
55
+ response = { "data" => { "_entities" => value } }
56
+ response["errors"] = failures unless failures.empty?
57
+ response
53
58
  end
54
59
 
55
60
  private
56
61
 
57
62
  # the router sends _entities as the operation's only root field
58
- def entities_field(document)
59
- operation = document.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
63
+ def entities_field(operation)
60
64
  operation&.selections&.find do |node|
61
65
  node.is_a?(GraphQL::Language::Nodes::Field) && node.name == "_entities"
62
66
  end
@@ -64,15 +68,22 @@ module GraphWeaver
64
68
 
65
69
  # one object per representation, in order and as the type it names —
66
70
  # which is the contract _entities answers on
67
- def entities_value(field, document, variables)
71
+ def entities_value(field, document, operation, variables, failures)
68
72
  fragments = document.definitions
69
73
  .grep(GraphQL::Language::Nodes::FragmentDefinition).to_h { |node| [node.name, node] }
70
74
 
71
- representations(variables).map do |representation|
75
+ representations(variables).each_with_index.map do |representation, index|
72
76
  type_name = representation["__typename"] or raise GraphWeaver::Error,
73
- "a representation sent to #{@name} carries no __typename: #{representation.inspect}"
77
+ "a representation sent to #{@name} carries no __typename: " \
78
+ "#{GraphWeaver::Internal::Log.filter_variables(representation).inspect}"
74
79
 
75
- @client.object(type_name, field.selections, fragments:)
80
+ here = []
81
+ object = @client.object(type_name, field.selections, fragments:, variables:, operation:,
82
+ failures: here)
83
+ # rooted where the fetch put it, which is how the router maps an
84
+ # entity error back onto the caller's path
85
+ here.each { |error| failures << error.merge("path" => ["_entities", index] + error["path"]) }
86
+ object
76
87
  end
77
88
  end
78
89