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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +6 -13
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- 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.
|
|
19
|
-
# Values for value fabrication (mode: :faker / :literal).
|
|
18
|
+
# every fake response casts cleanly through the generated structs.
|
|
20
19
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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(
|
|
39
|
-
#
|
|
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
|
|
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
|
-
#
|
|
69
|
-
#
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
@
|
|
102
|
-
|
|
103
|
-
@
|
|
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
|
-
|
|
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
|
|
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 ")} —
|
|
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
|
|
344
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
core_value(type, node, selections)
|
|
554
|
+
core_value(type, node, selections, coordinate)
|
|
376
555
|
end
|
|
377
556
|
end
|
|
378
557
|
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
77
|
+
"a representation sent to #{@name} carries no __typename: " \
|
|
78
|
+
"#{GraphWeaver::Internal::Log.filter_variables(representation).inspect}"
|
|
74
79
|
|
|
75
|
-
|
|
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
|
|