graph_weaver 0.5.0 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. 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,17 +61,47 @@ 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
 
73
+ # Called by generated code — not semver'd for direct use.
74
+ #
75
+ # Cast a response's data, keeping the server's own errors on a failure.
76
+ # The common cause of a cast failure is a field that came back null *with
77
+ # a reason attached* — a permission rule, a partial outage — and raising
78
+ # only Sorbet's nil complaint throws that reason away, leaving whoever is
79
+ # on call with a type error and no explanation.
80
+ sig do
81
+ params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
82
+ .returns(T.untyped)
83
+ end
84
+ def cast_data(struct, data, errors)
85
+ struct.from_h(data)
86
+ rescue GraphWeaver::TypeError => e
87
+ raise if errors.empty?
88
+
89
+ detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
90
+ raise GraphWeaver::TypeError.new(
91
+ struct:,
92
+ message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
93
+ )
94
+ end
95
+
96
+ # Called by generated code — not semver'd for direct use.
97
+ #
66
98
  # Shape-check a raw response envelope, returning it. Generated
67
99
  # from_response is public API taking anything with #to_h, so a malformed
68
100
  # body has to brand rather than escape as a raw Sorbet TypeError from a sig
69
101
  # (which fires before the struct's own rescue can see it). Lives here rather
70
102
  # than unrolled into every generated module.
71
103
  def check_envelope!(raw, struct)
104
+ raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
72
105
  unless raw.is_a?(Hash)
73
106
  raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
74
107
  end
@@ -85,36 +118,37 @@ module GraphWeaver
85
118
  raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
86
119
  end
87
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
+
88
131
  raw
89
132
  end
90
133
 
91
- # The module a .graphql file generates, and the basename of the file it
92
- # generates into: the camelized file name plus the operation's own word.
93
- #
94
- # person.graphql => PersonQuery (person_query.rb)
95
- # save_list_entry.graphql => SaveListEntryMutation
96
- # (save_list_entry_mutation.rb)
97
- #
98
- # Every naming site goes through here — generate!, parse(path), and
99
- # load_queries! — so the constant a file produces is the same one
100
- # whichever door you came in by, and the file it lands in matches it.
101
- def generated_names(path, source)
102
- base = File.basename(path, ".*")
103
- suffix = operation_suffix(source)
104
- ["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
105
- 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
106
141
 
107
- # just the module name see generated_names
108
- 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)
109
147
 
110
- # "Mutation" for a mutation document, "Query" for everything else.
111
- def operation_suffix(source)
112
- operation = GraphQL.parse(source).definitions
113
- .grep(GraphQL::Language::Nodes::OperationDefinition).first
114
- (operation&.operation_type == "mutation") ? "Mutation" : "Query"
115
- rescue GraphQL::ParseError
116
- "Query" # unparseable: codegen brands the real error a moment later
148
+ rails = Object.const_get(:Rails)
149
+ rails.root if rails.respond_to?(:root)
117
150
  end
151
+ private :rails_root
118
152
 
119
153
  # Conventional locations. Every directory setting is a LIST,
120
154
  # factory_bot-style: extra locations (a test-only dir, an engine's) can be
@@ -131,11 +165,6 @@ module GraphWeaver
131
165
  # read. A second schema is a second generate! (schema: names it).
132
166
  attr_writer :schema_path
133
167
 
134
- # Set by every graph_weaver rake task: those tasks WRITE the generated
135
- # files, so loading them first lets a stale one block its own repair.
136
- # None of them needs the modules loaded.
137
- attr_accessor :skip_generated_load
138
-
139
168
  def queries_paths = @queries_paths ||= ["app/graphql/queries"]
140
169
  def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
141
170
 
@@ -159,11 +188,10 @@ module GraphWeaver
159
188
 
160
189
  def schema_path = @schema_path || "app/graphql/schema.json"
161
190
 
162
- # Every query document under these directories, sorted the files
163
- # generate!, verify_generated!, check_queries and load_queries! all read.
164
- def query_files(paths = queries_paths)
165
- Array(paths).flat_map { |dir| Dir[File.join(dir, Codegen::DOCUMENT_GLOB)].sort }
166
- end
191
+ # Set by every graph_weaver rake task: those tasks WRITE the generated
192
+ # files, so loading them first lets a stale one block its own repair.
193
+ # None of them needs the modules loaded.
194
+ attr_accessor :skip_generated_load
167
195
 
168
196
  # The name of the shared module — the types that live once per schema
169
197
  # (input types, enums, unions hoisted from shared fragments) and are
@@ -183,38 +211,58 @@ module GraphWeaver
183
211
  #
184
212
  # GraphWeaver.generate! # queries_paths -> generated_paths.first
185
213
  #
186
- # person.graphql => person_query.rb defining PersonQuery. Returns the
187
- # 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
188
217
  # (see #orphaned), so renaming or dropping a .graphql leaves nothing
189
218
  # behind. Pair with a freshness spec (docs/generated_modules.md).
190
219
  def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
191
220
  types_module: nil)
192
221
  schema = schema ? schema_for(schema) : locate_schema!
193
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
+
194
229
  plan = generation_plan(queries:, schema:, client:, types_module:)
230
+ @unmatched_registrations = Codegen.unmatched_registrations(schema)
231
+ @changed_files = []
195
232
  written = plan.map do |filename, source|
196
- target = File.join(output, filename)
233
+ target = File.join(Internal::Util.resolve(output), filename)
234
+ next target if current?(target, source)
235
+
197
236
  FileUtils.mkdir_p(File.dirname(target))
198
- File.write(target, source)
199
- 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}" }
200
244
  target
201
245
  end
202
246
 
203
247
  orphaned(output, written).each do |orphan|
204
248
  File.delete(orphan)
205
- log(:info) { "pruned #{orphan}" }
249
+ Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
206
250
  end
207
251
 
208
- written
252
+ written.map { |target| Internal::Util.relative(target) }
209
253
  end
210
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
+
211
260
  # Generated files under output the current plan no longer produces — a
212
261
  # query renamed or deleted, a type dropped from the schema, a union no
213
262
  # longer hoisted. Left alone they'd keep being required by
214
263
  # load_generated!, resolving against a query that no longer exists.
215
264
  def orphaned(output, produced)
216
- current = produced.map { |path| File.expand_path(path) }
217
- generated_files(output).reject { |path| current.include?(File.expand_path(path)) }
265
+ generated_files(output) - produced
218
266
  end
219
267
  private :orphaned
220
268
 
@@ -223,7 +271,7 @@ module GraphWeaver
223
271
  # safe: this is a real directory, and a hand-written file in it must
224
272
  # survive regeneration.
225
273
  def generated_files(output)
226
- Dir[File.join(output, "**/*.rb")].sort.select do |path|
274
+ Dir[File.join(Internal::Util.resolve(output), "**/*.rb")].sort.select do |path|
227
275
  File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
228
276
  end
229
277
  end
@@ -240,24 +288,46 @@ module GraphWeaver
240
288
  # end
241
289
  def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
242
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
+
243
298
  schema = schema ? schema_for(schema) : locate_schema!
244
299
  plan = generation_plan(queries:, schema:, client:, types_module:)
300
+ @unmatched_registrations = Codegen.unmatched_registrations(schema)
245
301
  stale = plan.filter_map do |filename, source|
246
- target = File.join(output, filename)
247
- # git's autocrlf rewrites line endings on checkout — a Windows working
248
- # copy is not stale generated code, so don't fail CI over it
249
- 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)
250
304
  end
251
305
  # strays: a generated file the current schema + queries no longer produce
252
- stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
306
+ stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
253
307
 
254
308
  unless stale.empty?
255
- 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(", ")}"
256
311
  end
257
312
 
258
313
  true
259
314
  end
260
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
+
261
331
  # Which checked-in queries no longer validate — breaking-change
262
332
  # detection scoped to the operations you actually ship. Reports rather
263
333
  # than raising, keyed by file, JSON-ready like every #to_h here:
@@ -287,9 +357,9 @@ module GraphWeaver
287
357
  schema = schema ? schema_for(schema) : refreshed_schema
288
358
  shared = Codegen.load_fragments(fragments)
289
359
 
290
- query_files(queries).each_with_object({}) do |path, failures|
360
+ Internal::Util.query_files(queries).each_with_object({}) do |path, failures|
291
361
  errors = validation_errors(schema, File.read(path), shared, table)
292
- failures[path] = errors if errors.any?
362
+ failures[Internal::Util.relative(path)] = errors if errors.any?
293
363
  end
294
364
  end
295
365
 
@@ -300,7 +370,7 @@ module GraphWeaver
300
370
  # nil when a live schema class is what gets checked, since the dump then
301
371
  # isn't what the errors came from.
302
372
  def checked_routing_table
303
- return if live_schema
373
+ return if Internal::Util.live_schema
304
374
 
305
375
  path = SchemaLoader.locate_path
306
376
  return unless path&.end_with?(".graphql", ".gql")
@@ -319,31 +389,19 @@ module GraphWeaver
319
389
  # class — hand-written SDL, a composed supergraph — have nothing to
320
390
  # re-read, so they're checked as they are.
321
391
  def refreshed_schema
322
- live = live_schema
392
+ live = Internal::Util.live_schema
323
393
  return live if live
324
394
 
325
395
  # locate_schema! raises the conventional "no schema dump" message
326
396
  path = SchemaLoader.locate_path or locate_schema!
327
- meta = SchemaLoader.provenance(path)
328
- return SchemaLoader.load(path) unless meta&.key?("url")
397
+ return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
329
398
 
330
- SchemaLoader.introspect(new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport)
399
+ # source_transport rather than one built here: it reads the auth ENV var
400
+ # the dump named, so `--auth MY_TOKEN` reaches this path too
401
+ SchemaLoader.introspect(SchemaLoader.source_transport(path))
331
402
  end
332
403
  private :refreshed_schema
333
404
 
334
- # The graphql-ruby schema class the app default executes against, when it
335
- # runs in-process — a Client wrapping one, or the class in the slot bare.
336
- # nil for every network client. Not memoized: in dev the class object is
337
- # replaced on reload. (Public because testing's :in_process mode asks:
338
- # a client already running in-process names its own schema class.)
339
- def live_schema
340
- # through #transport, not #schema: a url client's #schema introspects,
341
- # so asking it would answer this question over the network
342
- target = client.is_a?(Client) ? client.transport : client
343
- target = target.schema if target.is_a?(InProcess)
344
- target if target.is_a?(Class) && target <= GraphQL::Schema
345
- end
346
-
347
405
  # One query's schema-validation errors as JSON-ready hashes, with the
348
406
  # source position graphql-ruby reports. Unparseable counts as an error
349
407
  # too — it doesn't validate either, and inline_fragments (which parses
@@ -403,20 +461,68 @@ module GraphWeaver
403
461
  # a schema migration).
404
462
  def load_generated!(path = nil)
405
463
  paths = path ? [path] : generated_paths
406
- 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
407
465
  files.each do |file|
408
- require File.expand_path(file)
466
+ require file
409
467
  rescue NameError => e
468
+ reported = Internal::Util.relative(file)
410
469
  # a dropped extend_type leaves this include dangling; say so here,
411
470
  # because the raw NameError points at generated code and names no fix
412
- helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1] or raise
413
- raise Error, "#{file} includes GraphWeaver::TypeHelpers::#{helper}, but nothing registers it — " \
414
- "the extend_type(#{helper.inspect}) it was generated from is gone. Re-add that registration, " \
415
- "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
511
+ end
512
+ load_generated!
513
+ end
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)
416
522
  end
417
- log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
418
- files
523
+ owner.send(:remove_const, base) if owner.const_defined?(base, false)
419
524
  end
525
+ private :undefine
420
526
 
421
527
  # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
422
528
  # the console object and the rake task point at the same thing. A path
@@ -453,11 +559,12 @@ module GraphWeaver
453
559
 
454
560
  seen = {} # module name => the file that produced it, for the collision message
455
561
 
456
- plan = query_files(queries).map do |path|
562
+ plan = Internal::Util.query_files(queries).map do |path|
457
563
  source = File.read(path)
458
- name, filename = generated_names(path, source)
564
+ name, filename = Internal::Util.generated_names(path, source)
459
565
  if (earlier = seen[name])
460
- 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; " \
461
568
  "the module name comes from the file name alone (directories don't namespace it), so rename one"
462
569
  end
463
570
  seen[name] = path
@@ -465,7 +572,7 @@ module GraphWeaver
465
572
  codegen = Codegen.new(
466
573
  schema:,
467
574
  query: Codegen.inline_fragments(source, shared, path),
468
- module_name: name,
575
+ name:,
469
576
  client:,
470
577
  types_namespace: types_module,
471
578
  hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
@@ -478,7 +585,7 @@ module GraphWeaver
478
585
  end
479
586
 
480
587
  if used_unions.any? || used.values.any?(&:any?)
481
- codegen = Codegen.new(schema:, query: "", module_name: types_module)
588
+ codegen = Codegen.new(schema:, query: "", name: types_module)
482
589
  plan = codegen.generate_types(
483
590
  inputs: used[:inputs], enums: used[:enums] + used[:mapped],
484
591
  unions: used_unions, fragments: shared,
@@ -489,15 +596,6 @@ module GraphWeaver
489
596
  end
490
597
  private :generation_plan
491
598
 
492
- # coerce: true for every scalar that doesn't say coerce: itself —
493
- # the same switch at global scope, resolved lazily at generation time
494
- # (so set it any time before you generate, no ordering dance):
495
- #
496
- # GraphWeaver.auto_coerce = true
497
- #
498
- # An explicit coerce: on a registration always wins.
499
- attr_accessor :auto_coerce
500
-
501
599
  # Whether generated modules/structs emit `extend T::Sig` (so `sig`
502
600
  # resolves standalone). Default (nil) auto-detects: an app that globally
503
601
  # injects T::Sig (`class Module; include T::Sig`) makes the per-struct
@@ -516,15 +614,7 @@ module GraphWeaver
516
614
  # Whether the host app has globally injected T::Sig into every module
517
615
  # (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
518
616
  def global_tsig? = Module.include?(T::Sig)
519
-
520
- # The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
521
- # or nil (also nil when did_you_mean isn't loadable). One home for the guard
522
- # used by codegen validation, alias resolution, and the runtime prop hints.
523
- def did_you_mean(dictionary, term)
524
- return unless defined?(DidYouMean::SpellChecker)
525
-
526
- DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
527
- end
617
+ private :global_tsig?
528
618
 
529
619
  # Teach the generator how a GraphQL custom scalar deserializes into a
530
620
  # rich Ruby object (and serializes back onto the wire when used as a
@@ -540,12 +630,17 @@ module GraphWeaver
540
630
  # Proc(expr) => code string, or :itself to force pass-through. requires:
541
631
  # (a String or Array) names files the generated code needs — validated,
542
632
  # and actually required to confirm it resolves when type: is a real class.
543
- # coerce: true makes a variable of this scalar accept the value OR its
544
- # raw input (e.g. "12.00"), normalizing the latter before serializing —
545
- # it raises on bad input, so some safety survives; GraphWeaver.auto_coerce
546
- # 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
547
636
  # pre-registered the same way, so this also overrides them.
548
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
+ #
549
644
  # Pass a `Type.field` coordinate instead of a scalar name to override just
550
645
  # that one field — so the same scalar can deserialize as different Ruby
551
646
  # types across fields (a `Date` for `User.birthday`, a `Time` elsewhere):
@@ -554,8 +649,8 @@ module GraphWeaver
554
649
  #
555
650
  # A field-level override wins over the scalar-name registration. Same
556
651
  # signature either way. Call before generating.
557
- def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
558
- 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:)
559
654
  end
560
655
 
561
656
  # Map a GraphQL enum onto an app-owned T::Enum, so generated code
@@ -593,8 +688,9 @@ module GraphWeaver
593
688
  end
594
689
 
595
690
  # Every registry back to its starting state: built-in scalars restored,
596
- # enum mappings and type helpers dropped — the clean slate between
597
- # 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:
598
694
  # GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
599
695
  # .reset_type_helpers!)
600
696
  def reset_registrations!
@@ -607,23 +703,30 @@ module GraphWeaver
607
703
  #
608
704
  # schema: is a graphql-ruby schema or a Client (its schema, and its
609
705
  # transport as the module's default). query is a .graphql/.gql path (module
610
- # name derived from the file name
611
- # and the operation see #module_name) or a raw query string (name
612
- # derived from the operation name,
613
- # falling back to "Query" for anonymous operations collisions are
614
- # impossible since each parse gets its own container). Pass name: to
615
- # override, client: to bake the module's default client/transport.
706
+ # name derived from the file name and the operation — see #module_name) or
707
+ # a raw query string (name derived from the operation name, falling back to
708
+ # "Query" for anonymous operations — collisions are impossible since each
709
+ # parse gets its own container). Pass name: to override, client: to bake
710
+ # the module's default client/transport.
616
711
  def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
617
712
  client ||= schema if schema.is_a?(Client)
618
713
  schema = schema_for(schema)
714
+ # Rails.root.join(...) hands you a Pathname, and to_path is the
715
+ # ecosystem's "I am a path" — the same conversion schema: gets through
716
+ # SchemaLoader.load. Without it end_with? below is a NoMethodError.
717
+ query = query.to_path if query.respond_to?(:to_path)
619
718
  path = query if query.end_with?(".graphql", ".gql")
620
719
  if path
621
- query = File.read(path)
622
- 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"
623
726
  end
624
727
  query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
625
728
 
626
- Codegen.parse(schema:, query:, module_name: name, client:, path:)
729
+ Codegen.parse(schema:, query:, name:, client:, path:)
627
730
  end
628
731
 
629
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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -227,16 +227,10 @@ extra_rdoc_files: []
227
227
  files:
228
228
  - ".yardopts"
229
229
  - CHANGELOG.md
230
- - CLAUDE.md
231
- - DECISIONS.md
232
230
  - Gemfile
233
231
  - Gemfile.lock
234
232
  - LICENSE.txt
235
- - Makefile
236
- - NOTES.md
237
- - PLAN.md
238
233
  - README.md
239
- - REVIEW.md
240
234
  - docs/cassettes.md
241
235
  - docs/editors.md
242
236
  - docs/errors.md
@@ -260,12 +254,20 @@ files:
260
254
  - lib/graph_weaver/codegen/nodes.rb
261
255
  - lib/graph_weaver/codegen/scalar_type.rb
262
256
  - lib/graph_weaver/codegen/type_helpers.rb
257
+ - lib/graph_weaver/coerce.rb
263
258
  - lib/graph_weaver/errors.rb
264
259
  - lib/graph_weaver/federation.rb
265
260
  - lib/graph_weaver/hints.rb
266
261
  - lib/graph_weaver/in_process.rb
267
262
  - lib/graph_weaver/inflect.rb
268
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
269
271
  - lib/graph_weaver/logging.rb
270
272
  - lib/graph_weaver/parsing.rb
271
273
  - lib/graph_weaver/query_module.rb
@@ -274,9 +276,8 @@ files:
274
276
  - lib/graph_weaver/response.rb
275
277
  - lib/graph_weaver/retry.rb
276
278
  - lib/graph_weaver/rspec.rb
279
+ - lib/graph_weaver/schema_diff.rb
277
280
  - lib/graph_weaver/schema_loader.rb
278
- - lib/graph_weaver/schemas.rb
279
- - lib/graph_weaver/selection.rb
280
281
  - lib/graph_weaver/tasks.rb
281
282
  - lib/graph_weaver/testing.rb
282
283
  - lib/graph_weaver/testing/cassette.rb
@@ -285,8 +286,6 @@ files:
285
286
  - lib/graph_weaver/testing/fake_client.rb
286
287
  - lib/graph_weaver/testing/fake_subgraph.rb
287
288
  - lib/graph_weaver/testing/router.rb
288
- - lib/graph_weaver/testing/subgraphs.rb
289
- - lib/graph_weaver/testing/values.rb
290
289
  - lib/graph_weaver/transport.rb
291
290
  - lib/graph_weaver/transport/faraday.rb
292
291
  - lib/graph_weaver/transport/http.rb
@@ -294,7 +293,12 @@ files:
294
293
  homepage: https://github.com/dpep/graph_weaver
295
294
  licenses:
296
295
  - MIT
297
- metadata: {}
296
+ metadata:
297
+ bug_tracker_uri: https://github.com/dpep/graph_weaver/issues
298
+ changelog_uri: https://github.com/dpep/graph_weaver/blob/main/CHANGELOG.md
299
+ documentation_uri: https://github.com/dpep/graph_weaver/tree/main/docs
300
+ rubygems_mfa_required: 'true'
301
+ source_code_uri: https://github.com/dpep/graph_weaver
298
302
  rdoc_options: []
299
303
  require_paths:
300
304
  - lib
@@ -311,5 +315,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
311
315
  requirements: []
312
316
  rubygems_version: 3.6.9
313
317
  specification_version: 4
314
- summary: GraphWeaver
318
+ summary: A typed GraphQL client for Ruby
315
319
  test_files: []