graph_weaver 0.7.5 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/docs/errors.md +7 -3
- data/docs/generated_modules.md +10 -6
- data/docs/getting_started.md +34 -17
- data/docs/logging.md +79 -35
- data/docs/migrating.md +11 -4
- data/docs/scalars.md +12 -3
- data/docs/testing.md +75 -13
- data/docs/upgrading.md +25 -3
- data/examples/README.md +4 -2
- data/examples/github/generate.rb +22 -8
- data/examples/github/generated/star_mutation.rb +2 -2
- data/examples/github/generated/stargazers_query.rb +2 -2
- data/examples/github/generated/starred_query.rb +2 -2
- data/examples/github/run.rb +1 -0
- data/examples/github/setup.rb +16 -8
- data/graph_weaver.gemspec +15 -6
- data/lib/generators/graph_weaver/install_generator.rb +20 -2
- data/lib/graph_weaver/client.rb +0 -23
- data/lib/graph_weaver/codegen/aliases.rb +15 -3
- data/lib/graph_weaver/codegen/emit.rb +7 -6
- data/lib/graph_weaver/codegen/enum_type.rb +26 -2
- data/lib/graph_weaver/codegen/nodes.rb +21 -2
- data/lib/graph_weaver/codegen.rb +106 -54
- data/lib/graph_weaver/graph.rb +55 -5
- data/lib/graph_weaver/in_process.rb +1 -1
- data/lib/graph_weaver/input_struct.rb +29 -4
- data/lib/graph_weaver/internal/overrides.rb +126 -14
- data/lib/graph_weaver/internal/test_clients.rb +29 -7
- data/lib/graph_weaver/internal/unused.rb +30 -11
- data/lib/graph_weaver/internal/values.rb +17 -5
- data/lib/graph_weaver/internal.rb +6 -6
- data/lib/graph_weaver/log_subscriber.rb +27 -17
- data/lib/graph_weaver/logging.rb +89 -53
- data/lib/graph_weaver/parsing.rb +32 -3
- data/lib/graph_weaver/query_module.rb +48 -8
- data/lib/graph_weaver/rspec.rb +41 -17
- data/lib/graph_weaver/schema_diff.rb +24 -5
- data/lib/graph_weaver/schema_loader.rb +22 -9
- data/lib/graph_weaver/tasks.rb +71 -44
- data/lib/graph_weaver/testing/fake_client.rb +24 -21
- data/lib/graph_weaver/testing.rb +27 -7
- data/lib/graph_weaver/transport.rb +1 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +59 -35
- metadata +2 -2
|
@@ -122,6 +122,8 @@ module GraphWeaver
|
|
|
122
122
|
return change(name, "#{kind(old)} -> #{kind(new)}", breaking: true)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
compare_description(name, old, new)
|
|
126
|
+
|
|
125
127
|
case new.kind.name
|
|
126
128
|
when "OBJECT", "INTERFACE"
|
|
127
129
|
compare_fields(name, old, new)
|
|
@@ -151,6 +153,7 @@ module GraphWeaver
|
|
|
151
153
|
breaking: breaks_output?(old.type, new.type))
|
|
152
154
|
end
|
|
153
155
|
compare_deprecation(coordinate, old, new)
|
|
156
|
+
compare_description(coordinate, old, new)
|
|
154
157
|
compare_arguments(coordinate, old.arguments, new.arguments)
|
|
155
158
|
end
|
|
156
159
|
|
|
@@ -192,6 +195,7 @@ module GraphWeaver
|
|
|
192
195
|
change(coordinate, "#{prefix}#{signature(old)} -> #{signature(new)}", breaking:)
|
|
193
196
|
end
|
|
194
197
|
compare_deprecation(coordinate, old, new)
|
|
198
|
+
compare_description(coordinate, old, new)
|
|
195
199
|
end
|
|
196
200
|
|
|
197
201
|
def compare_enum(name, old, new)
|
|
@@ -200,7 +204,10 @@ module GraphWeaver
|
|
|
200
204
|
|
|
201
205
|
(before.keys - after.keys).each { |value| change("#{name}.#{value}", "enum value removed", breaking: true) }
|
|
202
206
|
(after.keys - before.keys).each { |value| change("#{name}.#{value}", "enum value added") }
|
|
203
|
-
(before.keys & after.keys).each
|
|
207
|
+
(before.keys & after.keys).each do |value|
|
|
208
|
+
compare_deprecation("#{name}.#{value}", before[value], after[value])
|
|
209
|
+
compare_description("#{name}.#{value}", before[value], after[value])
|
|
210
|
+
end
|
|
204
211
|
end
|
|
205
212
|
|
|
206
213
|
# A dropped member silently stops matching a `... on X` fragment, which
|
|
@@ -233,10 +240,22 @@ module GraphWeaver
|
|
|
233
240
|
change(coordinate, now ? "deprecated: #{now}" : "no longer deprecated")
|
|
234
241
|
end
|
|
235
242
|
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
243
|
+
# A description breaks nothing and reaches no generated code, but it is
|
|
244
|
+
# the likeliest thing to differ between a hand-maintained dump and the
|
|
245
|
+
# server — and unnamed it fell through to note_unnamed_drift, which is a
|
|
246
|
+
# permanent red with nothing to act on. The text itself isn't printed: it
|
|
247
|
+
# can be paragraphs, and the coordinate is what you go and look at.
|
|
248
|
+
def compare_description(coordinate, old, new)
|
|
249
|
+
return unless old.respond_to?(:description)
|
|
250
|
+
return if old.description == new.description
|
|
251
|
+
|
|
252
|
+
change(coordinate, "description changed")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The walk names what a client breaks on. A directive definition or an
|
|
256
|
+
# argument default moves the SDL without appearing above — still drift,
|
|
257
|
+
# and a gate that went green on it would be worse than one that admits
|
|
258
|
+
# it can't name it.
|
|
240
259
|
def note_unnamed_drift(before, after)
|
|
241
260
|
return unless @changes.empty?
|
|
242
261
|
return if before.to_definition == after.to_definition
|
|
@@ -199,10 +199,21 @@ module GraphWeaver::SchemaLoader
|
|
|
199
199
|
COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
|
|
200
200
|
private_constant :COMPOSITION_SPEC
|
|
201
201
|
|
|
202
|
-
# A composed Fed2 supergraph is marked by @join__* directives
|
|
203
|
-
# type carries them); a plain schema
|
|
202
|
+
# A composed Fed2 supergraph is marked by @join__* directives APPLIED to
|
|
203
|
+
# its types (every merged type carries them); a plain schema applies none.
|
|
204
|
+
#
|
|
205
|
+
# Applied, not merely declared: introspect a router and graphql-ruby hands
|
|
206
|
+
# back the API schema with the @join__* directive DEFINITIONS still in it
|
|
207
|
+
# and every application gone — so a bare substring test answers "composed"
|
|
208
|
+
# about the one artifact whose routing table has been removed.
|
|
209
|
+
JOIN_DEFINITION = /\bdirective\s+@join__\w+/
|
|
210
|
+
private_constant :JOIN_DEFINITION
|
|
211
|
+
|
|
204
212
|
def self.federation_sdl?(sdl)
|
|
205
|
-
|
|
213
|
+
return true if sdl.match?(COMPOSITION_SPEC)
|
|
214
|
+
return false unless sdl.match?(/@join__\w/)
|
|
215
|
+
|
|
216
|
+
sdl.gsub(JOIN_DEFINITION, "").match?(/@join__\w/)
|
|
206
217
|
end
|
|
207
218
|
|
|
208
219
|
# The federation spec a fed-2 subgraph @links, and the directives a fed-1
|
|
@@ -720,12 +731,13 @@ module GraphWeaver::SchemaLoader
|
|
|
720
731
|
schema = build_introspection(result)
|
|
721
732
|
|
|
722
733
|
if cache
|
|
723
|
-
# the extension picks the format: .json is the
|
|
724
|
-
#
|
|
725
|
-
#
|
|
734
|
+
# the extension picks the format: .json is the wire artifact, pretty-
|
|
735
|
+
# printed so a refresh diffs line by line (graphql-client's dump is
|
|
736
|
+
# too); .graphql/.gql is SDL — human-readable, PR-reviewable diffs
|
|
737
|
+
# (both generate byte-identical code)
|
|
726
738
|
meta = stamp(transport, auth_env)
|
|
727
739
|
content = if cache.end_with?(".json")
|
|
728
|
-
JSON.
|
|
740
|
+
JSON.pretty_generate(meta ? result.merge("graph_weaver" => meta) : result)
|
|
729
741
|
else
|
|
730
742
|
header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
|
|
731
743
|
"#{header}#{schema.to_definition}"
|
|
@@ -909,13 +921,14 @@ module GraphWeaver::SchemaLoader
|
|
|
909
921
|
# Composition is the only thing that rebuilds a supergraph: introspection
|
|
910
922
|
# answers with the API schema, which is the merged shape minus the routing
|
|
911
923
|
# table, so refreshing one from a url replaces the contract with a strictly
|
|
912
|
-
# smaller artifact and reports success.
|
|
924
|
+
# smaller artifact and reports success. Public so `schema:refresh` can step
|
|
925
|
+
# over such a graph in these words rather than reaching the overwrite and
|
|
926
|
+
# relying on the guard to catch it.
|
|
913
927
|
def self.recompose_hint(path)
|
|
914
928
|
"#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
|
|
915
929
|
"the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
|
|
916
930
|
"and check the result in, instead of refreshing it"
|
|
917
931
|
end
|
|
918
|
-
private_class_method :recompose_hint
|
|
919
932
|
|
|
920
933
|
# A transport to the dump's recorded url, authenticated from whichever ENV
|
|
921
934
|
# var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a
|
data/lib/graph_weaver/tasks.rb
CHANGED
|
@@ -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
|
|
174
|
-
#
|
|
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:
|
|
187
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
|
221
|
-
# behind the file to re-read, so the file is the schema.
|
|
222
|
-
#
|
|
223
|
-
#
|
|
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
|
|
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
|
-
|
|
397
|
-
abort
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
454
|
-
#
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 —
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# example
|
|
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 =
|
|
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
|
-
#
|
|
628
|
-
# specific first like a pin — which is what keeps
|
|
629
|
-
# multiplying: every list the walk reaches re-reads
|
|
630
|
-
# for all of them is n rows x n tags.
|
|
631
|
-
def
|
|
632
|
-
return
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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"
|
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -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) —
|
|
345
|
-
"
|
|
346
|
-
"
|
|
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
|
|
452
|
-
"
|
|
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
|
-
|
|
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.
|
|
102
|
+
GraphWeaver::Internal::Log.instrument_request(payload) do
|
|
103
103
|
perform(query, variables, operation_name, payload)
|
|
104
104
|
end
|
|
105
105
|
end
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -614,8 +614,8 @@ module GraphWeaver
|
|
|
614
614
|
# # "line" => 4, "column" => 5 }] }
|
|
615
615
|
#
|
|
616
616
|
# Empty means every query validates. schema: defaults to the server as
|
|
617
|
-
# it is now — a FRESH introspection of the
|
|
618
|
-
# live schema class
|
|
617
|
+
# it is now — a FRESH introspection of the source behind each graph's
|
|
618
|
+
# dump, or the live schema class where that source IS one — and the dump
|
|
619
619
|
# is left alone; pass schema: and nothing touches the network.
|
|
620
620
|
#
|
|
621
621
|
# When that dump is a composed supergraph, an error naming a type is
|
|
@@ -623,17 +623,25 @@ module GraphWeaver
|
|
|
623
623
|
# (products, reviews)", plus a "subgraphs" key — since knowing whose
|
|
624
624
|
# code to look at is half the answer. A plain schema is unaffected.
|
|
625
625
|
#
|
|
626
|
-
# One query you have as a *string* is
|
|
627
|
-
# entries, against that
|
|
626
|
+
# One query you have as a *string* is #check_query, which every client
|
|
627
|
+
# holding a schema answers — the same entries, against that schema.
|
|
628
628
|
#
|
|
629
629
|
# A different question from verify_generated!, which asks whether the
|
|
630
630
|
# committed Ruby matches the committed schema. `rake
|
|
631
631
|
# graph_weaver:queries:check` prints this and exits non-zero.
|
|
632
632
|
def check_queries(schema: nil, queries: nil, fragments: fragments_paths)
|
|
633
633
|
shared = Codegen.load_fragments(fragments)
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
634
|
+
# one graph's server being down is that graph's verdict: every graph is
|
|
635
|
+
# attempted, so a run names every server it couldn't reach rather than
|
|
636
|
+
# ending at the first
|
|
637
|
+
unreachable = []
|
|
638
|
+
|
|
639
|
+
failures = graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
|
|
640
|
+
begin
|
|
641
|
+
checked = checked_schema(graph)
|
|
642
|
+
rescue Error => e
|
|
643
|
+
next unreachable << unreachable_source(graph, e)
|
|
644
|
+
end
|
|
637
645
|
table = checked_routing_table(graph)
|
|
638
646
|
Internal::Util.query_files(graph.queries).each do |path|
|
|
639
647
|
errors = Internal::QueryCheck.errors(checked, File.read(path), shared, table)
|
|
@@ -646,13 +654,39 @@ module GraphWeaver
|
|
|
646
654
|
failures[key] = failures.key?(key) ? failures[key] | errors : errors
|
|
647
655
|
end
|
|
648
656
|
end
|
|
657
|
+
|
|
658
|
+
raise Error, unreachable.join("\n") unless unreachable.empty?
|
|
659
|
+
|
|
660
|
+
failures
|
|
649
661
|
end
|
|
650
662
|
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
|
|
655
|
-
|
|
663
|
+
# Which graph couldn't be checked, and against what. The bare socket error
|
|
664
|
+
# named neither — in a multi-graph app it was the entire output, and the
|
|
665
|
+
# reader could not tell which of their servers was down.
|
|
666
|
+
def unreachable_source(graph, error)
|
|
667
|
+
whose = graph.name ? "graph #{graph.name.inspect}" : "this app"
|
|
668
|
+
dump = graph.dump_path || graph.named_dump_path
|
|
669
|
+
subject = dump ? Internal::Util.relative(dump) : "these queries"
|
|
670
|
+
"#{whose}: couldn't reach the schema behind #{subject} — #{error.message}"
|
|
671
|
+
end
|
|
672
|
+
private :unreachable_source
|
|
673
|
+
|
|
674
|
+
# What this graph is checked against: Graph#source, the rule `schema:refresh`
|
|
675
|
+
# and `schema:diff` follow too. A live class is asked directly — for an app
|
|
676
|
+
# that IS the server, a dump is a snapshot of its own code, and checking
|
|
677
|
+
# against it reports phantom errors about a field just added. A url is
|
|
678
|
+
# re-introspected, so no refresh step (and no rewritten dump) is needed
|
|
679
|
+
# first. A graph with neither — hand-written SDL nothing serves, a composed
|
|
680
|
+
# supergraph — has nothing to re-read, so it is checked as committed.
|
|
681
|
+
def checked_schema(graph)
|
|
682
|
+
source = graph.source
|
|
683
|
+
return source if source.is_a?(Module)
|
|
684
|
+
# Internal::Util.locate_schema! raises the conventional "no schema dump"
|
|
685
|
+
# message when the default graph has no dump either
|
|
686
|
+
return graph.schema unless source
|
|
687
|
+
|
|
688
|
+
SchemaLoader.introspect(graph.source_transport)
|
|
689
|
+
end
|
|
656
690
|
private :checked_schema
|
|
657
691
|
|
|
658
692
|
# The routing table behind the schema check_queries is about to use, when
|
|
@@ -663,28 +697,6 @@ module GraphWeaver
|
|
|
663
697
|
end
|
|
664
698
|
private :checked_routing_table
|
|
665
699
|
|
|
666
|
-
# The schema check_queries defaults to: the server as it is now. Over a
|
|
667
|
-
# socket that's a fresh introspection of the url the local dump recorded,
|
|
668
|
-
# so no refresh step (and no rewritten dump) is needed first. In-process
|
|
669
|
-
# it's the live schema class — for an app that IS the server, a dump is a
|
|
670
|
-
# snapshot of its own code, and checking against it reports phantom
|
|
671
|
-
# errors about a field you just added. Dumps with no url and no live
|
|
672
|
-
# class — hand-written SDL, a composed supergraph — have nothing to
|
|
673
|
-
# re-read, so they're checked as they are.
|
|
674
|
-
def refreshed_schema
|
|
675
|
-
live = Internal::Util.live_schema
|
|
676
|
-
return live if live
|
|
677
|
-
|
|
678
|
-
# Internal::Util.locate_schema! raises the conventional "no schema dump" message
|
|
679
|
-
path = SchemaLoader.locate_path or Internal::Util.locate_schema!
|
|
680
|
-
return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
|
|
681
|
-
|
|
682
|
-
# source_transport rather than one built here: it reads the auth ENV var
|
|
683
|
-
# the dump named, so `--auth MY_TOKEN` reaches this path too
|
|
684
|
-
SchemaLoader.introspect(SchemaLoader.source_transport(path))
|
|
685
|
-
end
|
|
686
|
-
private :refreshed_schema
|
|
687
|
-
|
|
688
700
|
# Load the generated modules — one line in an initializer or spec
|
|
689
701
|
# helper (loading happens only when you call this; skip it and
|
|
690
702
|
# require files yourself if you'd rather):
|
|
@@ -844,7 +856,7 @@ module GraphWeaver
|
|
|
844
856
|
rescue GraphWeaver::Error => e
|
|
845
857
|
# collected, not raised: nothing is written either way, and an adopter
|
|
846
858
|
# aiming generate! at an existing query directory wants the list
|
|
847
|
-
refusals << e
|
|
859
|
+
refusals << name_query_file(e, path)
|
|
848
860
|
nil
|
|
849
861
|
end
|
|
850
862
|
refuse_all!(refusals, paths.size)
|
|
@@ -907,6 +919,18 @@ module GraphWeaver
|
|
|
907
919
|
# itself, so a single bad file reads exactly as it always has — class,
|
|
908
920
|
# message and all; several become one list, because clearing them a file
|
|
909
921
|
# per run is the slowest way there is to adopt this.
|
|
922
|
+
# Almost every codegen refusal is about the query in front of it and never
|
|
923
|
+
# says which file that was — so a list of them named nothing, which is the
|
|
924
|
+
# one thing a list is for. This rescue is the only place holding both.
|
|
925
|
+
# #exception keeps the class and its fields and swaps only the message.
|
|
926
|
+
def name_query_file(error, path)
|
|
927
|
+
reported = Internal::Util.relative(path)
|
|
928
|
+
return error if error.message.include?(reported)
|
|
929
|
+
|
|
930
|
+
error.exception("#{reported}: #{error.message}")
|
|
931
|
+
end
|
|
932
|
+
private :name_query_file
|
|
933
|
+
|
|
910
934
|
def refuse_all!(refusals, considered)
|
|
911
935
|
return if refusals.empty?
|
|
912
936
|
raise refusals.first if refusals.one?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graph_weaver
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Pepper
|
|
@@ -364,7 +364,7 @@ licenses:
|
|
|
364
364
|
- MIT
|
|
365
365
|
metadata:
|
|
366
366
|
bug_tracker_uri: https://github.com/dpep/graph_weaver/issues
|
|
367
|
-
changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.
|
|
367
|
+
changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.6/CHANGELOG.md
|
|
368
368
|
documentation_uri: https://github.com/dpep/graph_weaver/tree/main/docs
|
|
369
369
|
rubygems_mfa_required: 'true'
|
|
370
370
|
source_code_uri: https://github.com/dpep/graph_weaver
|