graph_weaver 0.7.0 → 0.7.2

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/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
@@ -1,164 +1,24 @@
1
1
  # Generated modules
2
2
 
3
- What `rake graph_weaver:generate` writes, and the rules it follows: how modules
4
- and nested types get their names, how variables become kwargs, and how unions and
5
- interfaces come out. Read it when you want to predict the output — or when a
6
- generated name isn't the one you expected.
7
-
8
3
  `GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
9
4
  Ruby module. Everything `srb tc` knows about your query results comes from that
10
- file — there is no runtime schema, no lazy wrapper, no reflection. The setup
11
- around it is assembled step by step in
12
- [getting started](getting_started.md), including
13
- [what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without).
14
- For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
15
- scripts, `client.run!` skips modules entirely.
16
-
17
- ## Generating
18
-
19
- Queries live as `.graphql` files (the source of truth), generation writes the
20
- Ruby, and verification fails when the two drift. The conventional layout
21
- (configurable via `GraphWeaver.queries_paths` / `generated_paths` /
22
- `schema_path`):
23
-
24
- ```text
25
- app/graphql/
26
- schema.json # introspection dump (or schema.graphql SDL)
27
- queries/ # *.graphql / *.gql, nested — hand-written, reviewed
28
- fragments/ # shared fragments, spread by name from any query
29
- generated/
30
- types.rb # manifest: requires + forward declarations, in load order
31
- types/ # one file per shared type
32
- *_query.rb # one module per query — generated, checked in, never edited
33
- *_mutation.rb # ...and per mutation
34
- ```
35
-
36
- ```sh
37
- rake graph_weaver:generate # queries_paths -> generated_paths.first
38
- rake graph_weaver:verify # fail if anything is stale — run in CI
39
- ```
40
-
41
- The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"`
42
- to your Rakefile. Scalar/enum/type registrations are baked into generated
43
- source, so they must run first — in Rails they do, since the tasks depend on
44
- `:environment`. Or call the same APIs directly:
45
-
46
- ```ruby
47
- schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
48
- GraphWeaver.generate!(schema:) # write the modules
49
- GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
50
- ```
51
-
52
- **`verify_generated!` costs what `generate!` costs**, minus the writes — it
53
- recomputes the whole plan, every file's content, and diffs each against disk,
54
- whether nothing is stale or everything is. It doesn't get cheaper because only
55
- one query changed, so it belongs in *one* example per suite run, not in a
56
- `before` or an assertion per example, where it reads like a cheap check and
57
- isn't.
58
-
59
- `generate!` returns every file the plan produces, but rewrites only the ones
60
- whose bytes changed; `GraphWeaver.changed_files` is that subset. So
61
- `rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
62
- date` for the rest, and a watching dev server has one module to reload instead
63
- of all of them. The unregistered-scalar report is the rake task's `puts`, not
64
- `generate!`'s, so off rake read `GraphWeaver.untyped_scalars` for the unioned
65
- list — or set [`GraphWeaver.logger`](logging.md), which `generate!` names them
66
- on at `info` as it goes.
67
-
68
- The schema dump is step 0 — codegen reads it, never a live endpoint.
69
- `cache: true` on a url client writes it on first introspection
70
- (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
71
- generating without one fails pointing at exactly that.
72
-
73
- **A type shared across query modules lives in `GraphQLTypes` and is aliased
74
- in.** Input types, schema enums, and unions hoisted from shared fragments are
75
- all one kind of thing — a type that would otherwise be copied into every query
76
- that touches it — so they live in one module, one file each, and a query module
77
- that uses any of them opens with `require_relative "types"`. Rename the constant
78
- (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
79
- generates against two schemas. One module is one namespace, so a shared fragment
80
- whose name is already a schema type in that module is refused at generation,
81
- naming both.
82
-
83
- **Generation prunes.** Rename or delete a `.graphql` and the module it used
84
- to produce is deleted on the next `generate!` — which says so, since a
85
- deletion you didn't expect is the one worth reading. `verify` flags it as stale
86
- until you regenerate. Only files carrying GraphWeaver's header —
87
- `# Generated by GraphWeaver <version> — do not edit.`, where the version is the
88
- release that wrote the file — are ever deleted, so hand-written files in the
89
- output directory are safe. A run that finds **no** queries says where it looked
90
- rather than exiting 0 in silence, and `verify_generated!` fails outright: a
91
- mistyped `queries_paths` used to leave a CI gate green forever.
5
+ file — there is no runtime schema, no lazy wrapper, no reflection.
92
6
 
93
- In Rails, loading is automatic the Railtie requires every generated file at
94
- boot from a `to_prepare` block, after your initializers and after any
95
- registrations of your own in one (so a helper a file names is already there). Elsewhere it's
96
- explicit, factory_bot-style:
97
-
98
- ```ruby
99
- GraphWeaver.load_generated! # require every file under generated_paths
100
- ```
101
-
102
- **Outside Rails, four things have to agree**, and nothing wires them together
103
- for you — a script that generates its own modules sets all four:
104
-
105
- 1. `queries_paths` — where `generate!` reads `.graphql` files.
106
- 2. `generated_paths` — where it writes, and where `load_generated!` reads.
107
- Point them at the same directory or generation is invisible.
108
- 3. the call above, before the first `execute` — nothing else requires the
109
- files.
110
- 4. `GraphWeaver.client =` — a module generated without a baked
111
- [`client:`](#clients) has none of its own.
112
-
113
- Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
114
- gets `PersonQuery: client must respond to #execute(query, variables:), got
115
- NilClass` from a module that otherwise looks fine.
116
-
117
- Every directory setting is a list — `queries_paths`, `generated_paths`,
118
- `fragments_paths` — and every entry is read (entries may be globs; the
119
- generated default includes `app/graphql/*/generated`, so per-schema layouts
120
- load too). Append a test-only schema or an engine's queries and every reader
121
- walks them all:
7
+ Read this when you want to predict the output, or when a generated name isn't
8
+ the one you expected:
122
9
 
123
- ```ruby
124
- # e.g. in spec/support/graph_weaver.rb
125
- GraphWeaver.generated_paths << "spec/graphql/generated"
126
- ```
10
+ - **[Anatomy](#anatomy)** — what a module holds, and what a `Result` can do
11
+ - **[Naming](#naming)** how the module and every nested struct get their names
12
+ - **[Variables](#variables-become-typed-kwargs)** — kwargs, input objects, coercion
13
+ - **[Enums](#enums-one-graphql-enum-one-ruby-type)** and **[selections](#selections)** — fragments, aliases, unions and interfaces
14
+ - **[Type helpers](#type-helpers)** — your own methods on a generated struct
15
+ - **[Clients](#clients)**, **[`from_response`](#deserializing-a-response-from-another-client)**, and the **[build](#generating)** itself
127
16
 
128
- Assigning a String wraps it, so pointing at one directory stays a one-liner.
129
- `generate!` writes into the first `generated_paths` entry — one run, one output
130
- directory. `schema_path` is the one singular setting: a run reads one schema,
131
- so a list would name a dump nothing ever opens. A relative path resolves
132
- against `GraphWeaver.root` `Rails.root` in a Rails app, the working directory
133
- otherwise — so where you started the process doesn't change which files it
134
- reads, and every path it reports back is relative to that same root.
135
-
136
- (Plain requires, not Zeitwerk: Zeitwerk would expect
137
- `Generated::PersonQuery` from `generated/person_query.rb`. In development a
138
- query edit regenerates and reloads before the next request; everywhere else
139
- generated code changes only on regeneration — restart, like a schema
140
- migration. `GraphWeaver.reload_generated!` does the reload by hand, after
141
- regenerating in another terminal.)
142
-
143
- Regenerate when: a query changes, the schema changes, a registration changes,
144
- or GraphWeaver itself upgrades — **any release can change what codegen emits**,
145
- patch releases included, and `verify_generated!` is what catches it. The rake
146
- tasks that spot a *schema* change for you — `schema:diff`, `schema:refresh`,
147
- `queries:check` — are in
148
- [getting started](getting_started.md#5-verify-in-ci); a
149
- [`schema_stale?`](errors.md) error in production is the late signal.
150
-
151
- In development, skip the build entirely — `client.load_queries!` parses
152
- every query file into modules with the same names generation would use
153
- (see [dynamic mode](#dynamic-mode)).
154
-
155
- **Generation is deterministic.** The same schema and queries produce
156
- byte-identical files, on any machine, in any order — everything with a
157
- non-obvious order (schema members, enum values, requires, hoisted names) is
158
- sorted, and a spec asserts it both across calls and against the checked-in
159
- fixtures. So regenerating a file you didn't change produces no diff,
160
- `verify_generated!` never fails spuriously, and a generated file is worth
161
- reviewing line by line.
17
+ The setup around it is assembled step by step in
18
+ [getting started](getting_started.md), including
19
+ [what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without).
20
+ For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off scripts,
21
+ `client.run!` skips modules entirely.
162
22
 
163
23
  ## Anatomy
164
24
 
@@ -178,7 +38,7 @@ module PersonQuery
178
38
  const :person, T.nilable(Person)
179
39
  end
180
40
 
181
- extend GraphWeaver::QueryModule # client / client= (see below)
41
+ extend GraphWeaver::QueryModule # client (see below)
182
42
  def self.execute(id:, client: nil) # -> GraphWeaver::Response[Result]
183
43
  def self.execute!(id:, client: nil) # -> Result, or raises QueryError
184
44
 
@@ -187,113 +47,104 @@ module PersonQuery
187
47
  end
188
48
  ```
189
49
 
190
- - `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
191
- `#data`, `#data!`, `#errors`, `#extensions` — so partial data and
192
- cost/throttle metadata survive. `execute!` is the shortcut: the typed
193
- **result**, or a raised `GraphWeaver::QueryError`. See [errors](errors.md).
194
- - `from_response` / `from_response!` are the **network-free half** of the
195
- pair same envelope, but from a response hash you already have (below).
196
- - A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and
197
- `hash`, so a result works as a hash key), `deconstruct_keys` for pattern
198
- matching, `#to_h`, and `#to_json`/`#as_json`. All of them go the whole way
199
- down a nested result.
200
- Immutable as far as its props go, like `Struct` or `Data` and no further:
201
- the `String` or `Hash` a leaf holds is the one the response carried, so
202
- `result.name << "!"` changes the result, and its `hash` with it.
203
-
204
- **Cache a result with `Marshal`, not YAML.** A `T::Enum` member is a
205
- singleton that sorbet compares by identity, and Psych allocates an object
206
- before filling it in, so YAML has no way to hand back the canonical one:
207
- after a round trip `pet.species == Species::Dog` is false and the result no
208
- longer equals itself. `Marshal` restores it intact as does JSON, since
209
- `#to_json` writes the wire shape and `from_h` reads it back (below).
210
-
211
- ```ruby
212
- PersonQuery.from_response!(raw) == PersonQuery.from_response!(raw) # true — value, not identity
213
-
214
- case PersonQuery.execute!(id: "1")
215
- in { person: { name:, pets: [{ name: first_pet }, *] } } then "#{name} and #{first_pet}"
216
- in { person: { name: } } then "#{name}, petless"
217
- in { person: nil } then "nobody"
218
- end
50
+ `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
51
+ `#data`, `#data!`, `#errors`, `#extensions` — so partial data and cost/throttle
52
+ metadata survive. `execute!` is the shortcut: the typed **result**, or a raised
53
+ `GraphWeaver::QueryError`. See [errors](errors.md).
54
+ [`from_response`](#deserializing-a-response-from-another-client) is the
55
+ network-free half of the pair.
56
+
57
+ `OPERATION_NAME` rides along on every request as the spec's `operationName`, so
58
+ Apollo Studio, Hasura and your APM key traces, rate limits and slow-query reports
59
+ on the operation instead of lumping every request together. **You don't have to
60
+ name your operations**: an anonymous document is named after the module in the
61
+ emitted `QUERY` *and* in `OPERATION_NAME` both, since a server rejects an
62
+ `operationName` its document doesn't declare.
63
+
64
+ A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and `hash`,
65
+ so a result works as a hash key), `deconstruct_keys` for pattern matching,
66
+ `#to_h`, and `#to_json`/`#as_json`. All of them go the whole way down a nested
67
+ result. It is immutable as far as its props go, like `Struct` or `Data` and no
68
+ further: the `String` or `Hash` a leaf holds is the one the response carried, so
69
+ `result.name << "!"` changes the result, and its `hash` with it.
70
+
71
+ ```ruby
72
+ case PersonQuery.execute!(id: "1")
73
+ in { person: { name:, pets: [{ name: first_pet }, *] } } then "#{name} and #{first_pet}"
74
+ in { person: { name: } } then "#{name}, petless"
75
+ in { person: nil } then "nobody"
76
+ end
77
+ ```
219
78
 
220
- PersonQuery.execute!(id: "1").to_h
221
- # => { person: { id: "1", name: "Daniel", birthday: #<Date 1984-05-06>,
222
- # pets: [{ name: "Nibbler" }] } }
223
- ```
224
-
225
- `#to_h` is the **Ruby** shape, not the wire's: snake_case prop names as
226
- Symbols, nils kept, enums as their `T::Enum` members, and a registered
227
- scalar as whatever object its codec built. It is a view, for Ruby to read.
228
-
229
- `#to_json` and `#as_json`, which `render json:` goes through is the
230
- **wire** shape instead: the response keys, and every leaf back through its
231
- scalar registration's `serialize:`. So a result's JSON is the inverse of
232
- `from_h`:
233
-
234
- ```ruby
235
- PersonQuery::Result.from_h(JSON.parse(result.to_json)) == result # true
236
- ```
237
-
238
- which is what a cache entry, a log line or a JSON API response wants. That
239
- split is deliberate: a Symbol-keyed Ruby hash can't be mistaken for a
240
- server's response, and a JSON string can — so the JSON is the one that has
241
- to be true. (An **input** struct's `to_h` is already the wire hash it
242
- sends, so there its JSON and its `to_h` agree.) The trip is exactly as
243
- faithful as each scalar's own `cast:`/`serialize:` pair: a `Time` goes back
244
- out with [the microseconds its registration writes](scalars.md#going-out--what-a-variable-kwarg-accepts),
245
- and a `register_scalar` with a `cast:` and no `serialize:` has no wire
246
- spelling at all, so its value reaches the encoder as it is — the same
247
- reason an input can't send one.
248
- - `OPERATION_NAME` rides along on every request as the spec's
249
- `operationName`, so Apollo Studio, Hasura and your APM key traces, rate
250
- limits and slow-query reports on the operation instead of lumping every
251
- request together. **You don't have to name your operations**: an anonymous
252
- document is named after the module in the emitted `QUERY` *and* in
253
- `OPERATION_NAME` — both, since a server rejects an `operationName` its
254
- document doesn't declare. A document that names its own operation is left
255
- exactly as written.
79
+ **`#to_h` is the Ruby shape; `#to_json` is the wire shape.** `to_h` gives
80
+ snake_case prop names as Symbols, nils kept, enums as their `T::Enum` members,
81
+ and a registered scalar as whatever object its codec built — a view, for Ruby to
82
+ read. `#to_json` — and `#as_json`, which `render json:` goes through — writes
83
+ the response keys instead, every leaf back through its scalar registration's
84
+ `serialize:`, so a result's JSON is the inverse of `from_h`
85
+ (`Result.from_h(JSON.parse(result.to_json)) == result`), which is what a cache
86
+ entry, a log line or a JSON API response wants. The split is deliberate: a
87
+ Symbol-keyed Ruby hash can't be mistaken for a server's response, and a JSON
88
+ string can, so the JSON is the one that has to be true. (An **input** struct's
89
+ `to_h` is already the wire hash it sends, so there its JSON and its `to_h`
90
+ agree.) The trip is exactly as faithful as each scalar's own `cast:`/`serialize:`
91
+ pair: a `Time` goes back out with
92
+ [the microseconds its registration writes](scalars.md#going-out--what-a-variable-kwarg-accepts),
93
+ and a `register_scalar` with a `cast:` and no `serialize:` has no wire spelling
94
+ at all, so its value reaches the encoder as it is.
95
+
96
+ **Cache a result with `Marshal` or JSON, not YAML.** A `T::Enum` member is a
97
+ singleton that sorbet compares by identity, and Psych allocates an object before
98
+ filling it in, so YAML has no way to hand back the canonical one: after a round
99
+ trip `pet.species == Species::Dog` is false and the result no longer equals
100
+ itself.
256
101
 
257
102
  ## Naming
258
103
 
259
104
  **A module is named after its file**, suffixed with the operation the file
260
105
  defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
261
106
  `save_list_entry.graphql` → `SaveListEntryMutation` in
262
- `save_list_entry_mutation.rb`. The operation name written *inside* the file
263
- never names the module (it goes on the wire as `operationName`); leave it off
264
- and the module's name is written into the document instead. The same rule
265
- runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
266
- `client.load_queries!`.
107
+ `save_list_entry_mutation.rb`. The operation name written *inside* the file never
108
+ names the module (it goes on the wire as `operationName`); leave it off and the
109
+ module's name is written into the document instead. The same rule runs at all
110
+ three doors: `generate!`, `GraphWeaver.parse(path)`, and `client.load_queries!`.
111
+
112
+ **Every run of non-alphanumerics in the file name is a word boundary**, after a
113
+ trailing `.query`/`.mutation`/`.subscription` extension naming the document's own
114
+ operation is dropped — so `get-hello.graphql` is `GetHelloQuery` in
115
+ `get_hello_query.rb`, and `hello.query.graphql` is `HelloQuery`, not
116
+ `HelloQueryQuery`. Only that extension is dropped: `user.profile.graphql` is
117
+ `UserProfileQuery`, keeping the `profile`. A file whose extension names a kind it
118
+ doesn't hold (`hello.query.graphql` defining a mutation) is refused, naming both
119
+ halves. What is left still has to spell a constant — `01_home.graphql` is
120
+ refused, since `01HomeQuery` isn't one.
267
121
 
268
122
  Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
269
123
  found, but the module name still comes from the file name alone, so it is
270
- `PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
271
- generation, naming both, rather than one silently overwriting the other; so is a
272
- file holding two operations, since one file can't name two modules. Change a
273
- file's `query` to `mutation` and its constant changes with it; the next
274
- `generate!` prunes the old file, and `verify` fails until you regenerate.
124
+ `PetsQuery`. Two files that name the same module are refused at generation,
125
+ naming both, rather than one silently overwriting the other; so is a file holding
126
+ two operations, since one file can't name two modules. Change a file's `query` to
127
+ `mutation` and its constant changes with it; the next `generate!` prunes the old
128
+ file, and `verify` fails until you regenerate.
275
129
 
276
130
  **A graph's `namespace:` nests what it generates**, and is the answer when two
277
131
  schemas in one app each have a `person.graphql`: `namespace: "Billing"` makes
278
132
  that one `Billing::PersonQuery` in the same `person_query.rb`, and its shared
279
- types module `Billing::GraphQLTypes`. Nothing else about the rule changes — the
280
- file still names the module. See [getting started](getting_started.md#more-than-one-schema).
133
+ types module `Billing::GraphQLTypes`. Nothing else about the rule changes. See
134
+ [getting started](getting_started.md#more-than-one-schema).
281
135
 
282
136
  Parsing a raw query *string* has no file to name it after, so it uses the
283
137
  operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
284
138
  `Query` for an anonymous one (its constants are container-scoped, so collisions
285
139
  are impossible) while `Codegen.generate` insists on a deliberate name. Override
286
- with `name:` on either.
287
-
288
- Whatever it lands on, the module wears it: a cast failure inside a parsed module
289
- reads `GraphWeaver.parse::PersonQuery::Result::Person`, not a hex object
290
- address. Assign the module to a constant and every nested struct upgrades to
291
- that real path.
140
+ with `name:` on either. Assign a parsed module to a constant and every nested
141
+ struct upgrades to that real path, so a cast failure names it rather than a hex
142
+ object address.
292
143
 
293
144
  **Every nested type is named for the response key that selects it**, camelized
294
145
  (`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
295
- `Entities`). Structs nest the way the selection does, so the constant path
296
- reads like the query:
146
+ `Entities`). Structs nest the way the selection does, so the constant path reads
147
+ like the query:
297
148
 
298
149
  ```graphql
299
150
  query { repository { stargazers { edges { node { login } } } } }
@@ -314,21 +165,19 @@ The key is used verbatim — no pluralization heuristics, so a list field `pets`
314
165
  generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
315
166
  query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
316
167
 
317
- Two kinds of name don't come from a key, both equally position-determined:
168
+ **Union and interface members** are the one name that doesn't come from a key:
169
+ they take the type condition that produces them (`... on Book` → `Book`) inside
170
+ the container named for the field, plus the catch-all `Other`; a union hoisted
171
+ out of a shared fragment is named for the fragment; and several fields sharing
172
+ one collapsed union type take the first of their keys alphabetically. Still
173
+ position-determined, all of it.
318
174
 
319
- - **Union and interface members** are named for the type condition that
320
- produces them (`... on Book` `Book`) inside the container named for the
321
- field, plus the catch-all `Other`. A union hoisted out of a shared fragment
322
- is named for the fragment.
323
- - Where several fields share one collapsed union type (identical selections),
324
- it takes the first of their keys alphabetically; and a name that would shadow
325
- the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
326
- (`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
327
-
328
- A generated name that would shadow a constant the file *uses* is refused
329
- instead — a key `date` beside a `Date` scalar prop turns `Date.iso8601` into a
330
- `NoMethodError` in a file that typechecks. The message names both; alias either
331
- one in the query.
175
+ Two collisions are handled rather than left to surprise you. A name that would
176
+ shadow the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
177
+ (`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child. And a
178
+ name that would shadow a constant the file *uses* is refused — a key `date`
179
+ beside a `Date` scalar prop turns `Date.iso8601` into a `NoMethodError` in a file
180
+ that typechecks. The message names both; alias either one in the query.
332
181
 
333
182
  ## Variables become typed kwargs
334
183
 
@@ -342,15 +191,19 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
342
191
 
343
192
  - required vs optional falls out of nullability and defaults: nullable or
344
193
  defaulted variables become optional kwargs
345
- - **absent and `null` are different things, and the kwarg says which.**
346
- Leaving a keyword out omits the variable, so the server's default applies;
347
- passing `nil` sends `null`, which is how a mutation clears a field.
348
- `bio: params[:bio]` therefore sends `null` when the param is missing — pass
349
- the keyword only when you mean to. A non-null variable can't carry `null`,
350
- so `nil` there still means omit.
351
- - enum variables accept the enum or its wire value (`species: Species::Dog`
352
- or `species: "DOG"`)
194
+ - **absent and `null` are different things, and the kwarg says which.** Leaving a
195
+ keyword out omits the variable, so the server's default applies; passing `nil`
196
+ sends `null`, which is how a mutation clears a field. `bio: params[:bio]`
197
+ therefore sends `null` when the param is missing — pass the keyword only when
198
+ you mean to. A non-null variable can't carry `null`, so `nil` there still means
199
+ omit.
200
+ - enum variables accept the enum or its wire value (`species: Species::Dog` or
201
+ `species: "DOG"`)
353
202
  - custom scalars serialize through the [scalar registry](scalars.md)
203
+ - one kwarg per declared variable, always — so adding a variable to a query adds
204
+ a kwarg and leaves every existing call site alone. Two names are refused at
205
+ generation, `$client` and `$variables`: the generated `execute` body already
206
+ owns them. Rename the variable in the query.
354
207
 
355
208
  **The kwarg is typed exactly as the schema types it, and the value is coerced
356
209
  anyway.** Those aren't in tension, because they answer different questions:
@@ -369,34 +222,18 @@ A value that won't convert raises `GraphWeaver::InputError` naming the variable,
369
222
  the operation and the value.
370
223
 
371
224
  That is why the emitted sig carries `.checked(:never)`: sorbet-runtime would
372
- otherwise reject the String before the body could read it. Coercion is what
373
- stands in its place for the arguments — stricter, and with a better message —
374
- and the `Result` it returns is a `T::Struct`, so its props are still checked one
375
- by one.
376
-
377
- `T::Configuration.default_checked_level = :never` buys nothing back here. That
378
- knob governs `sig` dispatch, and the emitted sigs already opt out; the cost that
379
- remains is `T::Struct`'s own prop validation, which sorbet-runtime declares
380
- `.checked(:never)` in its own source and runs through a setter built at class
381
- definition. `from_h` allocates and costs the same either way — measured
382
- object-for-object identical — so reach for the scalar's cast, not this, when a
383
- deserialization path is hot.
384
-
385
- One kwarg per declared variable, always — so adding a variable to a query
386
- adds a kwarg and leaves every existing call site alone. Two names are refused at
387
- generation, `$client` and `$variables`: the generated `execute` body already
388
- owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
389
- Rename the variable in the query.
225
+ otherwise reject the String before the body could read it. Coercion stands in its
226
+ place for the arguments — stricter, and with a better message — and the `Result`
227
+ it returns is a `T::Struct`, so its props are still checked one by one.
228
+ (`T::Configuration.default_checked_level = :never` buys nothing back: that knob
229
+ governs `sig` dispatch, which the emitted sigs already opt out of, and `from_h`
230
+ allocates and costs the same either way.)
390
231
 
391
232
  **Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
392
233
  normalizes underscored Symbol/String keys, enums accept wire values, nested
393
234
  inputs accept hashes, and an unknown key raises with a spellchecked hint rather
394
235
  than silently dropping:
395
236
 
396
- ```graphql
397
- mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
398
- ```
399
-
400
237
  ```ruby
401
238
  AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
402
239
 
@@ -410,25 +247,17 @@ the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
410
247
  the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
411
248
  struct built with `.new` can't tell the two apart — every unset prop is nil
412
249
  either way — so `nil` there means omit; reach for `coerce` to send an explicit
413
- null.
414
- Nested inputs work, including recursive ones a self-referential filter
415
- generates cleanly, with `_and:`/`_not:` typed as the struct itself:
416
-
417
- ```ruby
418
- where = mod::PokemonBoolExp.coerce(
419
- _and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
420
- )
421
- mod.execute!(where:)
422
- ```
423
-
424
- In the `generate!` workflow input types are emitted **once per schema**, one
425
- file per type under `generated/types/` with `types.rb` as the manifest. Query
426
- modules alias what they touch, so `AdoptMutation::AdoptionInput` still works and
427
- a shared type keeps one identity across modules. A query module aliases only its
428
- *variable root* types, so a deeply nested one is reached as
429
- `GraphQLTypes::<Type>`. Per-type files keep drift reviewable: a schema migration
430
- diffs exactly the types it touched, and types the schema drops are pruned on
431
- regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
250
+ null. Nested inputs work, including recursive ones: a self-referential filter
251
+ generates cleanly, with `_and:`/`_not:` typed as the struct itself.
252
+
253
+ In the `generate!` workflow input types are emitted **once per schema**, one file
254
+ per type under `generated/types/` with `types.rb` as the manifest. Query modules
255
+ alias what they touch, so `AdoptMutation::AdoptionInput` still works and a shared
256
+ type keeps one identity across modules; a query module aliases only its *variable
257
+ root* types, so a deeply nested one is reached as `GraphQLTypes::<Type>`. Per-type
258
+ files keep drift reviewable: a schema migration diffs exactly the types it
259
+ touched, and types the schema drops are pruned on regeneration (`verify` flags
260
+ strays). Dynamic `parse` stays self-contained.
432
261
 
433
262
  ### An input object generates its whole closure
434
263
 
@@ -451,18 +280,15 @@ query($name: String!, $minHeight: Int!) {
451
280
  }
452
281
  ```
453
282
 
454
- `srb tc` gets *more* out of that, not less. `name: String`, `min_height:
455
- Integer` are types it checks at every call site, where the variable form is
283
+ `srb tc` gets *more* out of that, not less. `name: String`, `min_height: Integer`
284
+ are types it checks at every call site, where the variable form is
456
285
  `T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped])` — and a hash built from
457
286
  `params`, which is how a filter is really assembled, takes the untyped branch.
458
287
  Refusals land on the leaf too, so `path` is the form field rather than the
459
- comparison operator under it.
460
-
461
- Two shapes can't be inlined, and codegen says which when a prop collision in an
462
- input type forces the question. A key chosen at runtime — the sort column in
463
- `order_by: { <column>: asc }` — has no spelling, because GraphQL has no dynamic
464
- object keys. And a literal list can't stand in for a length only the runtime
465
- knows.
288
+ comparison operator under it. Two shapes can't be inlined, and codegen says which
289
+ when a prop collision forces the question: a key chosen at runtime (the sort
290
+ column in `order_by: { <column>: asc }`), since GraphQL has no dynamic object
291
+ keys, and a list whose length only the runtime knows.
466
292
 
467
293
  ## Enums: one GraphQL enum, one Ruby type
468
294
 
@@ -477,16 +303,15 @@ AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
477
303
 
478
304
  So a value read out of one query hands straight back into another's variable,
479
305
  `case`/`T.absurd` is exhaustive across your app, and the class a field gets
480
- doesn't depend on what else the query happened to reference.
481
-
482
- An enum value that camelizes to nothing `_` and `__` are both legal GraphQL —
483
- is refused at generation: there is no constant to name it. Map the enum onto one
484
- of yours instead.
306
+ doesn't depend on what else the query happened to reference. An enum value that
307
+ camelizes to nothing — `_` and `__` are both legal GraphQL — is refused at
308
+ generation: there is no constant to name it. Map the enum onto one of yours
309
+ instead.
485
310
 
486
311
  `register_enum` replaces the generated `T::Enum` with your own app enum — see
487
- [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
488
- the enums into the query module itself; there's no cross-query set to share
489
- against, but one enum is still one class within that module.
312
+ [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
313
+ enums into the query module itself; there's no cross-query set to share against,
314
+ but one enum is still one class within that module.
490
315
 
491
316
  **The one misuse nothing catches** is comparing against the wire spelling:
492
317
 
@@ -497,49 +322,39 @@ pet.species == GraphQLTypes::Species::Cat
497
322
 
498
323
  A generated enum is a plain `T::Enum`, so `==` against a String is `false` —
499
324
  `srb tc` allows it (`==` takes `BasicObject`) and nothing raises. sorbet-runtime
500
- owns this question and ships the switch; turn it on in dev and test and route
501
- the report wherever your other soft assertions go:
502
-
503
- ```ruby
504
- T::Configuration.enable_legacy_t_enum_migration_mode
505
- T::Configuration.soft_assert_handler = ->(message, extra) { raise "#{message} #{extra}" }
506
- ```
507
-
508
- It covers your own `T::Enum`s too, which is why it belongs there rather than in
509
- the generated classes. Careful reading it: in that mode the comparison answers
510
- **true** (it serializes first), so the handler, not the return value, is the
511
- signal.
325
+ owns this question and ships the switch: turn on
326
+ `T::Configuration.enable_legacy_t_enum_migration_mode` in dev and test, and route
327
+ `soft_assert_handler` wherever your other soft assertions go. It covers your own
328
+ `T::Enum`s too, which is why it belongs there rather than in the generated
329
+ classes. Careful reading it: in that mode the comparison answers **true** (it
330
+ serializes first), so the handler, not the return value, is the signal.
512
331
 
513
332
  ## Selections
514
333
 
515
- - **Fragments** — inline fragments and named spreads flatten into the
516
- selection; type conditions match exact names or interfaces/unions the type
517
- belongs to.
518
- - **`@skip` / `@include`** a directive-conditional field may be absent from
519
- the response regardless of schema nullability, so its generated type is
520
- always nilable.
334
+ - **Fragments** — inline fragments and named spreads flatten into the selection;
335
+ type conditions match exact names or interfaces/unions the type belongs to.
336
+ - **`@skip` / `@include`** — a directive-conditional field may be absent from the
337
+ response regardless of schema nullability, so its generated type is always
338
+ nilable.
521
339
  - **Aliases** — result keys follow aliases; props are the underscored alias.
522
340
 
523
- Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
524
- for the wire name is a classic stumble, so it fails helpfully at both
525
- layers: `srb tc` flags it statically, and at runtime (consoles, dynamic
526
- mode) the struct raises a NoMethodError naming the prop that does exist —
527
- `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
528
- a near-miss typo in either casing.
341
+ Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching for
342
+ the wire name is a classic stumble, so it fails helpfully at both layers: `srb
343
+ tc` flags it statically, and at runtime (consoles, dynamic mode) the struct
344
+ raises a NoMethodError naming the prop that does exist — `use 'name_with_owner'`
345
+ for the exact wire name, `did you mean ...?` for a near-miss typo in either
346
+ casing.
529
347
 
530
348
  A name that would shadow a method every struct answers — `class`, `hash`,
531
349
  `display`, `to_json`, and `supplied` on an input — takes a trailing underscore
532
- instead: `class` → `class_`, in results and input types alike. The generated
533
- source says so on the line above the prop. Only the Ruby name moves: the wire
534
- keeps the schema's spelling in both directions, so the query, the request and
535
- the response are untouched, and `result.class` is still Ruby's `class`. The
536
- prop is the one Ruby name for the field, so `.new`, `.coerce`, a result's
537
- `#to_h` and pattern matching, and an `InputError`'s `#path` all use `class_`
538
- (an input error's `#coordinate` still names the schema's `Tricky.class`). The
539
- wire views are where the schema's spelling comes back: an **input** struct's
540
- `#to_h` is the hash you would send — `{"class" => …}` — and a result's
541
- `#as_json`/`#to_json` write `"class"` too, so `render json: result` never
542
- leaks a trailing underscore. Input structs don't pattern-match at all.
350
+ instead: `class` → `class_`, in results and input types alike, and the generated
351
+ source says so on the line above the prop. Only the Ruby name moves. It is the
352
+ one Ruby name for the field, so `.new`, `.coerce`, a result's `#to_h` and pattern
353
+ matching, and an `InputError`'s `#path` all use `class_` (an input error's
354
+ `#coordinate` still names the schema's `Tricky.class`) while the wire keeps the
355
+ schema's spelling in both directions, so the query, the request, the response,
356
+ and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
357
+ trailing underscore.
543
358
 
544
359
  ### Abstract types
545
360
 
@@ -550,35 +365,35 @@ plus a catch-all `Other`, wrapped in a module with
550
365
  unaliased and unconditional — the wire response carries no type tag unless you
551
366
  ask, and `from_h` reads it on every response. One `__typename` inside each
552
367
  `... on Type` does **not** substitute, however many of them there are: the
553
- dispatch runs before any member's selection applies, and a member the query
554
- never named would carry none at all.
368
+ dispatch runs before any member's selection applies, and a member the query never
369
+ named would carry none at all.
555
370
 
556
371
  Size follows the query, not the schema: two `... on` conditions against GitHub's
557
372
  `Node` — an interface with a few hundred implementations — emit three structs,
558
- not a few hundred. Anything the query didn't name — a member you have no
559
- fragment on, or one the schema grew *after* you generated — deserializes into
560
- `Other`, carrying what the abstract type itself guarantees (an interface's
561
- selected interface-level fields; for a union, `__typename`). Adding a union
562
- member upstream is a non-breaking change, and it stays one here.
373
+ not a few hundred. Anything the query didn't name — a member you have no fragment
374
+ on, or one the schema grew *after* you generated — deserializes into `Other`,
375
+ carrying what the abstract type itself guarantees (an interface's selected
376
+ interface-level fields; for a union, `__typename`). Adding a union member
377
+ upstream is a non-breaking change, and it stays one here.
563
378
 
564
379
  Two selections have nothing to dispatch between, so they skip the module and
565
380
  become the struct directly: **no conditions at all** (interface-level fields
566
381
  only) → one shared struct; **exactly one condition and nothing else** → that
567
382
  type's struct, always nilable, since a non-matching runtime type comes back as
568
383
  `nil` — so narrowing doubles as filtering. "Nothing else" is what keeps the miss
569
- legible: a field every member answers — spelled bare, or inside a fragment on
570
- the abstract type itself, which is the same selection — puts the field back on
571
- the dispatch path, so the other members keep what they sent. Narrowing reads the
572
- match off `__typename` when the selection carries one unaliased and unguarded,
573
- and off "the object came back empty" when it doesn't — so an
574
- all-`@skip`/`@include` narrowed fragment, or one whose `__typename` is itself
575
- guarded, is refused: a match would be indistinguishable from a miss.
384
+ legible: a field every member answers — spelled bare, or inside a fragment on the
385
+ abstract type itself, which is the same selection — puts the field back on the
386
+ dispatch path, so the other members keep what they sent. Narrowing reads the match
387
+ off `__typename` when the selection carries one unaliased and unguarded, and off
388
+ "the object came back empty" when it doesn't — so an all-`@skip`/`@include`
389
+ narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
390
+ match would be indistinguishable from a miss.
576
391
 
577
392
  When a whole union field is selected as one named *shared* fragment
578
393
  (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
579
394
  for the fragment — and each query aliases it, so the same union is one Ruby type
580
- family across queries, not a fresh dispatch module per query. Like shared
581
- inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
395
+ family across queries, not a fresh dispatch module per query. Like shared inputs,
396
+ it's a `generate!`-directory concern; dynamic `parse` inlines.
582
397
 
583
398
  ### Consuming a union — dispatch on the class, not `__typename`
584
399
 
@@ -600,28 +415,25 @@ end
600
415
  Two things a `case` on the `__typename` string can't give you. `when Book`
601
416
  *narrows*: inside the branch `item` is statically a `Book`, so its fields
602
417
  typecheck and a `Disc` field is a compile error. And after every branch the
603
- `T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
604
- **write a fragment for another member, regenerate, and the `T.absurd` stops
605
- compiling until you handle it.**
606
-
607
- Exhaustive over the members *this query asked about*, plus `Other` —
608
- deliberately not "every type in the schema", which is what keeps a `case` you
609
- wrote today compiling when upstream adds a member. To make the compiler force
610
- your hand on a new one, name it in the query.
418
+ `T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable — **write a
419
+ fragment for another member, regenerate, and the `T.absurd` stops compiling until
420
+ you handle it.** It is exhaustive over the members *this query asked about*, plus
421
+ `Other` — deliberately not "every type in the schema", which is what keeps a
422
+ `case` you wrote today compiling when upstream adds a member.
611
423
 
612
424
  `__typename` is still there as a plain `String`, with one use the class can't
613
- cover: two *differently-selected* occurrences of the same union are distinct
614
- type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
615
- `case` written for one won't span the other. Select the union through a shared
616
- fragment to hold it as one type across queries ([above](#abstract-types)); if
617
- all you have is the bare tag, `__typename` is the common denominator, unchecked.
425
+ cover: two *differently-selected* occurrences of the same union are distinct type
426
+ families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
427
+ written for one won't span the other. Select the union through a shared fragment
428
+ to hold it as one type across queries ([above](#abstract-types)); if all you have
429
+ is the bare tag, `__typename` is the common denominator, unchecked.
618
430
 
619
431
  ## Type helpers
620
432
 
621
- Derived values (display names, emoji, predicates) belong next to the data but
622
- not *in* it — rewriting wire values on the way in destroys the raw truth.
623
- Register a plain module and every struct generated from that GraphQL type
624
- includes it, whatever query it appears in:
433
+ Derived values (display names, emoji, predicates) belong next to the data but not
434
+ *in* it — rewriting wire values on the way in destroys the raw truth. Register a
435
+ plain module and every struct generated from that GraphQL type includes it,
436
+ whatever query it appears in:
625
437
 
626
438
  ```ruby
627
439
  module PetHelpers
@@ -643,9 +455,9 @@ same reason — [getting started](getting_started.md#2-run-the-generator) has th
643
455
  rule and the boot order behind it. Editing the *mixin* in development needs a
644
456
  restart, unlike a `.graphql` edit: a reload hands the constant a new module
645
457
  object, and the `include` that took the old one doesn't run again.
646
- For quick decoration, build the mixin inline — the block
647
- is `module_eval`'d into a fresh module auto-named under
648
- `GraphWeaver::TypeHelpers`:
458
+
459
+ For quick decoration, build the mixin inline — the block is `module_eval`'d into
460
+ a fresh module auto-named under `GraphWeaver::TypeHelpers`:
649
461
 
650
462
  ```ruby
651
463
  GraphWeaver.extend_type("Pet") do
@@ -664,28 +476,10 @@ one a boot creates.
664
476
  checks a mixin's method bodies in the module's own scope, not the including
665
477
  struct's, so a helper reading a wire field (`name`, `birthday`) fails with
666
478
  "method does not exist on the module" — and the block form has no source on disk
667
- for `srb tc` to read at all (it shows up as `Unable to resolve constant` on the
668
- generated `include`, which is the cost of the convenience).
669
-
670
- A *named* module can carry real sigs, though, by declaring the fields it leans
671
- on — abstract sigs are how a mixin says "whatever includes me has these", and the
672
- struct's `const`s satisfy them:
673
-
674
- ```ruby
675
- # typed: strict
676
- module PetHelpers
677
- extend T::Sig
678
- extend T::Helpers
679
- abstract!
680
-
681
- sig { abstract.returns(String) }
682
- def name; end
683
-
684
- sig { returns(String) }
685
- def display_name = "#{name} 🐶"
686
- end
687
- ```
688
-
479
+ for `srb tc` to read at all. A *named* module can carry real sigs, though, by
480
+ declaring the fields it leans on: `abstract!` plus a
481
+ `sig { abstract.returns(String) }; def name; end` is how a mixin says "whatever
482
+ includes me has these", and the struct's `const`s satisfy them.
689
483
  `T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
690
484
  beats `# typed: false` for a helper you want checked.
691
485
 
@@ -735,51 +529,43 @@ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional:
735
529
  `optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
736
530
  the path just omits the accessor instead of failing generation. Reach for it when
737
531
  the alias lives on a universal type like `Query` — where a strict alias would
738
- force *every* query to select the path — or when it only fits some selections.
739
- It excuses a field the query didn't select, not a segment the schema doesn't
740
- have: a typo or a wire-cased name (`findPets` for `find_pets`) still raises,
741
- since no selection could ever satisfy it.
532
+ force *every* query to select the path — or when it only fits some selections. It
533
+ excuses a field the query didn't select, not a segment the schema doesn't have: a
534
+ typo or a wire-cased name (`findPets` for `find_pets`) still raises, since no
535
+ selection could ever satisfy it.
742
536
 
743
537
  For anything beyond a passthrough projection — real logic, still typed — reopen
744
538
  the generated struct in your own file and add sig'd methods; Sorbet merges the
745
- bodies.
746
-
747
- Every form above, and every error it raises, is a named example in
539
+ bodies. Every form above, and every error it raises, is a named example in
748
540
  [`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
749
541
 
750
542
  ## Clients
751
543
 
752
544
  A client is anything satisfying the [execute contract](transports.md) — a
753
545
  `GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
754
- Resolution is per call (`client:`) per module baked constant
755
- `GraphWeaver.client`; the canonical list is in
756
- [transports](transports.md#client-resolution).
757
-
758
- Generate *without* a baked constant when you want modules to follow the
759
- app default (`GraphWeaver.client =` in an initializer).
760
-
761
- A baked constant is no longer a reason a module escapes
762
- [testing's `graphql:` tag](testing.md): the tag is exactly the instruction
763
- to replace the client generation chose, so it stands in for the baked one
764
- too. What the *example* says still wins — a per-call `client:`, or
765
- `MyQuery.client =` in a `before` block.
766
-
767
- `client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
768
- every generated module). A baked constant is emitted as a private
769
- `DEFAULT_CLIENT`, resolved on first use so a module can load before the
770
- initializer that builds its client. A module generated from a
771
- [declared graph](getting_started.md#more-than-one-schema) also carries a private `GRAPH` naming
772
- it — so with two graphs, `graphql: :fake` fabricates each module's own
773
- schema instead of having to be told which one you meant, and it is the
774
- `:graph` on every [instrumentation event](logging.md#the-payload) the
775
- module's `execute` produces.
546
+ A module knows which graph it belongs to, and the graph knows how to reach it:
547
+ resolution is per call (`client:`) a test mode's stand-in → the client its
548
+ [graph](getting_started.md#more-than-one-schema) names → `GraphWeaver.client`;
549
+ the canonical list is in [transports](transports.md#client-resolution). There is
550
+ no setter `MyQuery.client` reads back what the module would execute through,
551
+ and a [parsed](#dynamic-mode) module, which has no graph, runs
552
+ against whatever parsed it. A graph naming its own client is no reason a module
553
+ escapes [testing's `graphql:` tag](testing.md), which is exactly the instruction
554
+ to replace it; what the *example* says still wins.
555
+
556
+ `client` lives in the gem (`GraphWeaver::QueryModule`, extended by every
557
+ generated module). A generated file says nothing about transport — only a private
558
+ `GRAPH` naming its graph, which is also how `graphql: :fake` fabricates each
559
+ module's own schema with two graphs in play, and the
560
+ `:graph` on every [instrumentation event](logging.md#the-payload) the module's
561
+ `execute` produces.
776
562
 
777
563
  ## Deserializing a response from another client
778
564
 
779
565
  `execute` is two steps: make the request, then cast the JSON into the typed
780
566
  structs. Only the second step is GraphWeaver-specific, and it's exposed on its
781
- own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
782
- a batching layer, a recorded fixture) and hand the result over:
567
+ own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post, a
568
+ batching layer, a recorded fixture) and hand the result over:
783
569
 
784
570
  ```ruby
785
571
  raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
@@ -791,30 +577,161 @@ person = response.data!.person # typed, no network
791
577
  person = PersonQuery.from_response!(raw).person # or skip the envelope
792
578
  ```
793
579
 
794
- `execute` *is* `from_response(client.execute(...))`, so the envelope is
795
- identical. The one requirement: pass the response **verbatim** — a hash (or
796
- anything with `#to_h`) with the standard GraphQL shape and **wire-cased string
797
- keys** (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
798
- `"extensions"` keys included. Don't symbolize or snake_case it first.
580
+ `execute` *is* `from_response(client.execute(...))`, so the envelope is identical.
581
+ The one requirement: pass the response **verbatim** — a hash (or anything with
582
+ `#to_h`) with the standard GraphQL shape and **wire-cased string keys**
583
+ (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
584
+ `"extensions"` keys included. Don't symbolize or snake_case it first. Which is
585
+ checked, since symbolizing is the likeliest thing to go wrong at this seam: a
586
+ hash carrying neither `"data"` nor `"errors"` raises a `GraphWeaver::CastError`
587
+ naming the keys it *did* find, rather than handing back an envelope that reports
588
+ success with no data. `nil` and a bare String are refused the same way.
589
+
590
+ ## Generating
591
+
592
+ Queries live as `.graphql` files (the source of truth), generation writes the
593
+ Ruby, and verification fails when the two drift. The conventional layout
594
+ (configurable via `GraphWeaver.queries_paths` / `generated_paths` /
595
+ `schema_path`):
596
+
597
+ ```text
598
+ app/graphql/
599
+ schema.json # introspection dump (or schema.graphql SDL)
600
+ queries/ # *.graphql / *.gql, nested — hand-written, reviewed
601
+ fragments/ # shared fragments, spread by name from any query
602
+ generated/
603
+ types.rb # manifest: requires + forward declarations, in load order
604
+ types/ # one file per shared type
605
+ *_query.rb # one module per query — generated, checked in, never edited
606
+ *_mutation.rb # ...and per mutation
607
+ ```
608
+
609
+ ```sh
610
+ rake graph_weaver:generate # queries_paths -> generated_paths.first
611
+ rake graph_weaver:verify # fail if anything is stale — run in CI
612
+ ```
613
+
614
+ The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"` to
615
+ your Rakefile. Scalar/enum/type registrations are baked into generated source, so
616
+ they must run first — in Rails they do, since the tasks depend on `:environment`.
617
+ Or call the same APIs directly:
799
618
 
800
- Which is checked, since symbolizing is the likeliest thing to go wrong at this
801
- seam: a hash carrying neither `"data"` nor `"errors"` raises a
802
- `GraphWeaver::CastError` naming the keys it *did* find, rather than handing back
803
- an envelope that reports success with no data. `nil` and a bare String are
804
- refused the same way.
619
+ ```ruby
620
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
621
+ GraphWeaver.generate!(schema:) # write the modules
622
+ GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
623
+ ```
624
+
625
+ **`verify_generated!` costs what `generate!` costs**, minus the writes — it
626
+ recomputes the whole plan whether nothing is stale or everything is. So it
627
+ belongs in *one* example per suite run, not in a `before` or an assertion per
628
+ example, where it reads like a cheap check and isn't.
629
+
630
+ `generate!` returns every file the plan produces, but rewrites only the ones whose
631
+ bytes changed; `GraphWeaver.changed_files` is that subset. So
632
+ `rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
633
+ date` for the rest, and a watching dev server has one module to reload instead of
634
+ all of them. The unregistered-scalar report is the rake task's `puts`, so off rake
635
+ read `GraphWeaver.untyped_scalars` for the unioned list — or set
636
+ [`GraphWeaver.logger`](logging.md), which `generate!` names them on at `info` as
637
+ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
638
+ generating without one fails pointing at exactly that.
639
+
640
+ **A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
641
+ Input types, schema enums, and unions hoisted from shared fragments are all one
642
+ kind of thing — a type that would otherwise be copied into every query that
643
+ touches it — so they live in one module, one file each, and a query module that
644
+ uses any of them opens with `require_relative "types"`. Rename the constant
645
+ (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
646
+ generates against two schemas. One module is one namespace, so a shared fragment
647
+ whose name is already a schema type in that module is refused at generation,
648
+ naming both.
649
+
650
+ **Generation prunes.** Rename or delete a `.graphql` and the module it used to
651
+ produce is deleted on the next `generate!` — which says so, since a deletion you
652
+ didn't expect is the one worth reading; `verify` flags it as stale until you
653
+ regenerate. Only files carrying GraphWeaver's header
654
+ (`# Generated by GraphWeaver <version> — do not edit.`) are ever deleted, so
655
+ hand-written files in the output directory are safe. A run that finds **no**
656
+ queries says where it looked rather than exiting 0 in silence, and
657
+ `verify_generated!` fails outright.
658
+
659
+ **A refusal writes nothing at all** — not even the files that planned cleanly —
660
+ so a failed run leaves the tree exactly as it was, and it reports *every* query it
661
+ refused rather than the first.
662
+
663
+ **Generation is deterministic.** The same schema and queries produce
664
+ byte-identical files, on any machine, in any order — everything with a
665
+ non-obvious order (schema members, enum values, requires, hoisted names) is
666
+ sorted, and a spec asserts it both across calls and against the checked-in
667
+ fixtures. So regenerating a file you didn't change produces no diff,
668
+ `verify_generated!` never fails spuriously, and a generated file is worth
669
+ reviewing line by line.
670
+
671
+ Regenerate when: a query changes, the schema changes, a registration changes, or
672
+ GraphWeaver itself upgrades — **any release can change what codegen emits**, patch
673
+ releases included, and `verify_generated!` is what catches it. The rake tasks that
674
+ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:check`
675
+ — are in [getting started](getting_started.md#5-verify-in-ci); a
676
+ [`schema_stale?`](errors.md) error in production is the late signal.
677
+
678
+ ### Loading what it wrote
679
+
680
+ In Rails, loading is automatic — the Railtie requires every generated file at
681
+ boot from a `to_prepare` block, after your initializers and after any
682
+ registrations of your own in one. Elsewhere it's explicit, factory_bot-style:
683
+ `GraphWeaver.load_generated!` requires every file under `generated_paths`.
684
+
685
+ **Outside Rails, four things have to agree**, and nothing wires them together for
686
+ you — a script that generates its own modules sets all four:
687
+
688
+ 1. `queries_paths` — where `generate!` reads `.graphql` files.
689
+ 2. `generated_paths` — where it writes, and where `load_generated!` reads. Point
690
+ them at the same directory or generation is invisible.
691
+ 3. the call above, before the first `execute` — nothing else requires the files.
692
+ 4. `GraphWeaver.client =` — a module belonging to no declared graph has no
693
+ other [client](#clients) to reach for.
694
+
695
+ Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
696
+ gets `PersonQuery: client must respond to #execute(query, variables:), got
697
+ NilClass` from a module that otherwise looks fine.
698
+
699
+ Every directory setting is a list — `queries_paths`, `generated_paths`,
700
+ `fragments_paths` — and every entry is read (entries may be globs; the generated
701
+ default includes `app/graphql/*/generated`, so per-schema layouts load too).
702
+ Assigning a String wraps it, so pointing at one directory stays a one-liner.
703
+ `generate!` writes into the first `generated_paths` entry — one run, one output
704
+ directory. `schema_path` is the one singular setting: a run reads one schema, so a
705
+ list would name a dump nothing ever opens. A relative path resolves against
706
+ `GraphWeaver.root` — `Rails.root` in a Rails app, the working directory otherwise
707
+ — so where you started the process doesn't change which files it reads.
708
+
709
+ Plain requires, not Zeitwerk: Zeitwerk would expect `Generated::PersonQuery` from
710
+ `generated/person_query.rb`. In development a query edit regenerates and reloads
711
+ before the next request; everywhere else generated code changes only on
712
+ regeneration — restart, like a schema migration, or call
713
+ `GraphWeaver.reload_generated!` after regenerating in another terminal.
805
714
 
806
715
  ## Dynamic mode
807
716
 
808
- `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
809
- into an anonymous container — no global constants leak). Same runtime
810
- semantics; invisible to `srb tc`, so prefer the build step where static
811
- checking matters. `GraphWeaver.run(source, query, **variables)` — or
812
- `client.run` — is the one-shot form: parse and execute in one call, no module
813
- kept.
717
+ `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled into
718
+ an anonymous container — no global constants leak). Same runtime semantics;
719
+ invisible to `srb tc`, so prefer the build step where static checking matters.
720
+ `GraphWeaver.run(source, query, **variables)` — or `client.run` — is the one-shot
721
+ form: parse and execute in one call, no module kept. In development
722
+ `client.load_queries!` parses every query file into modules with the same names
723
+ generation would use.
724
+
725
+ A parsed module **runs against whatever parsed it** — `client.parse(query)` and
726
+ `load_queries!` bind the client they came from, and `GraphWeaver.parse(client:)`
727
+ says it outright. That is a property of parsing, not a slot you can set later:
728
+ it generates no file, so it has no graph to read a client off, and a per-call
729
+ `client:` still wins over it.
814
730
 
815
731
  In an app with [more than one graph](getting_started.md#more-than-one-schema), a
816
732
  parsed module belongs to one of them — that is what a `graphql:` tag runs it
817
- against, the same thing generation bakes into a file. It is read off the schema
733
+ against and whose `client` it reaches for, the same thing generation writes into
734
+ a file. It is read off the schema
818
735
  you parsed against when a graph runs that class in-process; say it outright
819
736
  otherwise:
820
737
 
@@ -823,5 +740,5 @@ PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)
823
740
  ```
824
741
 
825
742
  Generated source is eval'd, so inputs are validated: module names must be
826
- constant names, and query heredocs can't be terminated early. Still: queries
827
- are code — don't feed untrusted strings to parse.
743
+ constant names, and query heredocs can't be terminated early. Still: queries are
744
+ code — don't feed untrusted strings to parse.