graph_weaver 0.7.2 → 0.7.4

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.
@@ -7,16 +7,25 @@
7
7
  # rake_tasks block, so graph_weaver:* tasks appear with no Rakefile
8
8
  # edit. (Outside Rails there is no task-discovery hook — add
9
9
  # `require "graph_weaver/tasks"` to your Rakefile.)
10
- # - generated modules: required at boot once every registration has run —
11
- # both the initializer kind and the to_prepare kind, since a generated
12
- # file `include`s the type helper it was generated with and that
13
- # constant must resolve. load_generated! stays idempotent, so calling
14
- # it yourself too is harmless.
10
+ # - generated modules: required once every registration has run — both the
11
+ # initializer kind and the to_prepare kind, since a generated file
12
+ # `include`s the type helper it was generated with and that constant must
13
+ # resolve. load_generated! stays idempotent, so calling it yourself too is
14
+ # harmless.
15
15
  # - Zeitwerk: the generated directory is hidden from it, since the
16
16
  # default one lives under app/ and its files define top-level
17
17
  # constants.
18
18
  # - watch mode: in development, editing a .graphql regenerates before the
19
19
  # next request, the way editing a route or a locale takes effect.
20
+ #
21
+ # All of that is hung off Rails' lifecycle HOOKS — before_initialize,
22
+ # after_initialize, the reloader — rather than off initializers with
23
+ # `after:`/`before:` edges naming other railties' initializers. Rails
24
+ # topologically sorts every railtie's initializers together, so such an edge
25
+ # constrains the whole app's boot order and tsort is free to satisfy it by
26
+ # moving initializers that aren't ours: a real app failed to boot with
27
+ # graph_weaver in the Gemfile and every one of these bodies neutered. A hook
28
+ # has a fixed place in boot and adds no edge. See DECISIONS.md.
20
29
  class GraphWeaver::Railtie < Rails::Railtie
21
30
  # config.graph_weaver.watch — false to never regenerate during a request.
22
31
  # Default: development only. Every other setting is a top-level one, and an
@@ -62,94 +71,87 @@ class GraphWeaver::Railtie < Rails::Railtie
62
71
  config.graph_weaver = Options.new
63
72
 
64
73
  class << self
65
- # The file watcher, so the to_prepare block below can ask it whether a
66
- # query changed. nil when not watching.
74
+ # The file watcher, so the prepare hook below can ask it whether a query
75
+ # changed. nil when not watching.
67
76
  attr_accessor :watcher
68
77
 
69
- # What ignore_generated actually hid, resolved. Zeitwerk only reads its
78
+ # What has been hidden from Zeitwerk, resolved. Zeitwerk only reads its
70
79
  # ignore list at setup, so anything that arrives later isn't hidden by
71
80
  # calling ignore again — check_generated_ignored! refuses instead.
72
81
  attr_accessor :ignored_dirs
82
+
83
+ # Whether hiding an output right now would actually hide it: false until
84
+ # the initializer below has swept (it picks up anything declared so far),
85
+ # and false again from the moment Zeitwerk sets the main autoloader up.
86
+ attr_accessor :hiding_outputs
73
87
  end
74
88
 
75
89
  rake_tasks do
76
90
  require "graph_weaver/tasks"
77
91
  end
78
92
 
79
- # The two auto-wires an app gets for free, and the only two it can turn off
80
- # by assigning nil. They are declared FIRST, and `before:
81
- # :load_config_initializers`, and both of those are load-bearing:
93
+ # ONE initializer, and it declares no `after:` see the note on the class.
94
+ # The `before:` that is left is inert: it only says "not after Zeitwerk's
95
+ # setup", and this is emitted at its own place in the railties block anyway,
96
+ # so nothing moves. It records the deadline.
82
97
  #
83
- # Rails gives an initializer an implicit `after:` of the previous one in the
84
- # same railtie (Initializable#initializer: `opts[:after] ||=
85
- # initializers.last&.name`). Declared after ignore_generated, which is
86
- # `after: :load_config_initializers`, these two inherited that position —
87
- # they ran AFTER config/initializers, so the `if nil?` fallback overwrote an
88
- # app that had just said nil and the documented PII opt-out did nothing,
89
- # silently, while queries and variables kept reaching a debug Rails.logger.
90
- # Adding the `before:` without moving them is a TSort::Cyclic at boot, since
91
- # the implicit `after:` would then point through ignore_generated.
92
- #
93
- # Rails.logger, unless the app already chose one (set
94
- # GraphWeaver.logger = nil in an initializer to silence)
95
- initializer "graph_weaver.logger", before: :load_config_initializers do
96
- GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
98
+ # What it sweeps is what config/application.rb configured. A graph declared
99
+ # in config/initializers is later than this and hides its own output as it is
100
+ # declared (ignore_output!) which is where that knowledge arrives, and so
101
+ # needs no edge to wait for.
102
+ initializer "graph_weaver.ignore_generated", before: :setup_main_autoloader do
103
+ GraphWeaver::Railtie.hide_generated!
97
104
  end
98
105
 
99
- # An APM sees every GraphQL call without the app configuring anything:
100
- # the ActiveSupport::Notifications adapter from docs/logging.md, plus the
101
- # LogSubscriber that turns its event into one line. Measured at ~4.5µs per
102
- # execution all told (0.13µs of that ActiveSupport::Notifications itself
103
- # with nothing subscribed; the rest is its Event machinery) — 0.05% of a
104
- # 10ms round trip, so there is nothing to weigh.
105
- #
106
- # An instrumenter the app set is never replaced: assigned before this (in
107
- # config/application.rb) the nil check leaves it, and config/initializers now
108
- # genuinely runs later, so one assigned there wins on its own — including
109
- # `GraphWeaver.instrumenter = nil` to opt out.
110
- initializer "graph_weaver.instrumentation", before: :load_config_initializers do
111
- next unless defined?(ActiveSupport::Notifications)
112
-
113
- if GraphWeaver.instrumenter.nil?
114
- GraphWeaver.instrumenter = lambda do |event, payload, &block|
115
- ActiveSupport::Notifications.instrument(event, payload, &block)
116
- end
106
+ # generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
107
+ # Generated::PersonQuery from the path and app/graphql/generated is inside
108
+ # an autoload root by default, so eager loading raised "uninitialized
109
+ # constant Generated::PersonQuery" in production while development (lazy) was
110
+ # fine. prepare_generated! requires them instead.
111
+ def self.hide_generated!
112
+ self.ignored_dirs = []
113
+ self.hiding_outputs = true
114
+ GraphWeaver::Internal::Util.generated_dirs.each { |path| ignore_output!(path) }
115
+
116
+ # Zeitwerk reads its ignore list once, at setup, and nothing in Rails
117
+ # announces that moment — Zeitwerk itself does. With no such signal, shut
118
+ # the window here: an ignore registered after setup hides nothing, and
119
+ # check_generated_ignored! has to be the one to say so.
120
+ main = Rails.autoloaders.main if Rails.autoloaders.respond_to?(:main)
121
+ if main.respond_to?(:on_setup)
122
+ main.on_setup { GraphWeaver::Railtie.hiding_outputs = false }
123
+ else
124
+ self.hiding_outputs = false
117
125
  end
118
-
119
- # ActiveSupport::LogSubscriber is one of ActiveSupport's own eager
120
- # autoloads, so naming it is enough — no require of theirs needed
121
- require "graph_weaver/log_subscriber"
122
- # idempotent — Subscriber.add_event_subscriber skips a pattern it already has
123
- GraphWeaver::LogSubscriber.attach_to :graph_weaver
124
126
  end
125
127
 
126
- # generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
127
- # Generated::PersonQuery from the path and app/graphql/generated is
128
- # inside an autoload root by default, so eager loading raised
129
- # "uninitialized constant Generated::PersonQuery" in production while
130
- # development (lazy) was fine. load_generated! below requires them.
128
+ # Hide one output from Zeitwerk, the moment it is named — a graph declared in
129
+ # config/initializers says where it writes long after the sweep above ran.
130
+ # Waiting for it instead is what `after: :load_config_initializers` used to
131
+ # buy, at the cost of the whole app's boot order.
131
132
  #
132
- # after: :load_config_initializers as well as before Zeitwerk's setup a
133
- # graph's output: is only known once the app has declared its graphs, and
134
- # with only the `before:` constraint this ran ~20 initializers too early, so
135
- # an output outside the conventional glob was eager loaded on top of
136
- # load_generated! and died on a redefined enum.
137
- initializer "graph_weaver.ignore_generated",
138
- after: :load_config_initializers, before: :setup_main_autoloader do
139
- # patterns, not paths generated_paths may be globs, and Zeitwerk
140
- # expands its own at setup (which is what this runs before)
141
- dirs = GraphWeaver::Internal::Util.generated_dirs.map { GraphWeaver::Railtie.autoload_path(_1) }
142
- GraphWeaver::Railtie.check_autoload_once!(dirs)
143
- GraphWeaver::Railtie.ignored_dirs = dirs
144
- Rails.autoloaders.each { |loader| dirs.each { |path| loader.ignore(path) } }
133
+ # Outside the window this is a no-op on purpose: before it, the sweep will
134
+ # pick the path up; after it, calling Zeitwerk's `ignore` would only teach
135
+ # check_generated_ignored! a lie.
136
+ def self.ignore_output!(path)
137
+ return unless hiding_outputs
138
+
139
+ # patterns, not paths — generated_paths may be globs, and Zeitwerk expands
140
+ # its own at setup, which is what the window stays open until
141
+ dir = autoload_path(path)
142
+ return if ignored_dirs.include?(dir)
143
+
144
+ check_autoload_once!([dir])
145
+ ignored_dirs << dir
146
+ Rails.autoloaders.each { |loader| loader.ignore(dir) }
145
147
  end
146
148
 
147
- # The `once` autoloader is set up in bootstrap, and Zeitwerk reads its ignore
148
- # list only at setup so the `loader.ignore` above, which runs after
149
- # config/initializers, hides nothing from it however the output is spelled.
150
- # The generic advice ("name it in GraphWeaver.generated_paths from
151
- # config/initializers") produced byte-identical output for this one, so it
152
- # gets its own refusal, naming the place that is still early enough.
149
+ # The `once` autoloader is set up in bootstrap, before any of this — and
150
+ # Zeitwerk reads its ignore list only at setup, so `loader.ignore` hides
151
+ # nothing from it however the output is spelled. The generic advice ("name it
152
+ # in GraphWeaver.generated_paths from config/initializers") produced
153
+ # byte-identical output for this one, so it gets its own refusal, naming the
154
+ # place that is still early enough.
153
155
  def self.check_autoload_once!(dirs)
154
156
  return unless Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:once)
155
157
 
@@ -232,13 +234,64 @@ class GraphWeaver::Railtie < Rails::Railtie
232
234
  end
233
235
  end
234
236
 
237
+ # The two auto-wires an app gets for free, and the only two it can turn off
238
+ # by assigning nil.
239
+ #
240
+ # before_initialize runs in Rails' own :bootstrap_hook — after
241
+ # :initialize_logger and before the first railtie initializer, so the default
242
+ # is in place for anything that boots, and config/initializers still runs
243
+ # later and still wins. That timing used to be a `before:
244
+ # :load_config_initializers` edge, and getting it wrong shipped: declared
245
+ # without one, these ran AFTER config/initializers, so the `if nil?` fallback
246
+ # overwrote an app that had just said nil — the documented PII opt-out did
247
+ # nothing, silently, while queries and variables kept reaching a debug
248
+ # Rails.logger.
249
+ config.before_initialize { GraphWeaver::Railtie.default_logger! }
250
+
251
+ # Rails.logger, unless the app already chose one (set GraphWeaver.logger =
252
+ # nil in an initializer to silence)
253
+ def self.default_logger!
254
+ GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
255
+ end
256
+
257
+ # An APM sees every GraphQL call without the app configuring anything:
258
+ # the ActiveSupport::Notifications adapter from docs/logging.md, plus the
259
+ # LogSubscriber that turns its event into one line. Measured at ~4.5µs per
260
+ # execution all told (0.13µs of that ActiveSupport::Notifications itself
261
+ # with nothing subscribed; the rest is its Event machinery) — 0.05% of a
262
+ # 10ms round trip, so there is nothing to weigh.
263
+ #
264
+ # An instrumenter the app set is never replaced: assigned before this (in
265
+ # config/application.rb) the nil check leaves it, and config/initializers runs
266
+ # later, so one assigned there wins on its own — including
267
+ # `GraphWeaver.instrumenter = nil` to opt out.
268
+ config.before_initialize { GraphWeaver::Railtie.default_instrumenter! }
269
+
270
+ def self.default_instrumenter!
271
+ return unless defined?(ActiveSupport::Notifications)
272
+
273
+ if GraphWeaver.instrumenter.nil?
274
+ GraphWeaver.instrumenter = lambda do |event, payload, &block|
275
+ ActiveSupport::Notifications.instrument(event, payload, &block)
276
+ end
277
+ end
278
+
279
+ # ActiveSupport::LogSubscriber is one of ActiveSupport's own eager
280
+ # autoloads, so naming it is enough — no require of theirs needed
281
+ require "graph_weaver/log_subscriber"
282
+ # idempotent — Subscriber.add_event_subscriber skips a pattern it already has
283
+ GraphWeaver::LogSubscriber.attach_to :graph_weaver
284
+ end
285
+
235
286
  # The app already declared what is sensitive, so variables logged at debug
236
287
  # honour the same list as its request logs — including the Procs and dotted
237
- # paths only ParameterFilter understands. after: :load_config_initializers,
238
- # since filter_parameter_logging.rb is where an app adds to it.
239
- initializer "graph_weaver.filter_parameters", after: :load_config_initializers do |app|
288
+ # paths only ParameterFilter understands. after_initialize, since
289
+ # filter_parameter_logging.rb is where an app adds to it.
290
+ config.after_initialize { |app| GraphWeaver::Railtie.adopt_filter_parameters!(app) }
291
+
292
+ def self.adopt_filter_parameters!(app)
240
293
  filters = app.config.filter_parameters
241
- next if filters.empty? || GraphWeaver.filter_parameters != GraphWeaver::DEFAULT_FILTER_PARAMETERS
294
+ return if filters.empty? || GraphWeaver.filter_parameters != GraphWeaver::DEFAULT_FILTER_PARAMETERS
242
295
 
243
296
  GraphWeaver.filter_parameters = ActiveSupport::ParameterFilter.new(filters)
244
297
  end
@@ -246,41 +299,31 @@ class GraphWeaver::Railtie < Rails::Railtie
246
299
  # Watch mode. A .graphql edit should reach the next request the way a route
247
300
  # or a locale change does, so the query directories and the schema dump
248
301
  # become one of Rails' own reloaders: a change there alone triggers a reload
249
- # cycle, and the to_prepare below regenerates before it loads. Off with
302
+ # cycle, and the prepare hook below regenerates before it loads. Off with
250
303
  #
251
304
  # config.graph_weaver.watch = false
252
305
  #
253
- # after: :load_config_initializers that's where an app moves
254
- # queries_paths, and the finisher that reads app.reloaders runs later still.
255
- #
256
- # to_prepare, not the initializer itself: a graph declared from one of those
257
- # (what the docs say to do when its block names an autoloaded constant) isn't
258
- # declared until every initializer has run, and a watcher built before it
259
- # watched the default queries_paths — an edit to that graph's .graphql
260
- # silently never regenerated. to_prepare blocks run in registration order and
261
- # an app registers its own during :load_config_initializers, which this is
262
- # `after:`, so every graph is in by the time this runs; app.reloaders is read
263
- # per request, so joining it this late still counts. Once, though: a dev
264
- # reload re-runs to_prepare, and a second watcher is a second reloader over
265
- # the same files.
266
- initializer "graph_weaver.watch", after: :load_config_initializers do |app|
267
- watched = false
268
- app.config.to_prepare do
269
- next if watched
270
-
271
- watched = true
272
- GraphWeaver::Railtie.watch!(app)
273
- end
274
- end
306
+ # after_initialize, because every graph has to be declared before the watcher
307
+ # is built: a graph declared from a to_prepare block (what the docs say to do
308
+ # when its block names an autoloaded constant) isn't declared until the
309
+ # prepare callbacks have run, and a watcher built before it watched the
310
+ # default queries_paths an edit to that graph's .graphql silently never
311
+ # regenerated. app.reloaders is read per request, so joining it this late
312
+ # still counts.
313
+ config.after_initialize { |app| GraphWeaver::Railtie.watch!(app) }
275
314
 
276
315
  # Registers the watcher, and says so: this is the one thing GraphWeaver does
277
316
  # that writes a checked-in file outside a rake task. Returns it, or nil when
278
317
  # nothing is being watched.
279
318
  def self.watch!(app)
319
+ # exactly one watcher per app, however many times this is called — a second
320
+ # one is a second reloader polling the same files
321
+ app.reloaders.delete(watcher) if watcher
322
+
280
323
  watch = app.config.graph_weaver.watch
281
324
  watch = Rails.env.development? if watch.nil?
282
- # with reloading off nothing re-runs to_prepare, so a watcher could only
283
- # promise something it can't do
325
+ # with reloading off nothing re-runs the prepare hook, so a watcher could
326
+ # only promise something it can't do
284
327
  return self.watcher = nil unless watch && app.config.reloading_enabled?
285
328
 
286
329
  # a directory that doesn't exist yet is still watched — FileUpdateChecker
@@ -321,51 +364,63 @@ class GraphWeaver::Railtie < Rails::Railtie
321
364
  end
322
365
 
323
366
  # A generated file `include`s the type helper it was generated with, so it
324
- # can't load until that constant resolves — and both Zeitwerk's setup and
325
- # the app's own to_prepare blocks (where extend_type/register_enum are told
326
- # to register, Codegen::AUTOLOAD_HINT) happen after config/initializers.
327
- # to_prepare, not `after:` a finisher initializer: naming one there makes
328
- # tsort hoist it ahead of the app's own config/initializers. Re-running on
329
- # each dev reload is free require is idempotent and picks up a module
330
- # generated since boot.
331
- initializer "graph_weaver.load_generated", after: :load_config_initializers do |app|
332
- app.config.to_prepare do
333
- # The graph_weaver tasks write these files and need none of them loaded.
334
- # Loading them would let a stale one block its own repair: a dropped
335
- # extend_type leaves a dangling include, and generate depends on
336
- # :environment, so boot failed before the task that would regenerate it.
337
- next if GraphWeaver.skip_generated_load
338
-
339
- # the app's own to_prepare blocks have run by now, so this is the first
340
- # point that sees every graph and the last before one of them loads
341
- GraphWeaver::Railtie.check_generated_ignored!
342
-
343
- # Regenerate first, then load — and here rather than in the watcher's own
344
- # to_run, so an extend_type or register_enum the app registers in its own
345
- # to_prepare is already in place (that block was registered at
346
- # :load_config_initializers, so it has run by now). A run that
347
- # regenerated has already reloaded what it wrote.
348
- next if GraphWeaver::Railtie.watcher&.execute_if_updated
349
-
350
- # entries may be globs, so Dir[] rather than Dir.exist?
351
- generated = GraphWeaver::Internal::Util.generated_dirs.any? do |dir|
352
- Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
353
- end
354
- next unless generated
355
-
356
- # `require` no-ops on a file it has already read — which is what we want,
357
- # except when the constant that file defined is gone. A graph's
358
- # `namespace:` is normally a module Zeitwerk owns (app/graphql/accounts/
359
- # implies Accounts), and unloading it on a dev reload takes the generated
360
- # module nested inside it with it; require then restores nothing and every
361
- # request 500s on "uninitialized constant Accounts::PersonQuery" until a
362
- # .graphql edit happens to trigger the watcher. An un-namespaced module
363
- # defines a top-level constant Zeitwerk never manages, so it survives.
364
- if GraphWeaver.graphs.any?(&:namespace)
365
- GraphWeaver.reload_generated!
366
- else
367
- GraphWeaver.load_generated!
368
- end
367
+ # can't load until that constant resolves — and both Zeitwerk's setup and the
368
+ # app's own to_prepare blocks (where extend_type/register_enum are told to
369
+ # register, Codegen::AUTOLOAD_HINT) happen after config/initializers.
370
+ # after_initialize is past all of them, and past the watcher above.
371
+ #
372
+ # Except where the app eager loads: an eager-loaded class may name a
373
+ # generated constant in its class body, and Zeitwerk can't resolve one (the
374
+ # directory is ignored, by design), so the modules have to be in place before
375
+ # Rails' :eager_load! rather than after it. before_eager_load is that point,
376
+ # and Rails only runs it when config.eager_load is on which is exactly when
377
+ # after_initialize must not do the work a second time.
378
+ config.before_eager_load { GraphWeaver::Railtie.prepare_generated! }
379
+
380
+ config.after_initialize do |app|
381
+ GraphWeaver::Railtie.prepare_generated! unless app.config.eager_load
382
+ # and again on every dev reload, which picks up a module generated since
383
+ # boot and re-requires one whose namespace Zeitwerk just unloaded. On the
384
+ # reloader itself, not config.to_prepare: the :add_to_prepare_blocks
385
+ # finisher has already drained that list by now.
386
+ ActiveSupport::Reloader.to_prepare { GraphWeaver::Railtie.prepare_generated! }
387
+ end
388
+
389
+ def self.prepare_generated!
390
+ # The graph_weaver tasks write these files and need none of them loaded.
391
+ # Loading them would let a stale one block its own repair: a dropped
392
+ # extend_type leaves a dangling include, and generate depends on
393
+ # :environment, so boot failed before the task that would regenerate it.
394
+ return if GraphWeaver.skip_generated_load
395
+
396
+ # every graph is declared by now, and this is the last point before one of
397
+ # them loads
398
+ check_generated_ignored!
399
+
400
+ # Regenerate first, then load and here rather than in the watcher's own
401
+ # to_run, so an extend_type or register_enum the app registers in its own
402
+ # to_prepare is already in place. A run that regenerated has already
403
+ # reloaded what it wrote.
404
+ return if watcher&.execute_if_updated
405
+
406
+ # entries may be globs, so Dir[] rather than Dir.exist?
407
+ generated = GraphWeaver::Internal::Util.generated_dirs.any? do |dir|
408
+ Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
409
+ end
410
+ return unless generated
411
+
412
+ # `require` no-ops on a file it has already read — which is what we want,
413
+ # except when the constant that file defined is gone. A graph's
414
+ # `namespace:` is normally a module Zeitwerk owns (app/graphql/accounts/
415
+ # implies Accounts), and unloading it on a dev reload takes the generated
416
+ # module nested inside it with it; require then restores nothing and every
417
+ # request 500s on "uninitialized constant Accounts::PersonQuery" until a
418
+ # .graphql edit happens to trigger the watcher. An un-namespaced module
419
+ # defines a top-level constant Zeitwerk never manages, so it survives.
420
+ if GraphWeaver.graphs.any?(&:namespace)
421
+ GraphWeaver.reload_generated!
422
+ else
423
+ GraphWeaver.load_generated!
369
424
  end
370
425
  end
371
426
  end
@@ -388,6 +388,107 @@ module GraphWeaver
388
388
  # The configured directory as a real path — a rake task or an rspec run
389
389
  # starts from wherever it starts from; the cassettes don't move.
390
390
  def cassette_dir = Internal::Util.resolve(config.cassette_dir)
391
+
392
+ # Whether a scalar's two definitions agree: the server's
393
+ # coerce_input/coerce_result, and your register_scalar. No schema
394
+ # carries the server's half — a scalar's SDL is its name and a url —
395
+ # so nothing `verify`, `schema:diff` or `generate` reads can say. A
396
+ # schema CLASS carries both, and this runs them against each other.
397
+ #
398
+ # Per scalar the schema declares and your app registered: fabricate a
399
+ # value the way :fake does, cast it, send it back out through
400
+ # `serialize:`, through the server's `coerce_input` and `coerce_result`,
401
+ # and back through `cast:`. Raises naming every scalar that disagreed
402
+ # and how; silent when they all agree.
403
+ #
404
+ # Pass the schema CLASS. A dump's scalars pass values through, so
405
+ # against one this checks only that a registration's `cast:` accepts
406
+ # what its own `serialize:` writes — which is worth knowing, and is not
407
+ # the same question.
408
+ #
409
+ # The fabricated value is what the check has to work with, so pin the
410
+ # one that matters where it matters —
411
+ # `config.overrides = { "Decimal" => "123456789.123456789" }` is how
412
+ # the precision case gets exercised at all.
413
+ def check_scalars!(schema)
414
+ registry = Internal::Util.registry_for(schema)
415
+ values = Internal::Values.new(seed: 0, schema:, registry:)
416
+ context = GraphQL::Query.new(schema, "{ __typename }").context
417
+
418
+ disagreed = schema.types.values.sort_by(&:graphql_name).filter_map do |type|
419
+ next unless type.kind.name == "SCALAR"
420
+ # the built-in entries are the library's own; it is your
421
+ # registration that can be wrong about this server
422
+ next if registry.builtin_scalar?(type.graphql_name) ||
423
+ !registry.scalar_registry.key?(type.graphql_name)
424
+
425
+ disagreement(registry.scalar(type.graphql_name), type, values, context)
426
+ end
427
+ return if disagreed.empty?
428
+
429
+ raise GraphWeaver::Error, "#{disagreed.size} scalar(s) disagree with #{schema}:\n" +
430
+ disagreed.map { |line| " #{line}" }.join("\n")
431
+ end
432
+
433
+ private
434
+
435
+ # One scalar's verdict, or nil when the two halves agree. Each step is
436
+ # a different mistake, so each says which.
437
+ def disagreement(scalar, type, values, context)
438
+ name = type.graphql_name
439
+ # a scalar registered as your own class has no fabricable value, and
440
+ # it says how to pin one — reported here rather than raised, so one
441
+ # unpinned scalar doesn't hide the verdict on all the others
442
+ begin
443
+ wire = values.scalar(name, name)
444
+ rescue GraphWeaver::Error => e
445
+ return "#{name}: #{e.message}"
446
+ end
447
+
448
+ cast = cast_proc(scalar)
449
+ begin
450
+ sample = cast.call(wire)
451
+ rescue StandardError => e
452
+ return "#{name}: cast: can't read #{wire.inspect}, the value fabricated for it (#{e.message}) " \
453
+ "— pin the form this server sends: overrides: { #{name.inspect} => ... }"
454
+ end
455
+
456
+ if scalar.serialize? && !scalar.serialize_value?
457
+ return "#{name}: serialize: is a Proc, which builds source for the generated file rather " \
458
+ "than converting a value, so there is nothing here to run it against"
459
+ end
460
+
461
+ out = scalar.serialize_value(sample)
462
+ refused = "#{name}: the server refused #{out.inspect}, the wire form serialize: writes"
463
+ begin
464
+ received = type.coerce_input(out, context)
465
+ rescue StandardError => e
466
+ return "#{refused} (#{e.message})"
467
+ end
468
+ return "#{refused} (coerce_input returned nil)" if received.nil? && !out.nil?
469
+
470
+ result = type.coerce_result(received, context)
471
+ begin
472
+ back = cast.call(result)
473
+ rescue StandardError => e
474
+ return "#{name}: cast: refused #{result.inspect}, the result form the server's " \
475
+ "coerce_result writes (#{e.message})"
476
+ end
477
+ return if back == sample
478
+
479
+ "#{name}: round-trips lossily — sent #{sample.inspect}, got back #{back.inspect}"
480
+ end
481
+
482
+ # The registration's `cast:`, RUN rather than emitted. A cast builds
483
+ # SOURCE for the generated file, so evaluating it is the only way to
484
+ # run one — at the top level, where a generated file's own constants
485
+ # resolve from.
486
+ def cast_proc(scalar)
487
+ source = scalar.cast("wire")
488
+ return ->(wire) { wire } if source.nil?
489
+
490
+ eval("->(wire) { #{source} }", TOPLEVEL_BINDING, __FILE__, __LINE__) # rubocop:disable Security/Eval
491
+ end
391
492
  end
392
493
  end
393
494
  end
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.7.2"
2
+ VERSION = "0.7.4"
3
3
  end