graph_weaver 0.4.6 → 0.5.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
data/lib/graph_weaver.rb CHANGED
@@ -5,10 +5,12 @@ require_relative "graph_weaver/logging"
5
5
  require_relative "graph_weaver/errors"
6
6
  require_relative "graph_weaver/hints"
7
7
  require_relative "graph_weaver/input_struct"
8
+ require_relative "graph_weaver/query_module"
8
9
  require_relative "graph_weaver/response"
9
10
  require_relative "graph_weaver/inflect"
10
11
  require_relative "graph_weaver/codegen"
11
12
  require_relative "graph_weaver/client"
13
+ require_relative "graph_weaver/in_process"
12
14
  require_relative "graph_weaver/transport/http"
13
15
  require_relative "graph_weaver/retry"
14
16
  require_relative "graph_weaver/schema_loader"
@@ -18,6 +20,14 @@ require_relative "graph_weaver/railtie" if defined?(::Rails::Railtie)
18
20
  # opt-in extras:
19
21
  # require "graph_weaver/transport/faraday" # Faraday transport
20
22
  module GraphWeaver
23
+ # The line every generated file opens with (see Codegen::Emit) — the marker
24
+ # that tells generate! which files in the output directory are its to prune.
25
+ GENERATED_HEADER = "# Generated by GraphWeaver"
26
+
27
+ # How far into a file to look for it: the header sits under the `typed:` and
28
+ # `frozen_string_literal:` magic comments, never deeper.
29
+ HEADER_SCAN_LINES = 10
30
+
21
31
  class << self
22
32
  # A client for one GraphQL server — transport, schema, and scoped
23
33
  # scalars in one object (see Client):
@@ -35,40 +45,113 @@ module GraphWeaver
35
45
  #
36
46
  # GraphWeaver.client = GraphWeaver.new(url, auth: token)
37
47
  #
38
- # Accepts a Client or anything satisfying the execute contract (a
39
- # schema class, a fake testing's auto_fake swaps one in per
48
+ # Anything satisfying the execute contract a Client, a schema class,
49
+ # a transport, a fake (testing's graphql: tag swaps one in per
40
50
  # example). Generated modules resolve per call -> per module
41
51
  # (MyQuery.client=) -> baked constant -> here.
42
52
  attr_accessor :client
43
53
 
44
54
  # the default client, when one is required
45
55
  def client!
46
- @client or raise Error, "no client configured set GraphWeaver.client= or pass a client"
56
+ # in a spec suite this is nearly always a forgotten tag, and "set
57
+ # GraphWeaver.client=" is advice for the wrong file — graph_weaver/rspec
58
+ # being loaded is what says which suggestion is the useful one
59
+ @client or raise Error, "no client configured — " + if defined?(Testing::RSpecIntegration)
60
+ "tag the example graphql: :fake (or :in_process / :router), or build one with graphql_fake"
61
+ else
62
+ "set GraphWeaver.client= or pass a client"
63
+ end
64
+ end
65
+
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.
71
+ # Cast a response's data, keeping the server's own errors on a failure.
72
+ # The common cause of a cast failure is a field that came back null *with
73
+ # a reason attached* — a permission rule, a partial outage — and raising
74
+ # only Sorbet's nil complaint throws that reason away, leaving whoever is
75
+ # on call with a type error and no explanation.
76
+ sig do
77
+ params(struct: T.untyped, data: T.untyped, errors: T::Array[GraphWeaver::GraphQLError])
78
+ .returns(T.untyped)
79
+ end
80
+ def cast_data(struct, data, errors)
81
+ struct.from_h(data)
82
+ rescue GraphWeaver::TypeError => e
83
+ raise if errors.empty?
84
+
85
+ detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
86
+ raise GraphWeaver::TypeError.new(
87
+ struct:,
88
+ message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
89
+ )
90
+ end
91
+
92
+ def check_envelope!(raw, struct)
93
+ unless raw.is_a?(Hash)
94
+ raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
95
+ end
96
+
97
+ %w[data extensions].each do |key|
98
+ value = raw[key]
99
+ next if value.nil? || value.is_a?(Hash)
100
+
101
+ raise GraphWeaver::TypeError.new(struct:, message: "response #{key.inspect} must be an object, got #{value.class}")
102
+ end
103
+
104
+ errors = raw["errors"]
105
+ unless errors.nil? || (errors.is_a?(Array) && errors.all?(Hash))
106
+ raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
107
+ end
108
+
109
+ raw
47
110
  end
48
111
 
49
- # The transport behind a client-or-transport value: a Client resolves
50
- # to its own transport, anything else already speaks execute.
51
- # Generated modules call this on every execute, so any slot in the
52
- # resolution chain can hold either kind.
53
- def resolve_transport(target)
54
- target.is_a?(Client) ? target.transport! : target
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
127
+
128
+ # just the module name — see generated_names
129
+ def module_name(path, source) = generated_names(path, source).first
130
+
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
55
138
  end
56
139
 
57
- # Conventional locations, factory_bot-style LISTS, so extra
58
- # locations (a test-only dir, an engine's) can be appended and every
59
- # loader walks them all:
140
+ # Conventional locations. Every directory setting is a LIST,
141
+ # factory_bot-style: extra locations (a test-only dir, an engine's) can be
142
+ # appended and every reader walks them all. Entries may be glob patterns,
143
+ # and the generated default already matches per-schema layouts
144
+ # (app/graphql/github/generated). Assigning a String wraps it, so pointing
145
+ # at one directory stays a one-liner:
60
146
  #
61
- # # e.g. in spec/support/graph_weaver.rb
62
- # GraphWeaver.generated_paths << "spec/support/graphql/generated"
63
- # GraphWeaver.queries_paths << "spec/support/graphql/queries"
147
+ # GraphWeaver.queries_paths = "app/graphql/operations"
148
+ # GraphWeaver.generated_paths << "spec/graphql/generated"
64
149
  #
65
- # The singular accessors read the first entry (the default target
66
- # for generate! and the rake tasks); assigning one replaces the list.
67
- attr_writer :queries_paths, :generated_paths, :schema_path, :fragments_paths
150
+ # schema_path is the exception, and singular on purpose: one generate! run
151
+ # reads ONE schema, so a second dump in a list is a file nothing would ever
152
+ # read. A second schema is a second generate! (schema: names it).
153
+ attr_writer :schema_path
68
154
 
69
- # Entries may be glob patterns — the generated default also matches
70
- # per-schema layouts (app/graphql/github/generated). Queries stay
71
- # single-schema: load_queries! parses everything against one client.
72
155
  def queries_paths = @queries_paths ||= ["app/graphql/queries"]
73
156
  def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
74
157
 
@@ -77,65 +160,59 @@ module GraphWeaver
77
160
  # query stays self-contained.
78
161
  def fragments_paths = @fragments_paths ||= ["app/graphql/fragments"]
79
162
 
80
- def queries_path = queries_paths.first
81
- def generated_path = generated_paths.first
82
- def fragments_path = fragments_paths.first
163
+ # nil restores the default; a String is one entry, not a second spelling
164
+ def queries_paths=(paths)
165
+ @queries_paths = paths && Array(paths)
166
+ end
83
167
 
84
- def queries_path=(path)
85
- @queries_paths = path.nil? ? nil : [path]
168
+ def generated_paths=(paths)
169
+ @generated_paths = paths && Array(paths)
86
170
  end
87
171
 
88
- def generated_path=(path)
89
- @generated_paths = path.nil? ? nil : [path]
172
+ def fragments_paths=(paths)
173
+ @fragments_paths = paths && Array(paths)
90
174
  end
91
175
 
92
176
  def schema_path = @schema_path || "app/graphql/schema.json"
93
177
 
94
- # The shared-inputs / shared-unions module names: set them globally, pass
95
- # inputs_module:/unions_module: per generate!, or let them derive from the
96
- # output path the directory above generated/ names the schema in
97
- # multi-schema layouts (app/graphql/github/generated => GithubInputs /
98
- # GithubUnions); the conventional layout (and anything unrecognizable)
99
- # stays GraphQLInputs / GraphQLUnions.
100
- attr_writer :inputs_module, :unions_module
101
-
102
- def inputs_module(output = generated_path)
103
- @inputs_module || derive_module("Inputs", output)
104
- end
105
-
106
- def unions_module(output = generated_path)
107
- @unions_module || derive_module("Unions", output)
108
- end
178
+ # Set by every graph_weaver rake task: those tasks WRITE the generated
179
+ # files, so loading them first lets a stale one block its own repair.
180
+ # None of them needs the modules loaded.
181
+ attr_accessor :skip_generated_load
109
182
 
110
- # Name a shared module from the output path: <Schema><suffix> in a
111
- # multi-schema layout, else GraphQL<suffix>.
112
- def derive_module(suffix, output)
113
- segments = File.expand_path(output.to_s).split(File::SEPARATOR)
114
- segments.pop if segments.last == "generated"
115
- parent = segments.last.to_s
116
- if parent.match?(/\A[a-zA-Z]\w*\z/) && !%w[graphql app lib spec support test].include?(parent)
117
- "#{Inflect.camelize(parent)}#{suffix}"
118
- else
119
- "GraphQL#{suffix}"
120
- end
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 }
121
187
  end
122
- private :derive_module
123
188
 
124
- # Generate every .graphql query in a directory into checked-in Ruby
125
- # files. Paths default to the conventions above; schema: defaults to
126
- # the dump at schema_path (any supported extension):
189
+ # The name of the shared module the types that live once per schema
190
+ # (input types, enums, unions hoisted from shared fragments) and are
191
+ # aliased into every query module that touches them. Constant, not derived
192
+ # from where you put the files: set it globally, or pass types_module: per
193
+ # generate!. A multi-schema layout names it in the same initializer that
194
+ # sets its paths.
195
+ attr_writer :types_module
196
+
197
+ def types_module = @types_module || "GraphQLTypes"
198
+
199
+ # Generate every query in a directory — .graphql/.gql, subdirectories
200
+ # included — into checked-in Ruby files. Paths default to the conventions
201
+ # above; schema: defaults to the dump at schema_path (any supported
202
+ # extension), and also takes a Client (its schema — the console object,
203
+ # no dump needed):
127
204
  #
128
- # GraphWeaver.generate! # queries_path -> generated_path
205
+ # GraphWeaver.generate! # queries_paths -> generated_paths.first
129
206
  #
130
207
  # person.graphql => person_query.rb defining PersonQuery. Returns the
131
- # written paths. Pair with a freshness spec (docs/generated_modules.md).
132
- def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil,
133
- inputs_module: nil, unions_module: nil)
134
- schema ||= locate_schema!
135
- inputs_module ||= self.inputs_module(output)
136
- unions_module ||= self.unions_module(output)
137
-
138
- plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
208
+ # written paths. Generated files the plan no longer produces are deleted
209
+ # (see #orphaned), so renaming or dropping a .graphql leaves nothing
210
+ # behind. Pair with a freshness spec (docs/generated_modules.md).
211
+ def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
212
+ types_module: nil)
213
+ schema = schema ? schema_for(schema) : locate_schema!
214
+
215
+ plan = generation_plan(queries:, schema:, client:, types_module:)
139
216
  written = plan.map do |filename, source|
140
217
  target = File.join(output, filename)
141
218
  FileUtils.mkdir_p(File.dirname(target))
@@ -144,9 +221,7 @@ module GraphWeaver
144
221
  target
145
222
  end
146
223
 
147
- # a type dropped from the schema (or a union no longer hoisted) must not
148
- # linger as a stale file — inputs/ and unions.rb are wholly generated
149
- (shared_artifacts(output) - written).each do |orphan|
224
+ orphaned(output, written).each do |orphan|
150
225
  File.delete(orphan)
151
226
  log(:info) { "pruned #{orphan}" }
152
227
  end
@@ -154,32 +229,48 @@ module GraphWeaver
154
229
  written
155
230
  end
156
231
 
157
- # The wholly-generated shared-artifact files under output (inputs/*.rb and
158
- # unions.rb) safe to prune when regeneration no longer produces them.
159
- def shared_artifacts(output)
160
- Dir[File.join(output, "inputs", "*.rb")] + Dir[File.join(output, "unions.rb")]
232
+ # Generated files under output the current plan no longer produces — a
233
+ # query renamed or deleted, a type dropped from the schema, a union no
234
+ # longer hoisted. Left alone they'd keep being required by
235
+ # load_generated!, resolving against a query that no longer exists.
236
+ 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)) }
161
239
  end
162
- private :shared_artifacts
240
+ private :orphaned
241
+
242
+ # Every .rb under output that GraphWeaver wrote, identified by the header
243
+ # it emits. The header — not a *_query.rb glob — is what makes pruning
244
+ # safe: this is a real directory, and a hand-written file in it must
245
+ # survive regeneration.
246
+ def generated_files(output)
247
+ Dir[File.join(output, "**/*.rb")].sort.select do |path|
248
+ File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
249
+ end
250
+ end
251
+ private :generated_files
163
252
 
164
253
  # The freshness guard: raise unless every generated file matches what
165
- # the current schema + queries + scalar registrations would produce.
166
- # One line in a spec, or `rake graph_weaver:verify` in CI:
254
+ # the current schema + queries + scalar registrations would produce
255
+ # counting a generated file the plan no longer produces as stale, so a
256
+ # deleted query can't leave a live module behind. One line in a spec, or
257
+ # `rake graph_weaver:verify` in CI:
167
258
  #
168
259
  # it "generated queries are current" do
169
260
  # GraphWeaver.verify_generated!
170
261
  # end
171
- def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil,
172
- inputs_module: nil, unions_module: nil)
173
- schema ||= locate_schema!
174
- inputs_module ||= self.inputs_module(output)
175
- unions_module ||= self.unions_module(output)
176
- plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
262
+ def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
263
+ types_module: nil)
264
+ schema = schema ? schema_for(schema) : locate_schema!
265
+ plan = generation_plan(queries:, schema:, client:, types_module:)
177
266
  stale = plan.filter_map do |filename, source|
178
267
  target = File.join(output, filename)
179
- target unless File.exist?(target) && File.read(target) == source
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")
180
271
  end
181
- # strays: a shared-artifact file the current schema + queries no longer produce
182
- stale += shared_artifacts(output) - plan.map { |f, _| File.join(output, f) }
272
+ # strays: a generated file the current schema + queries no longer produce
273
+ stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
183
274
 
184
275
  unless stale.empty?
185
276
  raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
@@ -188,6 +279,140 @@ module GraphWeaver
188
279
  true
189
280
  end
190
281
 
282
+ # Which checked-in queries no longer validate — breaking-change
283
+ # detection scoped to the operations you actually ship. Reports rather
284
+ # than raising, keyed by file, JSON-ready like every #to_h here:
285
+ #
286
+ # GraphWeaver.check_queries
287
+ # # => { "app/graphql/queries/person.graphql" =>
288
+ # # [{ "message" => "Field 'titel' doesn't exist on type 'Person'",
289
+ # # "line" => 4, "column" => 5 }] }
290
+ #
291
+ # Empty means every query validates. schema: defaults to the server as
292
+ # it is now — a FRESH introspection of the url the dump records, or the
293
+ # live schema class when the app default runs in-process — and the dump
294
+ # is left alone; pass schema: and nothing touches the network.
295
+ #
296
+ # When that dump is a composed supergraph, an error naming a type is
297
+ # branded with the subgraphs behind it — "…on type 'Product'
298
+ # (products, reviews)", plus a "subgraphs" key — since knowing whose
299
+ # code to look at is half the answer. A plain schema is unaffected.
300
+ #
301
+ # A different question from verify_generated!, which asks whether the
302
+ # committed Ruby matches the committed schema. `rake
303
+ # graph_weaver:queries:check` prints this and exits non-zero.
304
+ def check_queries(schema: nil, queries: queries_paths, fragments: fragments_paths)
305
+ # subgraph branding comes from the local supergraph dump, so a caller
306
+ # supplying its own schema opts out of it
307
+ table = schema ? nil : checked_routing_table
308
+ schema = schema ? schema_for(schema) : refreshed_schema
309
+ shared = Codegen.load_fragments(fragments)
310
+
311
+ query_files(queries).each_with_object({}) do |path, failures|
312
+ errors = validation_errors(schema, File.read(path), shared, table)
313
+ failures[path] = errors if errors.any?
314
+ end
315
+ end
316
+
317
+ # The routing table behind the schema check_queries is about to use,
318
+ # when there is one: a composed supergraph dump says who resolves what,
319
+ # so a validation error can name the subgraph whose code to look at. nil
320
+ # for every other source — a plain schema is entirely unaffected — and
321
+ # nil when a live schema class is what gets checked, since the dump then
322
+ # isn't what the errors came from.
323
+ def checked_routing_table
324
+ return if live_schema
325
+
326
+ path = SchemaLoader.locate_path
327
+ return unless path&.end_with?(".graphql", ".gql")
328
+
329
+ sdl = File.read(path)
330
+ SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
331
+ end
332
+ private :checked_routing_table
333
+
334
+ # The schema check_queries defaults to: the server as it is now. Over a
335
+ # socket that's a fresh introspection of the url the local dump recorded,
336
+ # so no refresh step (and no rewritten dump) is needed first. In-process
337
+ # it's the live schema class — for an app that IS the server, a dump is a
338
+ # snapshot of its own code, and checking against it reports phantom
339
+ # errors about a field you just added. Dumps with no url and no live
340
+ # class — hand-written SDL, a composed supergraph — have nothing to
341
+ # re-read, so they're checked as they are.
342
+ def refreshed_schema
343
+ live = live_schema
344
+ return live if live
345
+
346
+ # locate_schema! raises the conventional "no schema dump" message
347
+ path = SchemaLoader.locate_path or locate_schema!
348
+ return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
349
+
350
+ # source_transport rather than one built here: it reads the auth ENV var
351
+ # the dump named, so `--auth MY_TOKEN` reaches this path too
352
+ SchemaLoader.introspect(SchemaLoader.source_transport(path))
353
+ end
354
+ private :refreshed_schema
355
+
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
+ # One query's schema-validation errors as JSON-ready hashes, with the
370
+ # source position graphql-ruby reports. Unparseable counts as an error
371
+ # too — it doesn't validate either, and inline_fragments (which parses
372
+ # first) has already branded it with its position.
373
+ def validation_errors(schema, source, shared, table = nil)
374
+ # path omitted: the caller keys the report by file, so branding the
375
+ # message with it too would just print the path twice
376
+ schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
377
+ detail = error.to_h
378
+ location = detail["locations"]&.first || {}
379
+ subgraphs = table ? attribute(table, detail["extensions"]) : []
380
+ entry = {
381
+ "message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
382
+ "line" => location["line"],
383
+ "column" => location["column"],
384
+ }
385
+ subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
386
+ end
387
+ rescue GraphWeaver::ValidationError => e
388
+ # an unparseable query: codegen folds the position (and the file) into
389
+ # the message, and this report keeps them separate — same splitter the
390
+ # rendered error uses, so the two can't drift apart
391
+ e.errors.map do |detail|
392
+ _path, _position, message = ValidationError.split(detail)
393
+ detail.transform_keys(&:to_s).merge("message" => message)
394
+ end
395
+ end
396
+ private :validation_errors
397
+
398
+ # Which subgraphs a validation error is about, on a federated schema:
399
+ # "Field 'weight' doesn't exist on type 'Product'" is much less useful
400
+ # than the same line plus "(products)" — whose code to look at, whose
401
+ # team to talk to. graphql-ruby reports the coordinate structurally, so
402
+ # this is a lookup rather than message parsing. Both halves of the
403
+ # coordinate are required: an argument error reports typeName "Field"
404
+ # (the AST node kind, not a type), and looking that up would attribute
405
+ # confidently and wrongly.
406
+ def attribute(table, extensions)
407
+ return [] unless extensions
408
+
409
+ type_name, field_name = extensions.values_at("typeName", "fieldName")
410
+ return [] unless type_name && field_name
411
+
412
+ table.responsible(type_name, field_name)
413
+ end
414
+ private :attribute
415
+
191
416
  # Load the generated modules — one line in an initializer or spec
192
417
  # helper (loading happens only when you call this; skip it and
193
418
  # require files yourself if you'd rather):
@@ -201,11 +426,33 @@ module GraphWeaver
201
426
  def load_generated!(path = nil)
202
427
  paths = path ? [path] : generated_paths
203
428
  files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
204
- files.each { |file| require File.expand_path(file) }
429
+ files.each do |file|
430
+ require File.expand_path(file)
431
+ rescue NameError => e
432
+ # a dropped extend_type leaves this include dangling; say so here,
433
+ # 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"
438
+ end
205
439
  log(:info) { "loaded #{files.size} generated module(s) from #{paths.join(", ")}" }
206
440
  files
207
441
  end
208
442
 
443
+ # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
444
+ # the console object and the rake task point at the same thing. A path
445
+ # (String or Pathname) or SDL loads like it does everywhere else in the
446
+ # library; without that it reached `schema.validate` as itself and failed
447
+ # as `undefined method 'validate' for an instance of String`.
448
+ def schema_for(source)
449
+ return source.schema if source.is_a?(Client)
450
+ return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
451
+
452
+ source
453
+ end
454
+ private :schema_for
455
+
209
456
  # the conventional schema dump, required
210
457
  def locate_schema!
211
458
  SchemaLoader.locate or raise Error,
@@ -213,65 +460,64 @@ module GraphWeaver
213
460
  end
214
461
  private :locate_schema!
215
462
 
216
- # (filename, source) per artifact. Every variable type is emitted once into
217
- # inputs.rb, and each named shared fragment spread as a whole-union field
218
- # once into unions.rb, with query modules aliasing what they use the
219
- # difference between hundreds of duplicated bool_exp structs (or the same
220
- # union re-typed per query) and one copy per schema. (Single-query parse
221
- # inlines both there's no cross-query set to share against.)
222
- def generation_plan(queries:, schema:, client:, inputs_module: self.inputs_module,
223
- unions_module: self.unions_module, fragments: fragments_paths)
463
+ # (filename, source) per artifact. Types a schema shares across queries
464
+ # input types, schema enums, and each named shared fragment spread as a
465
+ # whole-union field are emitted once into the shared module, with query
466
+ # modules aliasing what they use. That's the difference between hundreds of
467
+ # duplicated bool_exp structs (or one Ruby class per query for the same
468
+ # schema enum) and one copy per schema. (Single-query parse inlines
469
+ # everything there's no cross-query set to share against.)
470
+ def generation_plan(queries:, schema:, client:, types_module: nil, fragments: fragments_paths)
471
+ types_module ||= self.types_module
224
472
  used = { inputs: [], enums: [], mapped: [] }
225
473
  used_unions = []
226
474
  shared = Codegen.load_fragments(fragments)
227
475
 
228
- plan = Dir[File.join(queries, "*.graphql")].sort.map do |path|
229
- base = File.basename(path, ".graphql")
476
+ seen = {} # module name => the file that produced it, for the collision message
477
+
478
+ plan = query_files(queries).map do |path|
230
479
  source = File.read(path)
480
+ name, filename = generated_names(path, source)
481
+ if (earlier = seen[name])
482
+ raise Error, "duplicate query module #{name} — #{earlier} and #{path} both generate it; " \
483
+ "the module name comes from the file name alone (directories don't namespace it), so rename one"
484
+ end
485
+ seen[name] = path
486
+
231
487
  codegen = Codegen.new(
232
488
  schema:,
233
- query: Codegen.inline_fragments(source, shared),
234
- module_name: "#{Inflect.camelize(base)}Query",
489
+ query: Codegen.inline_fragments(source, shared, path),
490
+ module_name: name,
235
491
  client:,
236
- inputs_namespace: inputs_module,
237
- unions_namespace: unions_module,
238
- hoistable_unions: Codegen.shared_fragment_spreads(source, shared),
492
+ types_namespace: types_module,
493
+ hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
494
+ path:,
239
495
  )
240
496
  out = codegen.generate
241
497
  codegen.variable_type_names.each { |kind, names| used[kind] |= names }
242
498
  used_unions |= codegen.used_union_names
243
- ["#{base}_query.rb", out]
499
+ [filename, out]
244
500
  end
245
501
 
246
- if inputs_module && used.values.any?(&:any?)
247
- inputs = Codegen.generate_inputs(
248
- schema:, module_name: inputs_module,
249
- input_types: used[:inputs], enum_types: used[:enums] + used[:mapped],
250
- )
251
- plan = inputs.to_a + plan
252
- end
253
-
254
- if unions_module && used_unions.any?
255
- unions = Codegen.generate_unions(
256
- schema:, module_name: unions_module, fragments: shared, names: used_unions,
257
- )
258
- plan = unions.to_a + plan
502
+ if used_unions.any? || used.values.any?(&:any?)
503
+ codegen = Codegen.new(schema:, query: "", module_name: types_module)
504
+ plan = codegen.generate_types(
505
+ inputs: used[:inputs], enums: used[:enums] + used[:mapped],
506
+ unions: used_unions, fragments: shared,
507
+ ).to_a + plan
259
508
  end
260
509
 
261
510
  plan
262
511
  end
263
512
  private :generation_plan
264
513
 
265
- # Default input coercion for scalars that don't say coerce: themselves,
266
- # resolved lazily at generation time (so set it any time before you
267
- # generate no reset_scalars! ordering dance):
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):
268
517
  #
269
518
  # GraphWeaver.auto_coerce = true
270
519
  #
271
- # Convertible built-ins take their conversion (Int accepts 5/"5"),
272
- # and any scalar with a full cast/serialize pair (Date, your Money)
273
- # accepts its raw wire form. An explicit coerce: true/false/Symbol on
274
- # a registration always wins.
520
+ # An explicit coerce: on a registration always wins.
275
521
  attr_accessor :auto_coerce
276
522
 
277
523
  # Whether generated modules/structs emit `extend T::Sig` (so `sig`
@@ -317,9 +563,10 @@ module GraphWeaver
317
563
  # (a String or Array) names files the generated code needs — validated,
318
564
  # and actually required to confirm it resolves when type: is a real class.
319
565
  # coerce: true makes a variable of this scalar accept the value OR its
320
- # raw input (e.g. "12.00"), running the latter through the cast before
321
- # serializing — it raises on bad input, so some safety survives. Built-in
322
- # scalars are pre-registered the same way, so this also overrides them.
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
569
+ # pre-registered the same way, so this also overrides them.
323
570
  #
324
571
  # Pass a `Type.field` coordinate instead of a scalar name to override just
325
572
  # that one field — so the same scalar can deserialize as different Ruby
@@ -342,15 +589,9 @@ module GraphWeaver
342
589
  # renames, fallback: absorbs unknown wire values on cast (inputs stay
343
590
  # strict), requires: names files the generated code should require.
344
591
  # Generation fails naming any schema value that doesn't resolve —
345
- # exhaustiveness checked ahead of runtime. Global; client.register_enum
346
- # scopes to one client.
347
- def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
348
- Codegen.register_enum(graphql_name, type, map:, fallback:, requires:)
349
- end
350
-
351
- # Bulk, inference-only form: register_enums("Species" => PetKind, ...)
352
- def register_enums(mappings)
353
- Codegen.register_enums(mappings)
592
+ # exhaustiveness checked ahead of runtime.
593
+ def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
594
+ Codegen.register_enum(graphql_name, type, positional_map, map:, fallback:, requires:)
354
595
  end
355
596
 
356
597
  # Include app-owned helper modules into every struct generated from a
@@ -368,65 +609,68 @@ module GraphWeaver
368
609
  # def display_name = "#{name} the pet"
369
610
  # end
370
611
  #
371
- # Additive (repeated and client-scoped registrations stack). Global;
372
- # client.extend_type scopes to one client.
612
+ # Additive repeated registrations stack.
373
613
  def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
374
614
  Codegen.extend_type(graphql_name, *mixins, requires:, **kw, &block)
375
615
  end
376
616
 
377
- # Restore the built-in scalars, dropping every custom registration —
378
- # the clean slate to reach for between tests or to undo overrides.
379
- # (Coercible built-ins are auto_coerce's job, not a reset flavor.)
380
- def reset_scalars!
381
- Codegen.reset_scalars!
382
- end
383
-
384
- # Empty the scalar registry entirely, built-ins included (see
385
- # reset_scalars! to restore the defaults).
386
- def clear_scalars!
387
- Codegen.clear_scalars!
617
+ # 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:
620
+ # GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
621
+ # .reset_type_helpers!)
622
+ def reset_registrations!
623
+ Codegen.reset_registrations!
388
624
  end
389
625
 
390
626
  # Parse a query into a typed query module:
391
627
  #
392
628
  # PersonQuery = GraphWeaver.parse(schema:, query: "queries/person.graphql")
393
629
  #
394
- # query is a .graphql/.gql path (module name derived from the file
395
- # name) or a raw query string (name derived from the operation name,
396
- # falling back to "Query" for anonymous operationscollisions are
397
- # impossible since each parse gets its own container). Pass name: to
398
- # override, client: to bake the module's default client/transport.
399
- def parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil,
400
- fragments: fragments_paths)
401
- if query.end_with?(".graphql", ".gql")
402
- name ||= "#{Inflect.camelize(File.basename(query, ".*"))}Query"
403
- query = File.read(query)
630
+ # schema: is a graphql-ruby schema or a Client (its schema, and its
631
+ # transport as the module's default). query is a .graphql/.gql path (module
632
+ # name derived from the file name and the operation see #module_name) or
633
+ # a raw query string (name derived from the operation name, falling back to
634
+ # "Query" for anonymous operations collisions are impossible since each
635
+ # parse gets its own container). Pass name: to override, client: to bake
636
+ # the module's default client/transport.
637
+ def parse(schema:, query:, name: nil, client: nil, fragments: fragments_paths)
638
+ client ||= schema if schema.is_a?(Client)
639
+ schema = schema_for(schema)
640
+ # Rails.root.join(...) hands you a Pathname, and to_path is the
641
+ # ecosystem's "I am a path" — the same conversion schema: gets through
642
+ # SchemaLoader.load. Without it end_with? below is a NoMethodError.
643
+ query = query.to_path if query.respond_to?(:to_path)
644
+ path = query if query.end_with?(".graphql", ".gql")
645
+ if path
646
+ query = File.read(path)
647
+ name ||= module_name(path, query)
404
648
  end
405
- query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments))
649
+ query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
406
650
 
407
- Codegen.parse(schema:, query:, module_name: name, client:, scalars:, enums:, types:)
651
+ Codegen.parse(schema:, query:, module_name: name, client:, path:)
408
652
  end
409
653
 
410
654
  # One-shot dynamic execution — a throwaway client, no build step:
411
655
  #
412
- # GraphWeaver.execute(schema, "query($id: ID!) { ... }", id: "1") # => Response
413
- # GraphWeaver.execute!(url, "query { viewer { login } }") # => Result (or raise)
656
+ # GraphWeaver.run(schema, "query($id: ID!) { ... }", id: "1") # => Response
657
+ # GraphWeaver.run!(url, "query { viewer { login } }") # => Result (or raise)
414
658
  #
415
659
  # The first argument is a url or schema source, exactly as
416
- # GraphWeaver.new; this is Client#execute on a client you don't keep.
660
+ # GraphWeaver.new; this is Client#run on a client you don't keep.
417
661
  # (A url source introspects the schema on every call — keep a client
418
662
  # for more than one query.) Variables are plain kwargs, as on a
419
- # generated module (nothing reserved). execute returns the
420
- # Response envelope, execute! the typed result, raising QueryError on
421
- # top-level errors.
422
- def execute(source, query, **variables)
423
- client = source.is_a?(Client) ? source : Client.new(source)
424
- client.execute(query, **variables)
663
+ # generated module (nothing reserved). run returns the Response
664
+ # envelope, run! the typed result, raising QueryError on top-level
665
+ # errors.
666
+ def run(source, query, **variables)
667
+ client = source.is_a?(Client) ? source : new(source)
668
+ client.run(query, **variables)
425
669
  end
426
670
 
427
- # execute + data! — the typed result, or a raised QueryError. See execute.
428
- def execute!(source, query, **variables)
429
- execute(source, query, **variables).data!
671
+ # run + data! — the typed result, or a raised QueryError. See run.
672
+ def run!(source, query, **variables)
673
+ run(source, query, **variables).data!
430
674
  end
431
675
  end
432
676
  end