graph_weaver 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver.rb CHANGED
@@ -2,7 +2,9 @@ require "graphql"
2
2
  require "sorbet-runtime"
3
3
 
4
4
  require_relative "graph_weaver/logging"
5
+ require_relative "graph_weaver/internal"
5
6
  require_relative "graph_weaver/errors"
7
+ require_relative "graph_weaver/coerce"
6
8
  require_relative "graph_weaver/hints"
7
9
  require_relative "graph_weaver/input_struct"
8
10
  require_relative "graph_weaver/query_module"
@@ -27,6 +29,7 @@ module GraphWeaver
27
29
  # How far into a file to look for it: the header sits under the `typed:` and
28
30
  # `frozen_string_literal:` magic comments, never deeper.
29
31
  HEADER_SCAN_LINES = 10
32
+ private_constant :GENERATED_HEADER, :HEADER_SCAN_LINES
30
33
 
31
34
  class << self
32
35
  # A client for one GraphQL server — transport, schema, and scoped
@@ -58,16 +61,17 @@ module GraphWeaver
58
61
  # being loaded is what says which suggestion is the useful one
59
62
  @client or raise Error, "no client configured — " + if defined?(Testing::RSpecIntegration)
60
63
  "tag the example graphql: :fake (or :in_process / :router), or build one with graphql_fake"
64
+ elsif defined?(::RSpec)
65
+ # a graphql: tag without graph_weaver/rspec is silent, and lands here
66
+ "set GraphWeaver.client= or pass a client; if you tagged this example graphql:, " \
67
+ "require \"graph_weaver/rspec\" in your spec helper — the tag does nothing without it"
61
68
  else
62
69
  "set GraphWeaver.client= or pass a client"
63
70
  end
64
71
  end
65
72
 
66
- # Shape-check a raw response envelope, returning it. Generated
67
- # from_response is public API taking anything with #to_h, so a malformed
68
- # body has to brand rather than escape as a raw Sorbet TypeError from a sig
69
- # (which fires before the struct's own rescue can see it). Lives here rather
70
- # than unrolled into every generated module.
73
+ # Called by generated code not semver'd for direct use.
74
+ #
71
75
  # Cast a response's data, keeping the server's own errors on a failure.
72
76
  # The common cause of a cast failure is a field that came back null *with
73
77
  # a reason attached* — a permission rule, a partial outage — and raising
@@ -89,7 +93,15 @@ module GraphWeaver
89
93
  )
90
94
  end
91
95
 
96
+ # Called by generated code — not semver'd for direct use.
97
+ #
98
+ # Shape-check a raw response envelope, returning it. Generated
99
+ # from_response is public API taking anything with #to_h, so a malformed
100
+ # body has to brand rather than escape as a raw Sorbet TypeError from a sig
101
+ # (which fires before the struct's own rescue can see it). Lives here rather
102
+ # than unrolled into every generated module.
92
103
  def check_envelope!(raw, struct)
104
+ raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
93
105
  unless raw.is_a?(Hash)
94
106
  raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
95
107
  end
@@ -106,36 +118,37 @@ module GraphWeaver
106
118
  raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
107
119
  end
108
120
 
121
+ # A response with neither key isn't a GraphQL response at all — a client
122
+ # that returned nil, one keying the envelope by symbol, one that typo'd
123
+ # "dat". Each otherwise passes as a success carrying no data.
124
+ unless raw.key?("data") || raw.key?("errors")
125
+ found = raw.empty? ? "it is empty" : "got #{raw.keys.first(5).map(&:inspect).join(", ")}"
126
+ raise GraphWeaver::TypeError.new(struct:, message:
127
+ "response carried neither \"data\" nor \"errors\" — #{found}; " \
128
+ "the keys are the wire's own, as strings")
129
+ end
130
+
109
131
  raw
110
132
  end
111
133
 
112
- # The module a .graphql file generates, and the basename of the file it
113
- # generates into: the camelized file name plus the operation's own word.
114
- #
115
- # person.graphql => PersonQuery (person_query.rb)
116
- # save_list_entry.graphql => SaveListEntryMutation
117
- # (save_list_entry_mutation.rb)
118
- #
119
- # Every naming site goes through here — generate!, parse(path), and
120
- # load_queries! — so the constant a file produces is the same one
121
- # whichever door you came in by, and the file it lands in matches it.
122
- def generated_names(path, source)
123
- base = File.basename(path, ".*")
124
- suffix = operation_suffix(source)
125
- ["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
126
- end
134
+ # What every relative path setting below is relative to: Rails.root in a
135
+ # Rails app, the working directory otherwise. So a dev server or an rspec
136
+ # run started from a subdirectory reads the same files a rake task does.
137
+ # An absolute setting is left alone.
138
+ attr_writer :root
139
+
140
+ def root = (@root || rails_root || Dir.pwd).to_s
127
141
 
128
- # just the module name see generated_names
129
- def module_name(path, source) = generated_names(path, source).first
142
+ # Not memoized: Rails.root isn't set when the gem is required. const_get
143
+ # rather than a bare Rails — sorbet can't resolve a constant the gem
144
+ # doesn't depend on, and something else may be named Rails.
145
+ def rails_root
146
+ return unless Object.const_defined?(:Rails)
130
147
 
131
- # "Mutation" for a mutation document, "Query" for everything else.
132
- def operation_suffix(source)
133
- operation = GraphQL.parse(source).definitions
134
- .grep(GraphQL::Language::Nodes::OperationDefinition).first
135
- (operation&.operation_type == "mutation") ? "Mutation" : "Query"
136
- rescue GraphQL::ParseError
137
- "Query" # unparseable: codegen brands the real error a moment later
148
+ rails = Object.const_get(:Rails)
149
+ rails.root if rails.respond_to?(:root)
138
150
  end
151
+ private :rails_root
139
152
 
140
153
  # Conventional locations. Every directory setting is a LIST,
141
154
  # factory_bot-style: extra locations (a test-only dir, an engine's) can be
@@ -180,12 +193,6 @@ module GraphWeaver
180
193
  # None of them needs the modules loaded.
181
194
  attr_accessor :skip_generated_load
182
195
 
183
- # Every query document under these directories, sorted — the files
184
- # generate!, verify_generated!, check_queries and load_queries! all read.
185
- def query_files(paths = queries_paths)
186
- Array(paths).flat_map { |dir| Dir[File.join(dir, Codegen::DOCUMENT_GLOB)].sort }
187
- end
188
-
189
196
  # The name of the shared module — the types that live once per schema
190
197
  # (input types, enums, unions hoisted from shared fragments) and are
191
198
  # aliased into every query module that touches them. Constant, not derived
@@ -204,38 +211,58 @@ module GraphWeaver
204
211
  #
205
212
  # GraphWeaver.generate! # queries_paths -> generated_paths.first
206
213
  #
207
- # person.graphql => person_query.rb defining PersonQuery. Returns the
208
- # written paths. Generated files the plan no longer produces are deleted
214
+ # person.graphql => person_query.rb defining PersonQuery. Returns every
215
+ # path the plan produces; a file already byte-identical is left untouched
216
+ # (see #changed_files). Generated files the plan no longer produces are deleted
209
217
  # (see #orphaned), so renaming or dropping a .graphql leaves nothing
210
218
  # behind. Pair with a freshness spec (docs/generated_modules.md).
211
219
  def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
212
220
  types_module: nil)
213
221
  schema = schema ? schema_for(schema) : locate_schema!
214
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" }
227
+ end
228
+
215
229
  plan = generation_plan(queries:, schema:, client:, types_module:)
230
+ @unmatched_registrations = Codegen.unmatched_registrations(schema)
231
+ @changed_files = []
216
232
  written = plan.map do |filename, source|
217
- target = File.join(output, filename)
233
+ target = File.join(Internal::Util.resolve(output), filename)
234
+ next target if current?(target, source)
235
+
218
236
  FileUtils.mkdir_p(File.dirname(target))
219
- File.write(target, source)
220
- log(:info) { "generated #{target}" }
237
+ # a rake task beside a watching dev server writes the same file: a
238
+ # truncating write can leave a prefix that no longer parses, and it is
239
+ # the running app that requires it next
240
+ Internal::Util.atomic_write(target, source)
241
+ reported = Internal::Util.relative(target)
242
+ @changed_files << reported
243
+ Internal::Log.log(:info) { "generated #{reported}" }
221
244
  target
222
245
  end
223
246
 
224
247
  orphaned(output, written).each do |orphan|
225
248
  File.delete(orphan)
226
- log(:info) { "pruned #{orphan}" }
249
+ Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
227
250
  end
228
251
 
229
- written
252
+ written.map { |target| Internal::Util.relative(target) }
230
253
  end
231
254
 
255
+ # Which of those files the last generate! actually wrote — the rest were
256
+ # already byte-identical, so a run that changed one query touches one file
257
+ # and a watching dev server has one module to reload.
258
+ def changed_files = @changed_files || []
259
+
232
260
  # Generated files under output the current plan no longer produces — a
233
261
  # query renamed or deleted, a type dropped from the schema, a union no
234
262
  # longer hoisted. Left alone they'd keep being required by
235
263
  # load_generated!, resolving against a query that no longer exists.
236
264
  def orphaned(output, produced)
237
- current = produced.map { |path| File.expand_path(path) }
238
- generated_files(output).reject { |path| current.include?(File.expand_path(path)) }
265
+ generated_files(output) - produced
239
266
  end
240
267
  private :orphaned
241
268
 
@@ -244,7 +271,7 @@ module GraphWeaver
244
271
  # safe: this is a real directory, and a hand-written file in it must
245
272
  # survive regeneration.
246
273
  def generated_files(output)
247
- Dir[File.join(output, "**/*.rb")].sort.select do |path|
274
+ Dir[File.join(Internal::Util.resolve(output), "**/*.rb")].sort.select do |path|
248
275
  File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
249
276
  end
250
277
  end
@@ -261,24 +288,46 @@ module GraphWeaver
261
288
  # end
262
289
  def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
263
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
297
+
264
298
  schema = schema ? schema_for(schema) : locate_schema!
265
299
  plan = generation_plan(queries:, schema:, client:, types_module:)
300
+ @unmatched_registrations = Codegen.unmatched_registrations(schema)
266
301
  stale = plan.filter_map do |filename, source|
267
- target = File.join(output, filename)
268
- # git's autocrlf rewrites line endings on checkout — a Windows working
269
- # copy is not stale generated code, so don't fail CI over it
270
- target unless File.exist?(target) && File.read(target).gsub("\r\n", "\n") == source.gsub("\r\n", "\n")
302
+ target = File.join(Internal::Util.resolve(output), filename)
303
+ target unless current?(target, source)
271
304
  end
272
305
  # strays: a generated file the current schema + queries no longer produce
273
- stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
306
+ stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
274
307
 
275
308
  unless stale.empty?
276
- raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
309
+ raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): " \
310
+ "#{stale.map { |path| Internal::Util.relative(path) }.join(", ")}"
277
311
  end
278
312
 
279
313
  true
280
314
  end
281
315
 
316
+ # Whether the file on disk is already what the plan would write — asked
317
+ # before writing it, and before calling it stale. autocrlf rewrites line
318
+ # endings on checkout, and a Windows working copy is neither stale
319
+ # generated code nor a file worth rewriting.
320
+ def current?(target, source)
321
+ File.exist?(target) && File.read(target).gsub("\r\n", "\n") == source.gsub("\r\n", "\n")
322
+ end
323
+ private :current?
324
+
325
+ # What the last generate!/verify_generated! couldn't match in the schema it
326
+ # ran against — one sentence per registration, empty after a clean run. The
327
+ # same list codegen logs at warn, kept here so the build can print it once
328
+ # instead of once per query file.
329
+ def unmatched_registrations = @unmatched_registrations || []
330
+
282
331
  # Which checked-in queries no longer validate — breaking-change
283
332
  # detection scoped to the operations you actually ship. Reports rather
284
333
  # than raising, keyed by file, JSON-ready like every #to_h here:
@@ -308,9 +357,9 @@ module GraphWeaver
308
357
  schema = schema ? schema_for(schema) : refreshed_schema
309
358
  shared = Codegen.load_fragments(fragments)
310
359
 
311
- query_files(queries).each_with_object({}) do |path, failures|
360
+ Internal::Util.query_files(queries).each_with_object({}) do |path, failures|
312
361
  errors = validation_errors(schema, File.read(path), shared, table)
313
- failures[path] = errors if errors.any?
362
+ failures[Internal::Util.relative(path)] = errors if errors.any?
314
363
  end
315
364
  end
316
365
 
@@ -321,7 +370,7 @@ module GraphWeaver
321
370
  # nil when a live schema class is what gets checked, since the dump then
322
371
  # isn't what the errors came from.
323
372
  def checked_routing_table
324
- return if live_schema
373
+ return if Internal::Util.live_schema
325
374
 
326
375
  path = SchemaLoader.locate_path
327
376
  return unless path&.end_with?(".graphql", ".gql")
@@ -340,7 +389,7 @@ module GraphWeaver
340
389
  # class — hand-written SDL, a composed supergraph — have nothing to
341
390
  # re-read, so they're checked as they are.
342
391
  def refreshed_schema
343
- live = live_schema
392
+ live = Internal::Util.live_schema
344
393
  return live if live
345
394
 
346
395
  # locate_schema! raises the conventional "no schema dump" message
@@ -353,19 +402,6 @@ module GraphWeaver
353
402
  end
354
403
  private :refreshed_schema
355
404
 
356
- # The graphql-ruby schema class the app default executes against, when it
357
- # runs in-process — a Client wrapping one, or the class in the slot bare.
358
- # nil for every network client. Not memoized: in dev the class object is
359
- # replaced on reload. (Public because testing's :in_process mode asks:
360
- # a client already running in-process names its own schema class.)
361
- def live_schema
362
- # through #transport, not #schema: a url client's #schema introspects,
363
- # so asking it would answer this question over the network
364
- target = client.is_a?(Client) ? client.transport : client
365
- target = target.schema if target.is_a?(InProcess)
366
- target if target.is_a?(Class) && target <= GraphQL::Schema
367
- end
368
-
369
405
  # One query's schema-validation errors as JSON-ready hashes, with the
370
406
  # source position graphql-ruby reports. Unparseable counts as an error
371
407
  # too — it doesn't validate either, and inline_fragments (which parses
@@ -425,21 +461,69 @@ module GraphWeaver
425
461
  # a schema migration).
426
462
  def load_generated!(path = nil)
427
463
  paths = path ? [path] : generated_paths
428
- files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
464
+ files = paths.flat_map { |dir| Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].sort }.uniq
429
465
  files.each do |file|
430
- require File.expand_path(file)
466
+ require file
431
467
  rescue NameError => e
468
+ reported = Internal::Util.relative(file)
432
469
  # a dropped extend_type leaves this include dangling; say so here,
433
470
  # because the raw NameError points at generated code and names no fix
434
- helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1] or raise
435
- raise Error, "#{file} includes GraphWeaver::TypeHelpers::#{helper}, but nothing registers it — " \
436
- "the extend_type(#{helper.inspect}) it was generated from is gone. Re-add that registration, " \
437
- "or regenerate without it: rake graph_weaver:generate"
471
+ helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1]
472
+ 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"
476
+ end
477
+
478
+ # an app's own mixin or enum class named by extend_type/register_enum
479
+ raise Error, "#{reported} can't load: #{e.message}. It was generated with an extend_type or " \
480
+ "register_enum whose constant is gone — re-add it, or regenerate: rake graph_weaver:generate"
481
+ end
482
+ Internal::Log.log(:info) do
483
+ "loaded #{files.size} generated module(s) from #{paths.map { |dir| Internal::Util.relative(dir) }.join(", ")}"
484
+ end
485
+ files.map { |file| Internal::Util.relative(file) }
486
+ end
487
+
488
+ # Load the generated modules again after generate! rewrote them in a
489
+ # running process — the second half of watch mode (see the Railtie), and
490
+ # what a console needs after regenerating in another terminal:
491
+ #
492
+ # GraphWeaver.reload_generated!
493
+ #
494
+ # `require` no-ops on a file it has already seen, and re-running one whose
495
+ # constants still exist raises (a T::Enum refuses a second definition), so
496
+ # the constants generation owns go first. Same caveat as load_queries!: an
497
+ # object built from the previous module stays an instance of it. A module
498
+ # whose query was just deleted keeps its old constant until restart —
499
+ # nothing on disk says what it was called any more.
500
+ def reload_generated!
501
+ names = Internal::Util.query_files.map { |path| Internal::Util.module_name(path, File.read(path)) } << types_module
502
+ names.each { |name| undefine(name) }
503
+
504
+ generated_paths.each do |dir|
505
+ Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].each do |file|
506
+ # require stores the realpath; the path load_generated! passed is
507
+ # the other one under a symlinked checkout
508
+ $LOADED_FEATURES.delete(file)
509
+ $LOADED_FEATURES.delete(File.realpath(file))
510
+ end
438
511
  end
439
- log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
440
- files
512
+ load_generated!
441
513
  end
442
514
 
515
+ # remove_const takes a bare name, and types_module may be namespaced
516
+ def undefine(name)
517
+ *outer, base = name.split("::")
518
+ owner = outer.reduce(Object) do |mod, part|
519
+ return unless mod.const_defined?(part, false)
520
+
521
+ mod.const_get(part, false)
522
+ end
523
+ owner.send(:remove_const, base) if owner.const_defined?(base, false)
524
+ end
525
+ private :undefine
526
+
443
527
  # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
444
528
  # the console object and the rake task point at the same thing. A path
445
529
  # (String or Pathname) or SDL loads like it does everywhere else in the
@@ -475,11 +559,12 @@ module GraphWeaver
475
559
 
476
560
  seen = {} # module name => the file that produced it, for the collision message
477
561
 
478
- plan = query_files(queries).map do |path|
562
+ plan = Internal::Util.query_files(queries).map do |path|
479
563
  source = File.read(path)
480
- name, filename = generated_names(path, source)
564
+ name, filename = Internal::Util.generated_names(path, source)
481
565
  if (earlier = seen[name])
482
- raise Error, "duplicate query module #{name} — #{earlier} and #{path} both generate it; " \
566
+ raise Error, "duplicate query module #{name} — #{Internal::Util.relative(earlier)} and " \
567
+ "#{Internal::Util.relative(path)} both generate it; " \
483
568
  "the module name comes from the file name alone (directories don't namespace it), so rename one"
484
569
  end
485
570
  seen[name] = path
@@ -487,7 +572,7 @@ module GraphWeaver
487
572
  codegen = Codegen.new(
488
573
  schema:,
489
574
  query: Codegen.inline_fragments(source, shared, path),
490
- module_name: name,
575
+ name:,
491
576
  client:,
492
577
  types_namespace: types_module,
493
578
  hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
@@ -500,7 +585,7 @@ module GraphWeaver
500
585
  end
501
586
 
502
587
  if used_unions.any? || used.values.any?(&:any?)
503
- codegen = Codegen.new(schema:, query: "", module_name: types_module)
588
+ codegen = Codegen.new(schema:, query: "", name: types_module)
504
589
  plan = codegen.generate_types(
505
590
  inputs: used[:inputs], enums: used[:enums] + used[:mapped],
506
591
  unions: used_unions, fragments: shared,
@@ -511,15 +596,6 @@ module GraphWeaver
511
596
  end
512
597
  private :generation_plan
513
598
 
514
- # coerce: true for every scalar that doesn't say coerce: itself —
515
- # the same switch at global scope, resolved lazily at generation time
516
- # (so set it any time before you generate, no ordering dance):
517
- #
518
- # GraphWeaver.auto_coerce = true
519
- #
520
- # An explicit coerce: on a registration always wins.
521
- attr_accessor :auto_coerce
522
-
523
599
  # Whether generated modules/structs emit `extend T::Sig` (so `sig`
524
600
  # resolves standalone). Default (nil) auto-detects: an app that globally
525
601
  # injects T::Sig (`class Module; include T::Sig`) makes the per-struct
@@ -538,15 +614,7 @@ module GraphWeaver
538
614
  # Whether the host app has globally injected T::Sig into every module
539
615
  # (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
540
616
  def global_tsig? = Module.include?(T::Sig)
541
-
542
- # The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
543
- # or nil (also nil when did_you_mean isn't loadable). One home for the guard
544
- # used by codegen validation, alias resolution, and the runtime prop hints.
545
- def did_you_mean(dictionary, term)
546
- return unless defined?(DidYouMean::SpellChecker)
547
-
548
- DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
549
- end
617
+ private :global_tsig?
550
618
 
551
619
  # Teach the generator how a GraphQL custom scalar deserializes into a
552
620
  # rich Ruby object (and serializes back onto the wire when used as a
@@ -562,12 +630,17 @@ module GraphWeaver
562
630
  # Proc(expr) => code string, or :itself to force pass-through. requires:
563
631
  # (a String or Array) names files the generated code needs — validated,
564
632
  # and actually required to confirm it resolves when type: is a real class.
565
- # coerce: true makes a variable of this scalar accept the value OR its
566
- # raw input (e.g. "12.00"), normalizing the latter before serializing —
567
- # it raises on bad input, so some safety survives; GraphWeaver.auto_coerce
568
- # is the same switch for every scalar at once. Built-in scalars are
633
+ # cast: is also what an untyped variable input coerces through, so a
634
+ # 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
569
636
  # pre-registered the same way, so this also overrides them.
570
637
  #
638
+ # A scalar registered as a class of your own is the one value the testing
639
+ # harness can't invent — only your `cast:` knows what it accepts — so it
640
+ # refuses rather than feed the cast a placeholder. Say it in test config,
641
+ # where the rest of that answer lives: `overrides: { "Money" => "12.00" }`
642
+ # (see docs/testing.md).
643
+ #
571
644
  # Pass a `Type.field` coordinate instead of a scalar name to override just
572
645
  # that one field — so the same scalar can deserialize as different Ruby
573
646
  # types across fields (a `Date` for `User.birthday`, a `Time` elsewhere):
@@ -576,8 +649,8 @@ module GraphWeaver
576
649
  #
577
650
  # A field-level override wins over the scalar-name registration. Same
578
651
  # signature either way. Call before generating.
579
- def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
580
- Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires:, coerce:)
652
+ def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil)
653
+ Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires:)
581
654
  end
582
655
 
583
656
  # Map a GraphQL enum onto an app-owned T::Enum, so generated code
@@ -615,8 +688,9 @@ module GraphWeaver
615
688
  end
616
689
 
617
690
  # Every registry back to its starting state: built-in scalars restored,
618
- # enum mappings and type helpers dropped — the clean slate between
619
- # tests. (One registry at a time is a Codegen call:
691
+ # enum mappings and type helpers dropped — the clean slate between tests,
692
+ # or between generations for different schemas. (One registry at a time
693
+ # is a Codegen call:
620
694
  # GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
621
695
  # .reset_type_helpers!)
622
696
  def reset_registrations!
@@ -643,12 +717,16 @@ module GraphWeaver
643
717
  query = query.to_path if query.respond_to?(:to_path)
644
718
  path = query if query.end_with?(".graphql", ".gql")
645
719
  if path
646
- query = File.read(path)
647
- name ||= module_name(path, query)
720
+ query = File.read(Internal::Util.resolve(path))
721
+ name ||= Internal::Util.module_name(path, query)
722
+ elsif !query.include?("{")
723
+ # every document has a selection set, so this is a path we won't read
724
+ # — and it would otherwise fail as a syntax error about SCHEMA/SCALAR
725
+ raise Error, "#{query.inspect} is not a GraphQL document — a query file must be named .graphql or .gql"
648
726
  end
649
727
  query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
650
728
 
651
- Codegen.parse(schema:, query:, module_name: name, client:, path:)
729
+ Codegen.parse(schema:, query:, name:, client:, path:)
652
730
  end
653
731
 
654
732
  # One-shot dynamic execution — a throwaway client, no build step:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -254,12 +254,20 @@ files:
254
254
  - lib/graph_weaver/codegen/nodes.rb
255
255
  - lib/graph_weaver/codegen/scalar_type.rb
256
256
  - lib/graph_weaver/codegen/type_helpers.rb
257
+ - lib/graph_weaver/coerce.rb
257
258
  - lib/graph_weaver/errors.rb
258
259
  - lib/graph_weaver/federation.rb
259
260
  - lib/graph_weaver/hints.rb
260
261
  - lib/graph_weaver/in_process.rb
261
262
  - lib/graph_weaver/inflect.rb
262
263
  - lib/graph_weaver/input_struct.rb
264
+ - lib/graph_weaver/internal.rb
265
+ - lib/graph_weaver/internal/overrides.rb
266
+ - lib/graph_weaver/internal/planner.rb
267
+ - lib/graph_weaver/internal/schemas.rb
268
+ - lib/graph_weaver/internal/selection.rb
269
+ - lib/graph_weaver/internal/subgraphs.rb
270
+ - lib/graph_weaver/internal/values.rb
263
271
  - lib/graph_weaver/logging.rb
264
272
  - lib/graph_weaver/parsing.rb
265
273
  - lib/graph_weaver/query_module.rb
@@ -268,9 +276,8 @@ files:
268
276
  - lib/graph_weaver/response.rb
269
277
  - lib/graph_weaver/retry.rb
270
278
  - lib/graph_weaver/rspec.rb
279
+ - lib/graph_weaver/schema_diff.rb
271
280
  - lib/graph_weaver/schema_loader.rb
272
- - lib/graph_weaver/schemas.rb
273
- - lib/graph_weaver/selection.rb
274
281
  - lib/graph_weaver/tasks.rb
275
282
  - lib/graph_weaver/testing.rb
276
283
  - lib/graph_weaver/testing/cassette.rb
@@ -279,8 +286,6 @@ files:
279
286
  - lib/graph_weaver/testing/fake_client.rb
280
287
  - lib/graph_weaver/testing/fake_subgraph.rb
281
288
  - lib/graph_weaver/testing/router.rb
282
- - lib/graph_weaver/testing/subgraphs.rb
283
- - lib/graph_weaver/testing/values.rb
284
289
  - lib/graph_weaver/transport.rb
285
290
  - lib/graph_weaver/transport/faraday.rb
286
291
  - lib/graph_weaver/transport/http.rb
@@ -1,48 +0,0 @@
1
- # typed: true
2
- # frozen_string_literal: true
3
-
4
- require "graphql"
5
-
6
- module GraphWeaver
7
- # The graphql-ruby schema classes already in this process, and what each
8
- # one defines. Where {SchemaLoader} *builds* a schema from a source — a
9
- # path, SDL, an introspection dump — this reads classes the app loaded
10
- # itself.
11
- #
12
- # Two features ask exactly these two questions, and match a schema on the
13
- # coordinates it defines rather than on its class name: {Testing::Subgraphs}
14
- # (which schema serves which subgraph) and {Federation::Drift} (has a
15
- # subgraph changed without a recompose). What they share is this evidence,
16
- # not the verdict: Subgraphs wants every type AND field, Drift only the
17
- # types — a schema that lost a field is not a candidate to run against, but
18
- # is exactly the one Drift has to recognize to report the loss.
19
- module Schemas
20
- class << self
21
- # Every named GraphQL::Schema in the process. An anonymous one is
22
- # graphql-ruby building from SDL — the router's own view of the
23
- # supergraph is one — and never an app's subgraph.
24
- def loaded
25
- descendants(GraphQL::Schema).select(&:name)
26
- end
27
-
28
- # Does this schema carry the coordinate — "Type", or "Type.field"?
29
- def defines?(schema, coordinate)
30
- type_name, field_name = coordinate.split(".", 2)
31
- type = schema.get_type(type_name) or return false
32
- return true unless field_name
33
-
34
- return type.fields.key?(field_name) if type.respond_to?(:fields)
35
- # an input object's members are arguments, not fields
36
- return type.arguments.key?(field_name) if type.respond_to?(:arguments)
37
-
38
- false
39
- end
40
-
41
- private
42
-
43
- def descendants(klass)
44
- klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
45
- end
46
- end
47
- end
48
- end