graph_weaver 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. metadata +56 -1
data/lib/graph_weaver.rb CHANGED
@@ -7,10 +7,12 @@ require_relative "graph_weaver/errors"
7
7
  require_relative "graph_weaver/coerce"
8
8
  require_relative "graph_weaver/hints"
9
9
  require_relative "graph_weaver/input_struct"
10
+ require_relative "graph_weaver/result_struct"
10
11
  require_relative "graph_weaver/query_module"
11
12
  require_relative "graph_weaver/response"
12
13
  require_relative "graph_weaver/inflect"
13
14
  require_relative "graph_weaver/codegen"
15
+ require_relative "graph_weaver/graph"
14
16
  require_relative "graph_weaver/client"
15
17
  require_relative "graph_weaver/in_process"
16
18
  require_relative "graph_weaver/transport/http"
@@ -19,8 +21,6 @@ require_relative "graph_weaver/schema_loader"
19
21
  require_relative "graph_weaver/version"
20
22
  require_relative "graph_weaver/railtie" if defined?(::Rails::Railtie)
21
23
 
22
- # opt-in extras:
23
- # require "graph_weaver/transport/faraday" # Faraday transport
24
24
  module GraphWeaver
25
25
  # The line every generated file opens with (see Codegen::Emit) — the marker
26
26
  # that tells generate! which files in the output directory are its to prune.
@@ -44,6 +44,21 @@ module GraphWeaver
44
44
  Client.new(source, **options, &middleware)
45
45
  end
46
46
 
47
+ # Every setting below, in initializer style — the shape an app already
48
+ # knows from Sidekiq, Devise and GraphWeaver::Testing:
49
+ #
50
+ # GraphWeaver.configure do |config|
51
+ # config.client = GraphWeaver.new(url, auth: token)
52
+ # config.queries_paths = "app/graphql/operations"
53
+ # end
54
+ #
55
+ # `config` is GraphWeaver itself, so this is the same call as the flat
56
+ # `GraphWeaver.client = ...`; both spellings stay valid.
57
+ def configure
58
+ yield self
59
+ self
60
+ end
61
+
47
62
  # The app's default client — how generated modules find their server:
48
63
  #
49
64
  # GraphWeaver.client = GraphWeaver.new(url, auth: token)
@@ -83,11 +98,11 @@ module GraphWeaver
83
98
  end
84
99
  def cast_data(struct, data, errors)
85
100
  struct.from_h(data)
86
- rescue GraphWeaver::TypeError => e
101
+ rescue GraphWeaver::CastError => e
87
102
  raise if errors.empty?
88
103
 
89
104
  detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
90
- raise GraphWeaver::TypeError.new(
105
+ raise GraphWeaver::CastError.new(
91
106
  struct:,
92
107
  message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
93
108
  )
@@ -103,19 +118,19 @@ module GraphWeaver
103
118
  def check_envelope!(raw, struct)
104
119
  raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
105
120
  unless raw.is_a?(Hash)
106
- raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
121
+ raise GraphWeaver::CastError.new(struct:, message: "response must be an object, got #{raw.class}")
107
122
  end
108
123
 
109
124
  %w[data extensions].each do |key|
110
125
  value = raw[key]
111
126
  next if value.nil? || value.is_a?(Hash)
112
127
 
113
- raise GraphWeaver::TypeError.new(struct:, message: "response #{key.inspect} must be an object, got #{value.class}")
128
+ raise GraphWeaver::CastError.new(struct:, message: "response #{key.inspect} must be an object, got #{value.class}")
114
129
  end
115
130
 
116
131
  errors = raw["errors"]
117
132
  unless errors.nil? || (errors.is_a?(Array) && errors.all?(Hash))
118
- raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
133
+ raise GraphWeaver::CastError.new(struct:, message: "response \"errors\" must be an array of objects")
119
134
  end
120
135
 
121
136
  # A response with neither key isn't a GraphQL response at all — a client
@@ -123,7 +138,7 @@ module GraphWeaver
123
138
  # "dat". Each otherwise passes as a success carrying no data.
124
139
  unless raw.key?("data") || raw.key?("errors")
125
140
  found = raw.empty? ? "it is empty" : "got #{raw.keys.first(5).map(&:inspect).join(", ")}"
126
- raise GraphWeaver::TypeError.new(struct:, message:
141
+ raise GraphWeaver::CastError.new(struct:, message:
127
142
  "response carried neither \"data\" nor \"errors\" — #{found}; " \
128
143
  "the keys are the wire's own, as strings")
129
144
  end
@@ -160,9 +175,9 @@ module GraphWeaver
160
175
  # GraphWeaver.queries_paths = "app/graphql/operations"
161
176
  # GraphWeaver.generated_paths << "spec/graphql/generated"
162
177
  #
163
- # schema_path is the exception, and singular on purpose: one generate! run
164
- # reads ONE schema, so a second dump in a list is a file nothing would ever
165
- # read. A second schema is a second generate! (schema: names it).
178
+ # schema_path is the exception, and singular on purpose: these settings
179
+ # describe ONE graph, so a second dump in a list is a file nothing would
180
+ # ever read. A second schema is a second graph (see #graph).
166
181
  attr_writer :schema_path
167
182
 
168
183
  def queries_paths = @queries_paths ||= ["app/graphql/queries"]
@@ -203,6 +218,160 @@ module GraphWeaver
203
218
 
204
219
  def types_module = @types_module || "GraphQLTypes"
205
220
 
221
+ # Declare a second schema — and a third, and the rest. Everything a graph
222
+ # knows is said inside its block, in call style:
223
+ #
224
+ # GraphWeaver.graph :billing do
225
+ # schema -> { Billing::Schema }
226
+ # queries "app/graphql/billing/queries"
227
+ # output "app/graphql/billing/generated"
228
+ # client Billing::Schema
229
+ # namespace "Billing"
230
+ # register_scalar "Money", BigDecimal
231
+ # end
232
+ #
233
+ # Every entry point then walks the list: one `rake graph_weaver:generate`
234
+ # does the app, one `verify` gates it, `check_queries` checks each graph
235
+ # against its own schema.
236
+ #
237
+ # Six settings — schema, queries, output, client, namespace, types_module —
238
+ # and the three registrations you already write at the top level. `schema "x"`
239
+ # sets and bare `schema` reads back; there is no `schema = "x"` form, since
240
+ # the block is instance_eval'd and that would be a local variable (this is
241
+ # graphql-ruby's `field :x` convention). Anything else the block calls is
242
+ # refused, naming the nine it takes.
243
+ #
244
+ # Each setting falls back to the matching top-level one, so a graph says
245
+ # only what differs; `namespace` nests everything that graph generates (the
246
+ # query modules and its shared types module) so two schemas with a
247
+ # person.graphql don't fight over one constant. The registrations reach this
248
+ # graph alone, on top of the top-level ones as they stand at declaration.
249
+ #
250
+ # The block runs where it is written. `schema` also takes a callable, which
251
+ # is how a Rails app names an autoloaded schema class from an initializer:
252
+ # `schema -> { Billing::Schema }` resolves when generation asks, and resolves
253
+ # again after a dev reload has replaced the class object. A registration
254
+ # naming an autoloaded constant has the same problem, and the same answer
255
+ # top-level registrations have always had (Codegen::AUTOLOAD_HINT).
256
+ #
257
+ # The name is the graph's identity, so re-declaring one REPLACES it —
258
+ # declaring from a `to_prepare` block, which re-runs on every reload, is
259
+ # safe. Declaring any graph replaces the implicit one the settings
260
+ # describe: an app either has graphs or has settings, never a silent third
261
+ # thing.
262
+ def graph(name, **keywords, &block)
263
+ unless name.is_a?(Symbol) || name.is_a?(String)
264
+ # codegen writes the name into every module this graph generates, so
265
+ # it has to be something source can spell
266
+ raise ArgumentError, "graph name must be a Symbol or a String, got #{name.inspect}"
267
+ end
268
+ # the name is the graph's identity, so the two spellings are one graph —
269
+ # re-declaring as `graph "billing"` replaces `graph :billing` rather than
270
+ # quietly making a second one that generates over the first's output
271
+ name = name.to_sym
272
+ unless keywords.empty?
273
+ raise ArgumentError, "GraphWeaver.graph takes a block, not keywords — say " \
274
+ "#{keywords.keys.join(", ")} inside it:\n" \
275
+ " GraphWeaver.graph #{name.inspect} do\n" \
276
+ " #{keywords.keys.first} #{keywords.values.first.inspect}\n" \
277
+ " end"
278
+ end
279
+ unless block
280
+ raise ArgumentError, "GraphWeaver.graph #{name.inspect} needs a block — a graph says " \
281
+ "what it is inside one"
282
+ end
283
+
284
+ graph = Internal::GraphBuilder.build(name, &block)
285
+ @graphs ||= []
286
+ # replace in place, so the declaration order an app wrote is the order
287
+ # generate! reports in however many times the initializer has re-run
288
+ existing = @graphs.index { |candidate| candidate.name == name }
289
+ existing ? @graphs[existing] = graph : @graphs << graph
290
+ graph
291
+ end
292
+
293
+ # Every graph an entry point walks: the declared ones, or the single graph
294
+ # the top-level settings describe. Never empty.
295
+ def graphs = @graphs&.dup || [default_graph]
296
+
297
+ # Forget every declared graph — back to the settings alone.
298
+ def reset_graphs!
299
+ @graphs = nil
300
+ self
301
+ end
302
+
303
+ # The graph the settings describe. Built fresh each time: the settings are
304
+ # writable, and it holds the default registry rather than a copy so a
305
+ # top-level register_scalar reaches it.
306
+ def default_graph = Graph.new
307
+ private :default_graph
308
+
309
+ # The graphs one generate!/verify run covers. Explicit arguments describe
310
+ # one graph inline — the call generate! has always taken — and otherwise
311
+ # it is every graph.
312
+ def graphs_for(**overrides)
313
+ overrides = overrides.compact
314
+ return [Graph.new(**overrides)] unless overrides.empty?
315
+
316
+ refuse_orphaned_paths!
317
+ graphs
318
+ end
319
+ private :graphs_for
320
+
321
+ # Declaring a graph replaces the implicit one the top-level settings
322
+ # describe — so nothing then reads the directories those settings name, and
323
+ # an app that declared a graph beside its existing queries got no word
324
+ # about it. A .graphql left behind generates nothing, verify calls the tree
325
+ # up to date and queries:check calls the file validated; a generated module
326
+ # left behind is regenerated by nothing, pruned by nothing, and loaded by
327
+ # the railtie every boot. The docs promise an app either has graphs or has
328
+ # settings and never a silent third thing, so this is where that is true.
329
+ #
330
+ # Only an app that declared graphs can have one: for every other app the
331
+ # settings ARE its graph, and everything under them is covered by
332
+ # definition.
333
+ def refuse_orphaned_paths!
334
+ return unless @graphs
335
+
336
+ strays = Internal::Util.query_files(queries_paths) -
337
+ graphs.flat_map { |graph| Internal::Util.query_files(graph.queries) }
338
+ # generated_paths holds globs, so a graph's own output is found by this
339
+ # sweep — matched by where it writes rather than by how the glob spells it
340
+ written = graphs.map { |graph| Internal::Util.resolve(graph.output) }
341
+ strays += generated_paths.flat_map { |path| generated_files(path) }
342
+ .reject { |file| written.any? { |dir| file == dir || file.start_with?("#{dir}/") } }
343
+ return if strays.empty?
344
+
345
+ covers = graphs.map do |graph|
346
+ " #{graph.name ? "graph #{graph.name.inspect}" : "this app"}: " \
347
+ "#{Array(graph.queries).join(", ")} -> #{Internal::Util.relative(graph.output)}"
348
+ end
349
+ raise Error, ["these files are under directories no declared graph covers:",
350
+ *strays.uniq.sort.map { |path| " #{Internal::Util.relative(path)}" },
351
+ "Nothing generates or prunes them, verify and queries:check pass over them, and a " \
352
+ "generated module left here still loads in production. The graphs this app declared cover:",
353
+ *covers,
354
+ "These are the directories the top-level settings name — the graph this app had before " \
355
+ "it declared any — so declaring a second graph means declaring the first too:",
356
+ *settings_as_graph,
357
+ "Its directories and nothing else: no schema, so it reads the one the settings already " \
358
+ "name, and no namespace:, which keeps every constant name exactly as it is. Or name " \
359
+ "these directories in a graph above (queries/output in its block), or delete them."].join("\n")
360
+ end
361
+ private :refuse_orphaned_paths!
362
+
363
+ # The graph the top-level settings already describe, spelled as a
364
+ # declaration — the fix for the commonest way to arrive at the refusal
365
+ # above, which is declaring graph two in an app that had only settings.
366
+ def settings_as_graph
367
+ queries = queries_paths.map { |path| Internal::Util.relative(path).inspect }
368
+ [" GraphWeaver.graph :app do",
369
+ " queries #{queries.join(", ")}",
370
+ " output #{Internal::Util.relative(generated_paths.first).inspect}",
371
+ " end"]
372
+ end
373
+ private :settings_as_graph
374
+
206
375
  # Generate every query in a directory — .graphql/.gql, subdirectories
207
376
  # included — into checked-in Ruby files. Paths default to the conventions
208
377
  # above; schema: defaults to the dump at schema_path (any supported
@@ -211,26 +380,44 @@ module GraphWeaver
211
380
  #
212
381
  # GraphWeaver.generate! # queries_paths -> generated_paths.first
213
382
  #
383
+ # With no arguments it generates every graph (see #graph) — which, for an
384
+ # app that declared none, is the one the settings above describe. Arguments
385
+ # describe one graph inline instead.
386
+ #
214
387
  # person.graphql => person_query.rb defining PersonQuery. Returns every
215
388
  # path the plan produces; a file already byte-identical is left untouched
216
389
  # (see #changed_files). Generated files the plan no longer produces are deleted
217
390
  # (see #orphaned), so renaming or dropping a .graphql leaves nothing
218
391
  # behind. Pair with a freshness spec (docs/generated_modules.md).
219
- def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
220
- types_module: nil)
221
- schema = schema ? schema_for(schema) : locate_schema!
222
-
223
- if Internal::Util.query_files(queries).empty?
224
- # a brand-new app legitimately has none; a mistyped queries_paths looks
225
- # exactly the same, and prints nothing either way
226
- Internal::Log.log(:warn) { "no query documents under #{Array(queries).join(", ")} nothing to generate" }
392
+ def generate!(schema: nil, queries: nil, output: nil, client: nil, types_module: nil)
393
+ @changed_files = []
394
+ @unmatched_registrations = []
395
+ @untyped_scalars_by_graph = {}
396
+ seen = new_seen
397
+
398
+ # Every plan first, then every write. Generation refusing must leave the
399
+ # tree exactly as it was the railtie's watch mode regenerates on a
400
+ # request and promises a failed save changes nothing, and that promise
401
+ # was true within a graph and false across them.
402
+ planned = graphs_for(schema:, queries:, output:, client:, types_module:).map do |graph|
403
+ if Internal::Util.query_files(graph.queries).empty?
404
+ # a brand-new app legitimately has none; a mistyped queries_paths looks
405
+ # exactly the same, and prints nothing either way
406
+ Internal::Log.log(:warn) do
407
+ "no query documents under #{Array(graph.queries).join(", ")}#{graph.described} — nothing to generate"
408
+ end
409
+ end
410
+
411
+ [graph, generation_plan(graph, seen)]
227
412
  end
228
413
 
229
- plan = generation_plan(queries:, schema:, client:, types_module:)
230
- @unmatched_registrations = Codegen.unmatched_registrations(schema)
231
- @changed_files = []
414
+ planned.flat_map { |graph, plan| write_plan!(graph, plan) }
415
+ end
416
+
417
+ # One graph's plan onto disk, pruning what it no longer produces.
418
+ def write_plan!(graph, plan)
232
419
  written = plan.map do |filename, source|
233
- target = File.join(Internal::Util.resolve(output), filename)
420
+ target = File.join(Internal::Util.resolve(graph.output), filename)
234
421
  next target if current?(target, source)
235
422
 
236
423
  FileUtils.mkdir_p(File.dirname(target))
@@ -244,13 +431,28 @@ module GraphWeaver
244
431
  target
245
432
  end
246
433
 
247
- orphaned(output, written).each do |orphan|
434
+ pruned = orphaned(graph.output, written)
435
+ pruned.each do |orphan|
248
436
  File.delete(orphan)
249
437
  Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
250
438
  end
439
+ prune_empty!(graph, pruned)
251
440
 
252
441
  written.map { |target| Internal::Util.relative(target) }
253
442
  end
443
+ private :write_plan!
444
+
445
+ # Pruning promises to leave nothing behind, and the types/ directory whose
446
+ # last file just went is something behind. One level deep is the whole of
447
+ # it: a generated file's name is its base name, so types/ is the only
448
+ # subdirectory generation makes.
449
+ def prune_empty!(graph, pruned)
450
+ output = Internal::Util.resolve(graph.output)
451
+ pruned.map { |orphan| File.dirname(orphan) }.uniq.each do |dir|
452
+ Dir.rmdir(dir) if dir != output && Dir.exist?(dir) && Dir.empty?(dir)
453
+ end
454
+ end
455
+ private :prune_empty!
254
456
 
255
457
  # Which of those files the last generate! actually wrote — the rest were
256
458
  # already byte-identical, so a run that changed one query touches one file
@@ -286,33 +488,71 @@ module GraphWeaver
286
488
  # it "generated queries are current" do
287
489
  # GraphWeaver.verify_generated!
288
490
  # end
289
- def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
290
- types_module: nil)
291
- if Internal::Util.query_files(queries).empty?
292
- # green over nothing is worse than red: a CI gate stays passing
293
- # forever because someone typed app/graphql/querys
294
- raise Error, "no query documents under #{Array(queries).join(", ")} — this checked nothing, " \
295
- "so it proved nothing (set GraphWeaver.queries_paths, or pass queries:)"
296
- end
491
+ def verify_generated!(schema: nil, queries: nil, output: nil, client: nil, types_module: nil)
492
+ @unmatched_registrations = []
493
+ @untyped_scalars_by_graph = {}
494
+ seen = new_seen
495
+ graphs = graphs_for(schema:, queries:, output:, client:, types_module:)
496
+
497
+ # The dump is checked in too, and everything below reads it — so a
498
+ # stale one is answered before staleness downstream of it, because
499
+ # refreshing has to come first. Asked only where it can be: a live
500
+ # class, and one introspection of it.
501
+ graphs.each { |graph| refuse_stale_dump!(graph) }
502
+
503
+ graphs.each do |graph|
504
+ if Internal::Util.query_files(graph.queries).empty?
505
+ # green over nothing is worse than red: a CI gate stays passing
506
+ # forever because someone typed app/graphql/querys
507
+ raise Error, "no query documents under #{Array(graph.queries).join(", ")}#{graph.described} — " \
508
+ "this checked nothing, so it proved nothing (set GraphWeaver.queries_paths, or pass queries:)"
509
+ end
297
510
 
298
- schema = schema ? schema_for(schema) : locate_schema!
299
- plan = generation_plan(queries:, schema:, client:, types_module:)
300
- @unmatched_registrations = Codegen.unmatched_registrations(schema)
301
- stale = plan.filter_map do |filename, source|
302
- target = File.join(Internal::Util.resolve(output), filename)
303
- target unless current?(target, source)
304
- end
305
- # strays: a generated file the current schema + queries no longer produce
306
- stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
511
+ plan = generation_plan(graph, seen)
512
+ resolved = ->(filename) { File.join(Internal::Util.resolve(graph.output), filename) }
513
+ stale = plan.filter_map do |filename, source|
514
+ target = resolved.call(filename)
515
+ target unless current?(target, source)
516
+ end
517
+ # strays: a generated file the current schema + queries no longer produce
518
+ stale += orphaned(graph.output, plan.map { |filename, _| resolved.call(filename) })
307
519
 
308
- unless stale.empty?
309
- raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): " \
310
- "#{stale.map { |path| Internal::Util.relative(path) }.join(", ")}"
520
+ unless stale.empty?
521
+ raise Error, "stale generated queries#{graph.described} — regenerate (rake graph_weaver:generate): " \
522
+ "#{stale.map { |path| Internal::Util.relative(path) }.join(", ")}"
523
+ end
311
524
  end
312
525
 
313
526
  true
314
527
  end
315
528
 
529
+ # A dump that has fallen behind the graphql-ruby class it was built
530
+ # from. `verify` asks whether what is checked in is current, and for an
531
+ # app that serves its own schema the dump is an artifact derived from
532
+ # code in the same repo: it drifts silently, and generating from it
533
+ # then produces stale Ruby that verify would call fresh.
534
+ #
535
+ # Only a live class is asked — it answers introspection in-process, so
536
+ # this costs one introspection and no network. A dump that records a
537
+ # url is `schema:diff`'s subject: reaching for it here would put a
538
+ # network call in every build and in every spec that calls this.
539
+ def refuse_stale_dump!(graph)
540
+ path = graph.dump_path
541
+ source = graph.dump_source
542
+ return unless path && source.is_a?(Module)
543
+
544
+ diff = SchemaLoader.diff(path, transport: source)
545
+ return if diff.empty?
546
+
547
+ breaking = diff.breaking.size
548
+ raise Error, "the dump is behind the schema#{graph.described} — " \
549
+ "#{Internal::Util.relative(path)} no longer matches #{SchemaLoader.endpoint(source)} " \
550
+ "(#{diff.changes.size} #{(diff.changes.size == 1) ? "change" : "changes"}, " \
551
+ "#{breaking} breaking). Run rake graph_weaver:schema:refresh, then rake " \
552
+ "graph_weaver:generate — rake graph_weaver:schema:diff names what moved."
553
+ end
554
+ private :refuse_stale_dump!
555
+
316
556
  # Whether the file on disk is already what the plan would write — asked
317
557
  # before writing it, and before calling it stale. autocrlf rewrites line
318
558
  # endings on checkout, and a Windows working copy is neither stale
@@ -328,6 +568,21 @@ module GraphWeaver
328
568
  # instead of once per query file.
329
569
  def unmatched_registrations = @unmatched_registrations || []
330
570
 
571
+ # The custom scalars the last generate!/verify_generated! found no
572
+ # registration for — they generate as T.untyped, which is legitimate but
573
+ # is the one hole in an otherwise exact result type. Names, sorted, empty
574
+ # when every scalar a query touched is registered. The same list codegen
575
+ # logs at info, kept here so the build can print it once instead of once
576
+ # per query file.
577
+ def untyped_scalars = untyped_scalars_by_graph.values.flatten.uniq.sort
578
+
579
+ # The same, kept apart by the graph that found them. A registration is
580
+ # scoped to one graph, so a scalar registered for one and forgotten for
581
+ # the next is a different finding from one forgotten everywhere — and a
582
+ # merged list can't tell them apart. Keyed by graph name, nil for an app
583
+ # that declared none.
584
+ def untyped_scalars_by_graph = @untyped_scalars_by_graph || {}
585
+
331
586
  # Which checked-in queries no longer validate — breaking-change
332
587
  # detection scoped to the operations you actually ship. Reports rather
333
588
  # than raising, keyed by file, JSON-ready like every #to_h here:
@@ -350,29 +605,42 @@ module GraphWeaver
350
605
  # A different question from verify_generated!, which asks whether the
351
606
  # committed Ruby matches the committed schema. `rake
352
607
  # graph_weaver:queries:check` prints this and exits non-zero.
353
- def check_queries(schema: nil, queries: queries_paths, fragments: fragments_paths)
354
- # subgraph branding comes from the local supergraph dump, so a caller
355
- # supplying its own schema opts out of it
356
- table = schema ? nil : checked_routing_table
357
- schema = schema ? schema_for(schema) : refreshed_schema
608
+ def check_queries(schema: nil, queries: nil, fragments: fragments_paths)
358
609
  shared = Codegen.load_fragments(fragments)
359
610
 
360
- Internal::Util.query_files(queries).each_with_object({}) do |path, failures|
361
- errors = validation_errors(schema, File.read(path), shared, table)
362
- failures[Internal::Util.relative(path)] = errors if errors.any?
611
+ graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
612
+ checked = checked_schema(graph)
613
+ table = checked_routing_table(graph)
614
+ Internal::Util.query_files(graph.queries).each do |path|
615
+ errors = validation_errors(checked, File.read(path), shared, table)
616
+ next if errors.empty?
617
+
618
+ # keyed by file, as it has always been — and two graphs may share a
619
+ # queries directory (the same operations against two subgraphs), so
620
+ # the second one's errors join the first's rather than replacing them
621
+ key = Internal::Util.relative(path)
622
+ failures[key] = failures.key?(key) ? failures[key] | errors : errors
623
+ end
363
624
  end
364
625
  end
365
626
 
627
+ # What this graph is checked against. A graph that names its schema is
628
+ # checked against exactly that, so nothing touches the network; the default
629
+ # graph names none, so its dump is re-introspected first (see
630
+ # refreshed_schema).
631
+ def checked_schema(graph) = graph.named_schema? ? graph.schema : refreshed_schema
632
+ private :checked_schema
633
+
366
634
  # The routing table behind the schema check_queries is about to use,
367
635
  # when there is one: a composed supergraph dump says who resolves what,
368
636
  # so a validation error can name the subgraph whose code to look at. nil
369
637
  # for every other source — a plain schema is entirely unaffected — and
370
638
  # nil when a live schema class is what gets checked, since the dump then
371
639
  # isn't what the errors came from.
372
- def checked_routing_table
373
- return if Internal::Util.live_schema
640
+ def checked_routing_table(graph)
641
+ return if graph.live_schema
374
642
 
375
- path = SchemaLoader.locate_path
643
+ path = graph.dump_path
376
644
  return unless path&.end_with?(".graphql", ".gql")
377
645
 
378
646
  sdl = File.read(path)
@@ -392,8 +660,8 @@ module GraphWeaver
392
660
  live = Internal::Util.live_schema
393
661
  return live if live
394
662
 
395
- # locate_schema! raises the conventional "no schema dump" message
396
- path = SchemaLoader.locate_path or locate_schema!
663
+ # Internal::Util.locate_schema! raises the conventional "no schema dump" message
664
+ path = SchemaLoader.locate_path or Internal::Util.locate_schema!
397
665
  return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
398
666
 
399
667
  # source_transport rather than one built here: it reads the auth ENV var
@@ -420,12 +688,12 @@ module GraphWeaver
420
688
  }
421
689
  subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
422
690
  end
423
- rescue GraphWeaver::ValidationError => e
691
+ rescue GraphWeaver::QueryValidationError => e
424
692
  # an unparseable query: codegen folds the position (and the file) into
425
693
  # the message, and this report keeps them separate — same splitter the
426
694
  # rendered error uses, so the two can't drift apart
427
695
  e.errors.map do |detail|
428
- _path, _position, message = ValidationError.split(detail)
696
+ _path, _position, message = QueryValidationError.split(detail)
429
697
  detail.transform_keys(&:to_s).merge("message" => message)
430
698
  end
431
699
  end
@@ -460,19 +728,34 @@ module GraphWeaver
460
728
  # generated code only changes on regeneration anyway (restart, like
461
729
  # a schema migration).
462
730
  def load_generated!(path = nil)
463
- paths = path ? [path] : generated_paths
731
+ paths = path ? [path] : Internal::Util.generated_dirs
464
732
  files = paths.flat_map { |dir| Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].sort }.uniq
465
733
  files.each do |file|
466
734
  require file
467
735
  rescue NameError => e
468
736
  reported = Internal::Util.relative(file)
737
+ # Zeitwerk autoloading the file rather than this require: it expects
738
+ # the constant the path spells, and generated modules are top-level.
739
+ # Nothing was dropped, so the advice below would send you hunting for
740
+ # a registration that is still there.
741
+ if zeitwerk_name_error?(e)
742
+ raise Error, "#{reported} can't load: #{e.message}. Zeitwerk owns that directory, and a " \
743
+ "generated module defines a top-level constant — so the one Zeitwerk expects never appears. " \
744
+ "The directory has to be hidden from autoloading: name it in GraphWeaver.generated_paths " \
745
+ "from config/initializers (the Railtie ignores what is listed there, and only before Rails " \
746
+ "sets Zeitwerk up), or generate into the conventional app/graphql/*/generated."
747
+ end
748
+
469
749
  # a dropped extend_type leaves this include dangling; say so here,
470
750
  # because the raw NameError points at generated code and names no fix
471
- helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1]
751
+ # a block-built helper is named for its graph as well as its type
752
+ # (TypeHelpers::Billing::Pet), so the constant says where to look
753
+ # rather than spelling the extend_type back
754
+ helper = e.message[/GraphWeaver::TypeHelpers::[\w:]+/]
472
755
  if helper
473
- raise Error, "#{reported} includes GraphWeaver::TypeHelpers::#{helper}, but nothing registers it — " \
474
- "the extend_type(#{helper.inspect}) it was generated from is gone. Re-add that registration, " \
475
- "or regenerate without it: rake graph_weaver:generate"
756
+ raise Error, "#{reported} includes #{helper}, but nothing registers it — the extend_type " \
757
+ "block it was generated from is gone. Re-add that registration, or regenerate without " \
758
+ "it: rake graph_weaver:generate"
476
759
  end
477
760
 
478
761
  # an app's own mixin or enum class named by extend_type/register_enum
@@ -498,10 +781,15 @@ module GraphWeaver
498
781
  # whose query was just deleted keeps its old constant until restart —
499
782
  # nothing on disk says what it was called any more.
500
783
  def reload_generated!
501
- names = Internal::Util.query_files.map { |path| Internal::Util.module_name(path, File.read(path)) } << types_module
784
+ names = graphs.flat_map do |graph|
785
+ modules = Internal::Util.query_files(graph.queries).map do |path|
786
+ graph.generated_names(path, File.read(path)).first
787
+ end
788
+ modules << graph.types_module
789
+ end
502
790
  names.each { |name| undefine(name) }
503
791
 
504
- generated_paths.each do |dir|
792
+ Internal::Util.generated_dirs.each do |dir|
505
793
  Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].each do |file|
506
794
  # require stores the realpath; the path load_generated! passed is
507
795
  # the other one under a symlinked checkout
@@ -512,6 +800,19 @@ module GraphWeaver
512
800
  load_generated!
513
801
  end
514
802
 
803
+ # The class when zeitwerk is loaded; its message otherwise, since the gem
804
+ # doesn't depend on zeitwerk and load_generated! runs outside Rails too.
805
+ # const_get rather than a bare Zeitwerk, as in rails_root: sorbet can't
806
+ # resolve a constant the gem doesn't depend on.
807
+ def zeitwerk_name_error?(error)
808
+ if Object.const_defined?("Zeitwerk::NameError")
809
+ error.is_a?(Object.const_get("Zeitwerk::NameError"))
810
+ else
811
+ error.message.match?(/\Aexpected file .* to define constant /)
812
+ end
813
+ end
814
+ private :zeitwerk_name_error?
815
+
515
816
  # remove_const takes a bare name, and types_module may be namespaced
516
817
  def undefine(name)
517
818
  *outer, base = name.split("::")
@@ -524,26 +825,6 @@ module GraphWeaver
524
825
  end
525
826
  private :undefine
526
827
 
527
- # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
528
- # the console object and the rake task point at the same thing. A path
529
- # (String or Pathname) or SDL loads like it does everywhere else in the
530
- # library; without that it reached `schema.validate` as itself and failed
531
- # as `undefined method 'validate' for an instance of String`.
532
- def schema_for(source)
533
- return source.schema if source.is_a?(Client)
534
- return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
535
-
536
- source
537
- end
538
- private :schema_for
539
-
540
- # the conventional schema dump, required
541
- def locate_schema!
542
- SchemaLoader.locate or raise Error,
543
- "no schema dump at #{schema_path} (.json/.graphql/.gql) — pass schema:, or cache one: GraphWeaver.new(url, cache: true).schema"
544
- end
545
- private :locate_schema!
546
-
547
828
  # (filename, source) per artifact. Types a schema shares across queries —
548
829
  # input types, schema enums, and each named shared fragment spread as a
549
830
  # whole-union field — are emitted once into the shared module, with query
@@ -551,51 +832,119 @@ module GraphWeaver
551
832
  # duplicated bool_exp structs (or one Ruby class per query for the same
552
833
  # schema enum) and one copy per schema. (Single-query parse inlines
553
834
  # everything — there's no cross-query set to share against.)
554
- def generation_plan(queries:, schema:, client:, types_module: nil, fragments: fragments_paths)
555
- types_module ||= self.types_module
835
+ # What one run has already produced, shared across every graph in it:
836
+ # constants are global and output files are just files, so two graphs
837
+ # landing on either would overwrite each other silently.
838
+ def new_seen = { modules: {}, files: {}, types: {} }
839
+ private :new_seen
840
+
841
+ def generation_plan(graph, seen = new_seen, fragments: fragments_paths)
842
+ @untyped_scalars_by_graph ||= {}
843
+ found = (@untyped_scalars_by_graph[graph.name] ||= [])
844
+ schema = graph.schema
845
+ registry = graph.registry
846
+ @unmatched_registrations |= registry.unmatched_registrations(schema)
847
+
556
848
  used = { inputs: [], enums: [], mapped: [] }
557
849
  used_unions = []
558
850
  shared = Codegen.load_fragments(fragments)
559
851
 
560
- seen = {} # module name => the file that produced it, for the collision message
561
-
562
- plan = Internal::Util.query_files(queries).map do |path|
852
+ plan = Internal::Util.query_files(graph.queries).map do |path|
563
853
  source = File.read(path)
564
- name, filename = Internal::Util.generated_names(path, source)
565
- if (earlier = seen[name])
566
- raise Error, "duplicate query module #{name} — #{Internal::Util.relative(earlier)} and " \
567
- "#{Internal::Util.relative(path)} both generate it; " \
568
- "the module name comes from the file name alone (directories don't namespace it), so rename one"
569
- end
570
- seen[name] = path
854
+ name, filename = graph.generated_names(path, source)
855
+ refuse_duplicate!(seen, name, filename, graph, path)
571
856
 
572
857
  codegen = Codegen.new(
573
858
  schema:,
574
859
  query: Codegen.inline_fragments(source, shared, path),
575
860
  name:,
576
- client:,
577
- types_namespace: types_module,
861
+ client: graph.client,
862
+ graph_name: graph.name,
863
+ types_namespace: graph.types_module,
578
864
  hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
579
865
  path:,
866
+ registry:,
580
867
  )
581
868
  out = codegen.generate
582
869
  codegen.variable_type_names.each { |kind, names| used[kind] |= names }
870
+ found.concat(codegen.untyped_scalars).uniq!
583
871
  used_unions |= codegen.used_union_names
584
872
  [filename, out]
585
873
  end
586
874
 
587
875
  if used_unions.any? || used.values.any?(&:any?)
588
- codegen = Codegen.new(schema:, query: "", name: types_module)
589
- plan = codegen.generate_types(
876
+ refuse_duplicate_types!(seen, graph)
877
+ codegen = Codegen.new(schema:, query: "", name: graph.types_module, registry:)
878
+ types = codegen.generate_types(
590
879
  inputs: used[:inputs], enums: used[:enums] + used[:mapped],
591
880
  unions: used_unions, fragments: shared,
592
- ).to_a + plan
881
+ )
882
+ found.concat(codegen.untyped_scalars).uniq!
883
+ # these land in the graph's output like any other file, so they collide
884
+ # with another graph's the same way
885
+ types.each_key { |filename| refuse_duplicate_file!(seen, filename, graph, graph.types_module) }
886
+ plan = types.to_a + plan
593
887
  end
594
888
 
595
889
  plan
596
890
  end
597
891
  private :generation_plan
598
892
 
893
+ # Two query files landing on one constant, or on one output file. Within a
894
+ # graph the fix is a rename, as it has always been; across two graphs it is
895
+ # `namespace:` for the constant and `output:` for the file — so the message
896
+ # names whichever one applies.
897
+ #
898
+ # The two checks are separate because a namespace fixes only the first:
899
+ # `person_query.rb` is named after `person.graphql` whatever module it
900
+ # defines, so two graphs sharing an output directory still collide there.
901
+ def refuse_duplicate!(seen, name, filename, graph, path)
902
+ refuse_collision!(seen[:modules], name, graph, path, "query module #{name}",
903
+ across: "give one of the graphs a namespace:, or rename one of the files",
904
+ within: "the module name comes from the file name alone (directories don't " \
905
+ "namespace it), so rename one")
906
+ refuse_duplicate_file!(seen, filename, graph, path)
907
+ end
908
+ private :refuse_duplicate!
909
+
910
+ def refuse_duplicate_file!(seen, filename, graph, path)
911
+ target = File.join(Internal::Util.resolve(graph.output), filename)
912
+ refuse_collision!(seen[:files], target, graph, path,
913
+ "generated file #{Internal::Util.relative(target)}",
914
+ across: "give one of the graphs its own output:", within: "rename one of the files")
915
+ end
916
+ private :refuse_duplicate_file!
917
+
918
+ # Two graphs hoisting shared types into one module. Unlike a query module
919
+ # there is no file to rename — the name is a setting — and the failure it
920
+ # replaces was the worst kind: generation succeeded, and the app died at
921
+ # boot on sorbet-runtime's "Enum GraphQLTypes::Status was already
922
+ # initialized", which names neither graph.
923
+ def refuse_duplicate_types!(seen, graph)
924
+ earlier = seen[:types][graph.types_module]
925
+ seen[:types][graph.types_module] = graph
926
+ return if earlier.nil? || earlier.equal?(graph)
927
+
928
+ raise Error, "two graphs hoist shared types into #{graph.types_module} — " \
929
+ "#{earlier.name.inspect} and #{graph.name.inspect} both generate it, and a generated " \
930
+ "enum refuses a second definition; give one of the graphs a namespace: (or its own types_module:)"
931
+ end
932
+ private :refuse_duplicate_types!
933
+
934
+ # `across` is the fix when the two are in different graphs, `within` when
935
+ # they are the same graph — two situations with two different answers.
936
+ def refuse_collision!(seen, key, graph, path, subject, across:, within:)
937
+ earlier_graph, earlier = seen[key]
938
+ seen[key] = [graph, path]
939
+ return unless earlier
940
+
941
+ raise Error, "duplicate #{subject} — " \
942
+ "#{Internal::Util.relative(earlier)}#{earlier_graph.described} and " \
943
+ "#{Internal::Util.relative(path)}#{graph.described} both generate it; " \
944
+ "#{earlier_graph.equal?(graph) ? within : across}"
945
+ end
946
+ private :refuse_collision!
947
+
599
948
  # Whether generated modules/structs emit `extend T::Sig` (so `sig`
600
949
  # resolves standalone). Default (nil) auto-detects: an app that globally
601
950
  # injects T::Sig (`class Module; include T::Sig`) makes the per-struct
@@ -620,20 +969,26 @@ module GraphWeaver
620
969
  # rich Ruby object (and serializes back onto the wire when used as a
621
970
  # variable):
622
971
  #
623
- # GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")
972
+ # GraphWeaver.register_scalar("Decimal", BigDecimal)
973
+ # GraphWeaver.register_scalar("Money", Money)
624
974
  #
625
975
  # A field typed `Money` then generates `const :price, T.nilable(Money)`
626
- # and casts with `Money.parse(...)` in from_h. Pass a real class as
627
- # type: and cast:/serialize: are inferred from it .parse/#to_s, or
628
- # .load/.dump by probing the deserialize side (see ScalarType::CODECS).
629
- # Override with a Symbol method name (safestno string to misspell), a
630
- # Proc(expr) => code string, or :itself to force pass-through. requires:
631
- # (a String or Array) names files the generated code needs validated,
632
- # and actually required to confirm it resolves when type: is a real class.
976
+ # and casts with `Money.parse(...)` in from_h. Pass a real class as type:
977
+ # and cast:/serialize: are inferred from it: a stdlib type the library
978
+ # knows (BigDecimal, Date, Time) brings its own codec and require, and
979
+ # anything else is probed on the deserialize side .parse/#to_s,
980
+ # .load/.dump, or a Kernel conversion of its name (see ScalarType).
981
+ # Override with a Symbol method name (safest no string to misspell),
982
+ # an Array for a method with arguments ([:to_s, "F"]), a Proc(expr) =>
983
+ # code string, or :itself to force pass-through. requires: (a String or
984
+ # Array) names files the generated code needs — validated, and actually
985
+ # required to confirm it resolves when type: is a real class.
633
986
  # cast: is also what an untyped variable input coerces through, so a
634
987
  # variable of this scalar takes the value OR its raw input ("12.00") with
635
- # no static loosening — see GraphWeaver::Coerce. Built-in scalars are
636
- # pre-registered the same way, so this also overrides them.
988
+ # no static loosening — see GraphWeaver::Coerce. The spec's own scalars
989
+ # and the conventional names (ISO8601Date, ISO8601DateTime, DateTime,
990
+ # BigInt, JSON) are pre-registered the same way, so this also overrides
991
+ # them — see Codegen::BUILTIN_SCALARS and docs/scalars.md.
637
992
  #
638
993
  # A scalar registered as a class of your own is the one value the testing
639
994
  # harness can't invent — only your `cast:` knows what it accepts — so it
@@ -708,9 +1063,15 @@ module GraphWeaver
708
1063
  # "Query" for anonymous operations — collisions are impossible since each
709
1064
  # parse gets its own container). Pass name: to override, client: to bake
710
1065
  # the module's default client/transport.
711
- def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
1066
+ #
1067
+ # graph: names the graph this module belongs to, which is what a test mode
1068
+ # runs it against in an app with more than one — the same thing generation
1069
+ # bakes into a file. Left out, it is the graph running this schema, when
1070
+ # one is declared; an app with one graph never needs it.
1071
+ def parse(schema:, query:, name: nil, client: nil, graph: nil, fragments: fragments_paths)
712
1072
  client ||= schema if schema.is_a?(Client)
713
- schema = schema_for(schema)
1073
+ schema = Internal::Util.schema_for(schema)
1074
+ graph_name = graph ? named_graph!(graph) : Internal::Util.graph_for(schema)&.name
714
1075
  # Rails.root.join(...) hands you a Pathname, and to_path is the
715
1076
  # ecosystem's "I am a path" — the same conversion schema: gets through
716
1077
  # SchemaLoader.load. Without it end_with? below is a NoMethodError.
@@ -726,8 +1087,21 @@ module GraphWeaver
726
1087
  end
727
1088
  query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
728
1089
 
729
- Codegen.parse(schema:, query:, name:, client:, path:)
1090
+ Codegen.parse(schema:, query:, name:, client:, path:, graph_name:)
1091
+ end
1092
+
1093
+ # `graph:`'s name, checked against what is declared — an unknown one would
1094
+ # otherwise bake a GRAPH no mode can match, and be reported as a module
1095
+ # that doesn't say which graph it came from.
1096
+ def named_graph!(name)
1097
+ declared = graphs.map(&:name).compact
1098
+ found = declared.find { |candidate| candidate.to_s == name.to_s }
1099
+ return found if found
1100
+
1101
+ raise ArgumentError, "graph: #{name.inspect} isn't declared — this app declares " \
1102
+ "#{declared.empty? ? "none (see GraphWeaver.graph)" : declared.map(&:inspect).join(", ")}"
730
1103
  end
1104
+ private :named_graph!
731
1105
 
732
1106
  # One-shot dynamic execution — a throwaway client, no build step:
733
1107
  #