graph_weaver 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +43 -43
- data/docs/errors.md +7 -3
- data/docs/generated_modules.md +66 -16
- data/docs/getting_started.md +39 -19
- 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 +43 -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 +67 -6
- 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 +23 -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/railtie.rb +250 -11
- 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 +76 -43
- metadata +2 -2
data/lib/graph_weaver/railtie.rb
CHANGED
|
@@ -85,6 +85,12 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
85
85
|
# calling ignore again — check_generated_ignored! refuses instead.
|
|
86
86
|
attr_accessor :ignored_dirs
|
|
87
87
|
|
|
88
|
+
# The outputs left to Zeitwerk, resolved — the trees whose every module is
|
|
89
|
+
# the constant Zeitwerk infers from its path, so hiding them would be
|
|
90
|
+
# hiding code that already works. Decided beside the sweep, because it is
|
|
91
|
+
# what the sweep must not hide.
|
|
92
|
+
attr_accessor :autoloaded_dirs
|
|
93
|
+
|
|
88
94
|
# Whether hiding an output right now would actually hide it: false until
|
|
89
95
|
# the initializer below has swept (it picks up anything declared so far),
|
|
90
96
|
# and false again from the moment Zeitwerk sets the main autoloader up.
|
|
@@ -108,14 +114,24 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
108
114
|
GraphWeaver::Railtie.hide_generated!
|
|
109
115
|
end
|
|
110
116
|
|
|
117
|
+
# ONE rule: a generated tree Zeitwerk would name correctly is left to
|
|
118
|
+
# Zeitwerk; any other is hidden from it and required at boot.
|
|
119
|
+
#
|
|
111
120
|
# generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
|
|
112
121
|
# Generated::PersonQuery from the path — and app/graphql/generated is inside
|
|
113
122
|
# an autoload root by default, so eager loading raised "uninitialized
|
|
114
123
|
# constant Generated::PersonQuery" in production while development (lazy) was
|
|
115
|
-
# fine. prepare_generated! requires
|
|
124
|
+
# fine. prepare_generated! requires those instead. A graph whose `namespace:`
|
|
125
|
+
# is the constant its output path spells has no such mismatch, and requiring
|
|
126
|
+
# it at boot would define its whole parent chain before a query is ever used.
|
|
116
127
|
def self.hide_generated!
|
|
117
128
|
self.ignored_dirs = []
|
|
129
|
+
self.autoloaded_dirs = []
|
|
118
130
|
self.hiding_outputs = true
|
|
131
|
+
# the aligned trees first: they are what the sweep must NOT hide, and
|
|
132
|
+
# generated_paths' own globs cover a graph's output (app/graphql/*/generated
|
|
133
|
+
# matches app/graphql/billing/generated)
|
|
134
|
+
GraphWeaver.graphs.each { |graph| consider_output!(graph) }
|
|
119
135
|
GraphWeaver::Internal::Util.generated_dirs.each { |path| ignore_output!(path) }
|
|
120
136
|
|
|
121
137
|
# Zeitwerk reads its ignore list once, at setup, and nothing in Rails
|
|
@@ -124,12 +140,189 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
124
140
|
# check_generated_ignored! has to be the one to say so.
|
|
125
141
|
main = Rails.autoloaders.main if Rails.autoloaders.respond_to?(:main)
|
|
126
142
|
if main.respond_to?(:on_setup)
|
|
127
|
-
main.on_setup
|
|
143
|
+
main.on_setup do
|
|
144
|
+
GraphWeaver::Railtie.hiding_outputs = false
|
|
145
|
+
GraphWeaver::Railtie.check_alignment!
|
|
146
|
+
end
|
|
128
147
|
else
|
|
129
148
|
self.hiding_outputs = false
|
|
130
149
|
end
|
|
131
150
|
end
|
|
132
151
|
|
|
152
|
+
# Which side of the rule one graph's output falls, and say so in a line at
|
|
153
|
+
# debug: a user who set `namespace:` and expected laziness learns here why
|
|
154
|
+
# they didn't get it. True when the tree is Zeitwerk's to load.
|
|
155
|
+
def self.consider_output!(graph)
|
|
156
|
+
dir = autoload_path(graph.output)
|
|
157
|
+
return true if autoloaded_dirs.include?(dir)
|
|
158
|
+
# already hidden by an earlier pass, and an ignore can't be taken back
|
|
159
|
+
return false if ignored_dirs.include?(dir)
|
|
160
|
+
|
|
161
|
+
reason = misalignment(graph, dir)
|
|
162
|
+
subject, = describe_output(dir)
|
|
163
|
+
if reason
|
|
164
|
+
GraphWeaver::Internal::Log.log(:debug) do
|
|
165
|
+
"#{subject} is hidden from Zeitwerk and required at boot — #{reason}"
|
|
166
|
+
end
|
|
167
|
+
# here rather than back in ignore_output!, so the decision is recorded
|
|
168
|
+
# the moment it is made and the sweep doesn't reach it a second time
|
|
169
|
+
hide!(dir)
|
|
170
|
+
return false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
autoloaded_dirs << dir
|
|
174
|
+
# types.rb requires its own directory — one file per type, in the order the
|
|
175
|
+
# runtime needs — so Zeitwerk never autoloads those files one at a time.
|
|
176
|
+
# Only eager loading walks them, and it would want a constant name for each
|
|
177
|
+
# that the generator's own inflection doesn't promise: HTTPHeaderInput
|
|
178
|
+
# writes httpheader_input.rb, which Zeitwerk reads as HttpheaderInput.
|
|
179
|
+
# Loading types.rb is what loads them, and types.rb is eager loaded.
|
|
180
|
+
Rails.autoloaders.each { |loader| loader.do_not_eager_load(File.join(dir, "types")) }
|
|
181
|
+
GraphWeaver::Internal::Log.log(:debug) do
|
|
182
|
+
"#{subject} is left to Zeitwerk — every module it generates is the constant Zeitwerk reads " \
|
|
183
|
+
"off the path, so it autoloads on first reference and reloads with the app"
|
|
184
|
+
end
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Why Zeitwerk would NOT name what this graph generates the way its files do
|
|
189
|
+
# — nil when it would, which is the aligned case. The first disagreement is
|
|
190
|
+
# the whole answer: one namespace settles every file, so the fix for one is
|
|
191
|
+
# the fix for all of them.
|
|
192
|
+
def self.misalignment(graph, dir)
|
|
193
|
+
main = Rails.autoloaders.main if Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:main)
|
|
194
|
+
# 2.6.2 is where cpath_expected_at arrives, and with it the only answer
|
|
195
|
+
# that can confirm this one at setup — without it nothing is left to
|
|
196
|
+
# Zeitwerk, which is what every app got before.
|
|
197
|
+
unless main.respond_to?(:cpath_expected_at)
|
|
198
|
+
return "no autoloader here answers cpath_expected_at (Zeitwerk 2.6.2 and up does)"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if ignored_by_app?(dir)
|
|
202
|
+
return "it is already hidden from autoloading — a GraphWeaver.generated_paths pattern reaches " \
|
|
203
|
+
"it, or the app ignored it itself"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
namespace = expected_cpath(dir)
|
|
207
|
+
return "it is not under an autoload path, so nothing but this would load it" unless namespace
|
|
208
|
+
unless namespace == graph.namespace
|
|
209
|
+
return "Zeitwerk reads that directory as #{namespace}, and this graph's constants are " \
|
|
210
|
+
"#{graph.namespace || "top-level"} (namespace #{namespace.inspect} makes them agree)"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
types = expected_cpath(File.join(dir, "types.rb"))
|
|
214
|
+
return "types.rb defines #{graph.types_module}, Zeitwerk expects #{types}" unless types == graph.types_module
|
|
215
|
+
|
|
216
|
+
first_mismatch(graph, dir)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The first query module whose name isn't the one Zeitwerk reads off its
|
|
220
|
+
# file. Only reached once the namespace agrees, so what is left is a file
|
|
221
|
+
# name the app's inflector camelizes differently from the generator.
|
|
222
|
+
def self.first_mismatch(graph, dir)
|
|
223
|
+
GraphWeaver::Internal::Util.query_files(graph.queries).each do |query|
|
|
224
|
+
name, filename = graph.generated_names(query, File.read(query))
|
|
225
|
+
expected = expected_cpath(File.join(dir, filename))
|
|
226
|
+
next if expected == name
|
|
227
|
+
|
|
228
|
+
return "#{filename} defines #{name}, Zeitwerk expects #{expected}"
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The constant Zeitwerk will expect at an absolute path, or nil when nothing
|
|
234
|
+
# will autoload it.
|
|
235
|
+
#
|
|
236
|
+
# Zeitwerk answers this itself, with cpath_expected_at — but only once Rails
|
|
237
|
+
# has pushed the main loader's roots, and Rails does that INSIDE
|
|
238
|
+
# :setup_main_autoloader, whose start is the deadline for `ignore`. So the
|
|
239
|
+
# roots here are the list Rails is about to push, and every constant name
|
|
240
|
+
# comes from the loader's own inflector, which an app's `inflect`/`acronym`
|
|
241
|
+
# is already on. check_alignment! re-asks Zeitwerk the moment it can answer.
|
|
242
|
+
def self.expected_cpath(path)
|
|
243
|
+
main = Rails.autoloaders.main
|
|
244
|
+
roots = autoload_roots
|
|
245
|
+
cnames = []
|
|
246
|
+
dir = path
|
|
247
|
+
if path.end_with?(".rb")
|
|
248
|
+
cnames << main.inflector.camelize(File.basename(path, ".rb"), path)
|
|
249
|
+
dir = File.dirname(path)
|
|
250
|
+
end
|
|
251
|
+
until roots.key?(dir)
|
|
252
|
+
parent = File.dirname(dir)
|
|
253
|
+
return if parent == dir || ignored_by_app?(dir)
|
|
254
|
+
|
|
255
|
+
cnames << main.inflector.camelize(File.basename(dir), dir)
|
|
256
|
+
dir = parent
|
|
257
|
+
end
|
|
258
|
+
return if ignored_by_app?(dir)
|
|
259
|
+
|
|
260
|
+
[roots[dir], *cnames.reverse].compact.join("::")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Every directory the main autoloader will have as a root, and the name of
|
|
264
|
+
# the namespace each sits under (nil for Object): the paths Rails is about to
|
|
265
|
+
# push, plus any the app pushed itself — which is the only way a root gets a
|
|
266
|
+
# namespace, and the one thing Rails leaves alone when it pushes the rest.
|
|
267
|
+
def self.autoload_roots
|
|
268
|
+
roots = {}
|
|
269
|
+
if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:autoload_paths)
|
|
270
|
+
once = Rails.autoloaders.respond_to?(:once) ? Rails.autoloaders.once.dirs : []
|
|
271
|
+
ActiveSupport::Dependencies.autoload_paths.each do |path|
|
|
272
|
+
path = path.to_s
|
|
273
|
+
# through autoload_path, so a root and an output under it are spelled
|
|
274
|
+
# the same way — a symlinked checkout (Capistrano's current/) otherwise
|
|
275
|
+
# has the root as Rails wrote it and the output as realpath saw it
|
|
276
|
+
roots[autoload_path(path)] = nil unless once.include?(path)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
Rails.autoloaders.main.dirs(namespaces: true).each do |dir, namespace|
|
|
280
|
+
roots[autoload_path(dir)] = (namespace == Object) ? nil : namespace.name
|
|
281
|
+
end
|
|
282
|
+
roots
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# An app that hid the directory itself has a tree Zeitwerk will not load
|
|
286
|
+
# however it is named — so it is one to require at boot, exactly as before.
|
|
287
|
+
def self.ignored_by_app?(dir)
|
|
288
|
+
asked = IGNORES.find { |name| Rails.autoloaders.main.respond_to?(name) }
|
|
289
|
+
asked && Rails.autoloaders.main.public_send(asked, dir)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The prediction above, against the one answer that is authoritative. A
|
|
293
|
+
# `collapse` between the autoload root and the output, or a root pushed under
|
|
294
|
+
# its own namespace after this railtie looked, changes the name — and neither
|
|
295
|
+
# is readable before setup. Left unsaid, that surfaces as Zeitwerk's own
|
|
296
|
+
# error on the first eager load in production.
|
|
297
|
+
def self.check_alignment!
|
|
298
|
+
main = Rails.autoloaders.main
|
|
299
|
+
Array(autoloaded_dirs).each do |dir|
|
|
300
|
+
# the top level only: types/ is excluded from eager loading, so what
|
|
301
|
+
# Zeitwerk reads off those files never comes up
|
|
302
|
+
Dir[File.join(dir, "*.rb")].sort.each do |file|
|
|
303
|
+
expected = main.cpath_expected_at(file)
|
|
304
|
+
# nil is "I don't see that file" — which is what Zeitwerk answers for a
|
|
305
|
+
# path spelled through a symlink its own roots aren't, so it is not an
|
|
306
|
+
# answer to compare against
|
|
307
|
+
next if expected.nil? || expected == expected_cpath(file)
|
|
308
|
+
|
|
309
|
+
subject, short = describe_output(dir)
|
|
310
|
+
raise GraphWeaver::Error,
|
|
311
|
+
"#{subject} was left to Zeitwerk because the constants it generates are the ones its path " \
|
|
312
|
+
"spells — but Zeitwerk reads #{GraphWeaver::Internal::Util.relative(file)} as " \
|
|
313
|
+
"#{expected.inspect}, not #{expected_cpath(file).inspect}. A `collapse`, or an autoload root " \
|
|
314
|
+
"pushed under its own namespace, over #{short} changes that name: move the output out from " \
|
|
315
|
+
"under it, or set namespace: to what Zeitwerk reads."
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether this generated directory is one Zeitwerk is loading — what
|
|
321
|
+
# Internal::Util.required_dirs asks, so the boot-time require skips it.
|
|
322
|
+
def self.left_to_zeitwerk?(path)
|
|
323
|
+
Array(autoloaded_dirs).include?(autoload_path(path))
|
|
324
|
+
end
|
|
325
|
+
|
|
133
326
|
# Hide one output from Zeitwerk, the moment it is named — a graph declared in
|
|
134
327
|
# config/initializers says where it writes long after the sweep above ran.
|
|
135
328
|
# Waiting for it instead is what `after: :load_config_initializers` used to
|
|
@@ -141,9 +334,17 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
141
334
|
def self.ignore_output!(path)
|
|
142
335
|
return unless hiding_outputs
|
|
143
336
|
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
337
|
+
# a graph saying where it writes is also the moment the rule can be applied
|
|
338
|
+
# to it: config/initializers is later than the sweep above, so this is
|
|
339
|
+
# where an app's second graph is first heard of
|
|
340
|
+
graph = GraphWeaver.graphs.find { |candidate| autoload_path(candidate.output) == autoload_path(path) }
|
|
341
|
+
return if graph && consider_output!(graph)
|
|
342
|
+
|
|
343
|
+
hidden_for(path).each { |dir| hide!(dir) }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Hide one resolved directory, once.
|
|
347
|
+
def self.hide!(dir)
|
|
147
348
|
return if ignored_dirs.include?(dir)
|
|
148
349
|
|
|
149
350
|
check_autoload_once!([dir])
|
|
@@ -151,6 +352,25 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
151
352
|
Rails.autoloaders.each { |loader| loader.ignore(dir) }
|
|
152
353
|
end
|
|
153
354
|
|
|
355
|
+
# What hiding one generated path means. Normally the pattern itself —
|
|
356
|
+
# generated_paths may be globs, and Zeitwerk expands its own at setup, so a
|
|
357
|
+
# directory a pattern will match is hidden however late it appears; that is
|
|
358
|
+
# what the window stays open until. The exception is a pattern that also
|
|
359
|
+
# covers a tree Zeitwerk is keeping (app/graphql/*/generated matches a
|
|
360
|
+
# namespaced graph's own output), since a glob can't say "all but this one":
|
|
361
|
+
# then it is expanded here and the aligned directories left out.
|
|
362
|
+
def self.hidden_for(path)
|
|
363
|
+
dir = autoload_path(path)
|
|
364
|
+
kept = Array(autoloaded_dirs)
|
|
365
|
+
return [dir] if kept.empty?
|
|
366
|
+
|
|
367
|
+
expanded = Dir[GraphWeaver::Internal::Util.resolve(path)]
|
|
368
|
+
.select { |found| File.directory?(found) }.map { |found| autoload_path(found) }
|
|
369
|
+
return [dir] if (expanded & kept).empty?
|
|
370
|
+
|
|
371
|
+
expanded - kept
|
|
372
|
+
end
|
|
373
|
+
|
|
154
374
|
# The `once` autoloader is set up in bootstrap, before any of this — and
|
|
155
375
|
# Zeitwerk reads its ignore list only at setup, so `loader.ignore` hides
|
|
156
376
|
# nothing from it however the output is spelled. The generic advice ("name it
|
|
@@ -211,7 +431,9 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
211
431
|
def self.describe_output(dir)
|
|
212
432
|
graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
|
|
213
433
|
short = GraphWeaver::Internal::Util.relative(GraphWeaver::Internal::Util.resolve(graph&.output || dir))
|
|
214
|
-
|
|
434
|
+
# the default graph has no name to say, and "graph :'s output" is worse
|
|
435
|
+
# than the path on its own
|
|
436
|
+
["#{graph&.name ? "graph #{graph.name.inspect}'s output" : "generated path"} #{short}", short]
|
|
215
437
|
end
|
|
216
438
|
|
|
217
439
|
# A graph declared from to_prepare — what the docs say to do when its block
|
|
@@ -219,16 +441,20 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
219
441
|
# Zeitwerk reads its ignore list only then. So an output that arrives that
|
|
220
442
|
# late can't be hidden: its files load as ordinary autoloads and raise on the
|
|
221
443
|
# constant they don't define, in a Zeitwerk error that blames a dropped
|
|
222
|
-
# extend_type. Refuse, and name what actually happened
|
|
444
|
+
# extend_type. Refuse, and name what actually happened — unless the tree is
|
|
445
|
+
# one Zeitwerk names correctly, which needed no hiding at any point and is as
|
|
446
|
+
# true declared late as declared early.
|
|
223
447
|
def self.check_generated_ignored!
|
|
224
448
|
# no autoloaders, no Zeitwerk, nothing to refuse
|
|
225
449
|
return unless Rails.respond_to?(:autoloaders)
|
|
226
450
|
|
|
227
|
-
late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } -
|
|
451
|
+
late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } -
|
|
452
|
+
Array(ignored_dirs) - Array(autoloaded_dirs)
|
|
228
453
|
return if late.empty?
|
|
229
454
|
|
|
230
455
|
late.each do |dir|
|
|
231
456
|
next unless Rails.autoloaders.any? { |loader| autoloaded?(loader, dir) }
|
|
457
|
+
next if aligned_late?(dir)
|
|
232
458
|
|
|
233
459
|
subject, short = describe_output(dir)
|
|
234
460
|
raise GraphWeaver::Error,
|
|
@@ -239,6 +465,17 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
239
465
|
end
|
|
240
466
|
end
|
|
241
467
|
|
|
468
|
+
# A graph declared after setup, whose tree Zeitwerk already names correctly:
|
|
469
|
+
# nothing had to be hidden, so nothing was missed. Recorded on the way past,
|
|
470
|
+
# so the boot-time require skips it too.
|
|
471
|
+
def self.aligned_late?(dir)
|
|
472
|
+
graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
|
|
473
|
+
return false unless graph && misalignment(graph, dir).nil?
|
|
474
|
+
|
|
475
|
+
self.autoloaded_dirs = Array(autoloaded_dirs) | [dir]
|
|
476
|
+
true
|
|
477
|
+
end
|
|
478
|
+
|
|
242
479
|
# The two auto-wires an app gets for free, and the only two it can turn off
|
|
243
480
|
# by assigning nil.
|
|
244
481
|
#
|
|
@@ -408,8 +645,10 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
408
645
|
# reloaded what it wrote.
|
|
409
646
|
return if watcher&.execute_if_updated
|
|
410
647
|
|
|
411
|
-
# entries may be globs, so Dir[] rather than Dir.exist
|
|
412
|
-
|
|
648
|
+
# entries may be globs, so Dir[] rather than Dir.exist?. required_dirs, not
|
|
649
|
+
# generated_dirs: a tree left to Zeitwerk loads on first reference, and not
|
|
650
|
+
# requiring its whole parent chain here is the point of leaving it there.
|
|
651
|
+
generated = GraphWeaver::Internal::Util.required_dirs.any? do |dir|
|
|
413
652
|
Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
|
|
414
653
|
end
|
|
415
654
|
return unless generated
|
|
@@ -422,7 +661,7 @@ class GraphWeaver::Railtie < Rails::Railtie
|
|
|
422
661
|
# request 500s on "uninitialized constant Accounts::PersonQuery" until a
|
|
423
662
|
# .graphql edit happens to trigger the watcher. An un-namespaced module
|
|
424
663
|
# defines a top-level constant Zeitwerk never manages, so it survives.
|
|
425
|
-
if GraphWeaver.graphs.any?(
|
|
664
|
+
if GraphWeaver.graphs.any? { |graph| graph.namespace && !left_to_zeitwerk?(graph.output) }
|
|
426
665
|
GraphWeaver.reload_generated!
|
|
427
666
|
else
|
|
428
667
|
GraphWeaver.load_generated!
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -161,7 +161,9 @@ module GraphWeaver
|
|
|
161
161
|
# suite's WebMock setup is touched.
|
|
162
162
|
def self.serve!
|
|
163
163
|
webmock!
|
|
164
|
-
|
|
164
|
+
targets, above = wire_targets
|
|
165
|
+
above.each { |graph| disclose_above!(graph) }
|
|
166
|
+
targets.map do |url, graph|
|
|
165
167
|
# built here, so a graph with nothing to serve refuses before the
|
|
166
168
|
# example runs rather than from inside its first request
|
|
167
169
|
disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
|
|
@@ -210,6 +212,17 @@ module GraphWeaver
|
|
|
210
212
|
end
|
|
211
213
|
end
|
|
212
214
|
|
|
215
|
+
# Say which graph ran above the wire. Its stand-in is built when one of
|
|
216
|
+
# its modules first runs, not here — a graph the example never touches
|
|
217
|
+
# must not refuse it — so this names the graph rather than what is
|
|
218
|
+
# behind it.
|
|
219
|
+
def self.disclose_above!(graph)
|
|
220
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
221
|
+
":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
|
|
222
|
+
"its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
213
226
|
# What the stand-in IS, read off the object rather than re-deciding —
|
|
214
227
|
# one answer, and it can't drift from what was built. A fake of a dump
|
|
215
228
|
# has no name to give: the dump loads as an anonymous class, and
|
|
@@ -254,21 +267,24 @@ module GraphWeaver
|
|
|
254
267
|
# inside the cleanup.
|
|
255
268
|
def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
|
|
256
269
|
|
|
257
|
-
#
|
|
258
|
-
# whose resolvers belong behind it
|
|
259
|
-
#
|
|
260
|
-
#
|
|
270
|
+
# What :wire does with each graph, in two lists: the endpoints to stub,
|
|
271
|
+
# each with the graph whose resolvers belong behind it, and the graphs
|
|
272
|
+
# there is no endpoint for. One graph per endpoint — an app whose graphs
|
|
273
|
+
# all name clients needs no app default at all.
|
|
274
|
+
#
|
|
275
|
+
# A graph whose client posts to no url has no wire to be served at, so
|
|
276
|
+
# it runs above one instead of refusing the example — including the
|
|
277
|
+
# examples that never touch it. An example where NO graph posts anywhere
|
|
278
|
+
# is refused, since a :wire that serves nothing tests no transport.
|
|
261
279
|
def self.wire_targets
|
|
262
|
-
targets = GraphWeaver.graphs.
|
|
263
|
-
client = graph.client || GraphWeaver.client
|
|
264
|
-
[endpoint!(client, graph), graph] if client
|
|
265
|
-
end
|
|
280
|
+
targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
|
|
266
281
|
refuse_shared_endpoint!(targets)
|
|
267
|
-
return targets if targets.any?
|
|
282
|
+
return [targets, above.map(&:last)] if targets.any?
|
|
268
283
|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
|
|
284
|
+
# the endpoint refusal names the client that posts to none, which is
|
|
285
|
+
# the thing to fix
|
|
286
|
+
graph = GraphWeaver.graphs.first
|
|
287
|
+
endpoint!(graph&.client || GraphWeaver.client, graph)
|
|
272
288
|
end
|
|
273
289
|
|
|
274
290
|
# One stub per url, so two graphs on one endpoint used to mean the
|
|
@@ -343,7 +359,8 @@ module GraphWeaver
|
|
|
343
359
|
end
|
|
344
360
|
|
|
345
361
|
private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
|
|
346
|
-
:webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas,
|
|
362
|
+
:webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
|
|
363
|
+
:loaded_schemas
|
|
347
364
|
|
|
348
365
|
# Included into every example group, so graphql_context is there
|
|
349
366
|
# whether or not this example took a client from the hook.
|
|
@@ -570,10 +587,17 @@ module GraphWeaver
|
|
|
570
587
|
raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
|
|
571
588
|
"#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
|
|
572
589
|
"#{TAG}: :router (or pin the data itself: " \
|
|
573
|
-
"graphql_fake(
|
|
590
|
+
"graphql_fake(\"Person.name\" => \"Ada\"))"
|
|
574
591
|
else
|
|
575
|
-
|
|
576
|
-
|
|
592
|
+
# :live, which is what an untagged example is. The app's own client
|
|
593
|
+
# stays in the slot holding the context it was built with, so there
|
|
594
|
+
# is nothing here to merge onto — and saying "you need resolvers"
|
|
595
|
+
# reads as false to an app whose own client is an InProcess, which
|
|
596
|
+
# is running them
|
|
597
|
+
raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
|
|
598
|
+
"and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
|
|
599
|
+
"context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
|
|
600
|
+
"which build a client per example."
|
|
577
601
|
end
|
|
578
602
|
end
|
|
579
603
|
|
|
@@ -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
|