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