graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -1,139 +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
- ```
5
+ file — there is no runtime schema, no lazy wrapper, no reflection.
51
6
 
52
- `generate!` returns every file the plan produces, but rewrites only the ones
53
- whose bytes changed; `GraphWeaver.changed_files` is that subset. So
54
- `rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
55
- date` for the rest, and a watching dev server has one module to reload instead
56
- of all of them.
7
+ Read this when you want to predict the output, or when a generated name isn't
8
+ the one you expected:
57
9
 
58
- The schema dump is step 0 codegen reads it, never a live endpoint.
59
- `cache: true` on a url client writes it on first introspection
60
- (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
61
- generating without one fails pointing at exactly that.
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
62
16
 
63
- **A type shared across query modules lives in `GraphQLTypes` and is aliased
64
- in.** Input types, schema enums, and unions hoisted from shared fragments are
65
- all one kind of thing — a type that would otherwise be copied into every query
66
- that touches it so they live in one module, one file each, and a query module
67
- that uses any of them opens with `require_relative "types"`. Rename the constant
68
- (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
69
- generates against two schemas. One module is one namespace, so a shared fragment
70
- whose name is already a schema type in that module is refused at generation,
71
- naming both.
72
-
73
- **Generation prunes.** Rename or delete a `.graphql` and the module it used
74
- to produce is deleted on the next `generate!` — which says so, since a
75
- deletion you didn't expect is the one worth reading. `verify` flags it as stale
76
- until you regenerate. Only files carrying GraphWeaver's header —
77
- `# Generated by GraphWeaver <version> — do not edit.`, where the version is the
78
- release that wrote the file — are ever deleted, so hand-written files in the
79
- output directory are safe. A run that finds **no** queries says where it looked
80
- rather than exiting 0 in silence, and `verify_generated!` fails outright: a
81
- mistyped `queries_paths` used to leave a CI gate green forever.
82
-
83
- In Rails, loading is automatic — the Railtie requires every generated file at
84
- boot from a `to_prepare` block, after your initializers and after any
85
- registrations of your own in one (so a helper a file names is already there). Elsewhere it's
86
- explicit, factory_bot-style:
87
-
88
- ```ruby
89
- GraphWeaver.load_generated! # require every file under generated_paths
90
- ```
91
-
92
- Every directory setting is a list — `queries_paths`, `generated_paths`,
93
- `fragments_paths` — and every entry is read (entries may be globs; the
94
- generated default includes `app/graphql/*/generated`, so per-schema layouts
95
- load too). Append a test-only schema or an engine's queries and every reader
96
- walks them all:
97
-
98
- ```ruby
99
- # e.g. in spec/support/graph_weaver.rb
100
- GraphWeaver.generated_paths << "spec/graphql/generated"
101
- ```
102
-
103
- Assigning a String wraps it, so pointing at one directory stays a one-liner.
104
- `generate!` writes into the first `generated_paths` entry — one run, one output
105
- directory. `schema_path` is the one singular setting: a run reads one schema,
106
- so a list would name a dump nothing ever opens. A relative path resolves
107
- against `GraphWeaver.root` — `Rails.root` in a Rails app, the working directory
108
- otherwise — so where you started the process doesn't change which files it
109
- reads, and every path it reports back is relative to that same root.
110
-
111
- (Plain requires, not Zeitwerk: Zeitwerk would expect
112
- `Generated::PersonQuery` from `generated/person_query.rb`. In development a
113
- query edit regenerates and reloads before the next request; everywhere else
114
- generated code changes only on regeneration — restart, like a schema
115
- migration. `GraphWeaver.reload_generated!` does the reload by hand, after
116
- regenerating in another terminal.)
117
-
118
- Regenerate when: a query changes, the schema changes, a registration changes,
119
- or GraphWeaver itself upgrades — **any release can change what codegen emits**,
120
- patch releases included, and `verify_generated!` is what catches it. The rake
121
- tasks that spot a *schema* change for you — `schema:diff`, `schema:refresh`,
122
- `queries:check` — are in
123
- [getting started](getting_started.md#5-verify-in-ci); a
124
- [`schema_stale?`](errors.md) error in production is the late signal.
125
-
126
- In development, skip the build entirely — `client.load_queries!` parses
127
- every query file into modules with the same names generation would use
128
- (see [dynamic mode](#dynamic-mode)).
129
-
130
- **Generation is deterministic.** The same schema and queries produce
131
- byte-identical files, on any machine, in any order — everything with a
132
- non-obvious order (schema members, enum values, requires, hoisted names) is
133
- sorted, and a spec asserts it both across calls and against the checked-in
134
- fixtures. So regenerating a file you didn't change produces no diff,
135
- `verify_generated!` never fails spuriously, and a generated file is worth
136
- 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.
137
22
 
138
23
  ## Anatomy
139
24
 
@@ -162,55 +47,104 @@ module PersonQuery
162
47
  end
163
48
  ```
164
49
 
165
- - `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
166
- `#data`, `#data!`, `#errors`, `#extensions` — so partial data and
167
- cost/throttle metadata survive. `execute!` is the shortcut: the typed
168
- **result**, or a raised `GraphWeaver::QueryError`. See [errors](errors.md).
169
- - `from_response` / `from_response!` are the **network-free half** of the
170
- pair same envelope, but from a response hash you already have (below).
171
- - `OPERATION_NAME` rides along on every request as the spec's
172
- `operationName`, so Apollo Studio, Hasura and your APM key traces, rate
173
- limits and slow-query reports on the operation instead of lumping every
174
- request together. **You don't have to name your operations**: an anonymous
175
- document is named after the module in the emitted `QUERY` *and* in
176
- `OPERATION_NAME` — both, since a server rejects an `operationName` its
177
- document doesn't declare. A document that names its own operation is left
178
- exactly as written.
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
+ ```
78
+
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.
179
101
 
180
102
  ## Naming
181
103
 
182
104
  **A module is named after its file**, suffixed with the operation the file
183
105
  defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
184
106
  `save_list_entry.graphql` → `SaveListEntryMutation` in
185
- `save_list_entry_mutation.rb`. The operation name written *inside* the file
186
- never names the module (it goes on the wire as `operationName`); leave it off
187
- and the module's name is written into the document instead. The same rule
188
- runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
189
- `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.
190
121
 
191
122
  Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
192
123
  found, but the module name still comes from the file name alone, so it is
193
- `PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
194
- generation, naming both, rather than one silently overwriting the other; so is a
195
- file holding two operations, since one file can't name two modules. Change a
196
- file's `query` to `mutation` and its constant changes with it; the next
197
- `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.
129
+
130
+ **A graph's `namespace:` nests what it generates**, and is the answer when two
131
+ schemas in one app each have a `person.graphql`: `namespace: "Billing"` makes
132
+ that one `Billing::PersonQuery` in the same `person_query.rb`, and its shared
133
+ types module `Billing::GraphQLTypes`. Nothing else about the rule changes. See
134
+ [getting started](getting_started.md#more-than-one-schema).
198
135
 
199
136
  Parsing a raw query *string* has no file to name it after, so it uses the
200
137
  operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
201
138
  `Query` for an anonymous one (its constants are container-scoped, so collisions
202
139
  are impossible) while `Codegen.generate` insists on a deliberate name. Override
203
- with `name:` on either.
204
-
205
- Whatever it lands on, the module wears it: a cast failure inside a parsed module
206
- reads `GraphWeaver.parse::PersonQuery::Result::Person`, not a hex object
207
- address. Assign the module to a constant and every nested struct upgrades to
208
- 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.
209
143
 
210
144
  **Every nested type is named for the response key that selects it**, camelized
211
145
  (`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
212
- `Entities`). Structs nest the way the selection does, so the constant path
213
- reads like the query:
146
+ `Entities`). Structs nest the way the selection does, so the constant path reads
147
+ like the query:
214
148
 
215
149
  ```graphql
216
150
  query { repository { stargazers { edges { node { login } } } } }
@@ -231,21 +165,19 @@ The key is used verbatim — no pluralization heuristics, so a list field `pets`
231
165
  generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
232
166
  query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
233
167
 
234
- 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.
235
174
 
236
- - **Union and interface members** are named for the type condition that
237
- produces them (`... on Book` `Book`) inside the container named for the
238
- field, plus the catch-all `Other`. A union hoisted out of a shared fragment
239
- is named for the fragment.
240
- - Where several fields share one collapsed union type (identical selections),
241
- it takes the first of their keys alphabetically; and a name that would shadow
242
- the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
243
- (`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
244
-
245
- A generated name that would shadow a constant the file *uses* is refused
246
- instead — a key `date` beside a `Date` scalar prop turns `Date.iso8601` into a
247
- `NoMethodError` in a file that typechecks. The message names both; alias either
248
- 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.
249
181
 
250
182
  ## Variables become typed kwargs
251
183
 
@@ -259,15 +191,19 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
259
191
 
260
192
  - required vs optional falls out of nullability and defaults: nullable or
261
193
  defaulted variables become optional kwargs
262
- - **absent and `null` are different things, and the kwarg says which.**
263
- Leaving a keyword out omits the variable, so the server's default applies;
264
- passing `nil` sends `null`, which is how a mutation clears a field.
265
- `bio: params[:bio]` therefore sends `null` when the param is missing — pass
266
- the keyword only when you mean to. A non-null variable can't carry `null`,
267
- so `nil` there still means omit.
268
- - enum variables accept the enum or its wire value (`species: Species::Dog`
269
- 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"`)
270
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.
271
207
 
272
208
  **The kwarg is typed exactly as the schema types it, and the value is coerced
273
209
  anyway.** Those aren't in tension, because they answer different questions:
@@ -286,26 +222,18 @@ A value that won't convert raises `GraphWeaver::InputError` naming the variable,
286
222
  the operation and the value.
287
223
 
288
224
  That is why the emitted sig carries `.checked(:never)`: sorbet-runtime would
289
- otherwise reject the String before the body could read it. Coercion is what
290
- stands in its place for the arguments — stricter, and with a better message —
291
- and the `Result` it returns is a `T::Struct`, so its props are still checked one
292
- by one.
293
-
294
- One kwarg per declared variable, always — so adding a variable to a query
295
- adds a kwarg and leaves every existing call site alone. Two names are refused at
296
- generation, `$client` and `$variables`: the generated `execute` body already
297
- owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
298
- 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.)
299
231
 
300
232
  **Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
301
233
  normalizes underscored Symbol/String keys, enums accept wire values, nested
302
234
  inputs accept hashes, and an unknown key raises with a spellchecked hint rather
303
235
  than silently dropping:
304
236
 
305
- ```graphql
306
- mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
307
- ```
308
-
309
237
  ```ruby
310
238
  AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
311
239
 
@@ -319,25 +247,48 @@ the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
319
247
  the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
320
248
  struct built with `.new` can't tell the two apart — every unset prop is nil
321
249
  either way — so `nil` there means omit; reach for `coerce` to send an explicit
322
- null.
323
- Nested inputs work, including recursive ones a self-referential filter
324
- generates cleanly, with `_and:`/`_not:` typed as the struct itself:
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.
261
+
262
+ ### An input object generates its whole closure
263
+
264
+ A result type is generated per selection set, because a selection set *is* the
265
+ question. An input object has no selection set, so the only static answer to
266
+ "what can `$where` hold" is every input type it can transitively reach — and
267
+ codegen emits a file for each. On a hand-written schema that closure is usually
268
+ the one type and nothing else. On a generated one (Hasura, Gatsby), where every
269
+ `_bool_exp` references every other, one `$where` reaches a thousand of them.
270
+
271
+ The escape is to stop making the filter a variable. Write it as a literal in the
272
+ query with a variable per leaf, and codegen has ordinary scalars to generate
273
+ instead of the closure — on the query that emitted ~1,200 files, exactly one:
325
274
 
326
- ```ruby
327
- where = mod::PokemonBoolExp.coerce(
328
- _and: [{ name: { _like: "%chu" } }, { _not: { name: { _eq: "raichu" } } }],
329
- )
330
- mod.execute!(where:)
275
+ ```graphql
276
+ query($name: String!, $minHeight: Int!) {
277
+ pokemon(where: { name: { _ilike: $name }, height: { _gte: $minHeight } }) {
278
+ name
279
+ }
280
+ }
331
281
  ```
332
282
 
333
- In the `generate!` workflow input types are emitted **once per schema**, one
334
- file per type under `generated/types/` with `types.rb` as the manifest. Query
335
- modules alias what they touch, so `AdoptMutation::AdoptionInput` still works and
336
- a shared type keeps one identity across modules. A query module aliases only its
337
- *variable root* types, so a deeply nested one is reached as
338
- `GraphQLTypes::<Type>`. Per-type files keep drift reviewable: a schema migration
339
- diffs exactly the types it touched, and types the schema drops are pruned on
340
- regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
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
285
+ `T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped])` and a hash built from
286
+ `params`, which is how a filter is really assembled, takes the untyped branch.
287
+ Refusals land on the leaf too, so `path` is the form field rather than the
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.
341
292
 
342
293
  ## Enums: one GraphQL enum, one Ruby type
343
294
 
@@ -352,33 +303,58 @@ AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
352
303
 
353
304
  So a value read out of one query hands straight back into another's variable,
354
305
  `case`/`T.absurd` is exhaustive across your app, and the class a field gets
355
- doesn't depend on what else the query happened to reference.
356
-
357
- An enum value that camelizes to nothing `_` and `__` are both legal GraphQL —
358
- is refused at generation: there is no constant to name it. Map the enum onto one
359
- 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.
360
310
 
361
311
  `register_enum` replaces the generated `T::Enum` with your own app enum — see
362
- [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
363
- the enums into the query module itself; there's no cross-query set to share
364
- 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.
315
+
316
+ **The one misuse nothing catches** is comparing against the wire spelling:
317
+
318
+ ```ruby
319
+ pet.species == "CAT" # => false, always, and silently
320
+ pet.species == GraphQLTypes::Species::Cat
321
+ ```
322
+
323
+ A generated enum is a plain `T::Enum`, so `==` against a String is `false` —
324
+ `srb tc` allows it (`==` takes `BasicObject`) and nothing raises. sorbet-runtime
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.
365
331
 
366
332
  ## Selections
367
333
 
368
- - **Fragments** — inline fragments and named spreads flatten into the
369
- selection; type conditions match exact names or interfaces/unions the type
370
- belongs to.
371
- - **`@skip` / `@include`** a directive-conditional field may be absent from
372
- the response regardless of schema nullability, so its generated type is
373
- 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.
374
339
  - **Aliases** — result keys follow aliases; props are the underscored alias.
375
340
 
376
- Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
377
- for the wire name is a classic stumble, so it fails helpfully at both
378
- layers: `srb tc` flags it statically, and at runtime (consoles, dynamic
379
- mode) the struct raises a NoMethodError naming the prop that does exist —
380
- `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
381
- 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.
347
+
348
+ A name that would shadow a method every struct answers — `class`, `hash`,
349
+ `display`, `to_json`, and `supplied` on an input — takes a trailing underscore
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.
382
358
 
383
359
  ### Abstract types
384
360
 
@@ -387,34 +363,37 @@ plus a catch-all `Other`, wrapped in a module with
387
363
  `Type = T.type_alias { T.any(...) }` and a `from_h` that dispatches on
388
364
  `__typename`. Generation therefore *requires* `__typename` in such a selection,
389
365
  unaliased and unconditional — the wire response carries no type tag unless you
390
- ask, and `from_h` reads it on every response.
366
+ ask, and `from_h` reads it on every response. One `__typename` inside each
367
+ `... on Type` does **not** substitute, however many of them there are: the
368
+ dispatch runs before any member's selection applies, and a member the query never
369
+ named would carry none at all.
391
370
 
392
371
  Size follows the query, not the schema: two `... on` conditions against GitHub's
393
372
  `Node` — an interface with a few hundred implementations — emit three structs,
394
- not a few hundred. Anything the query didn't name — a member you have no
395
- fragment on, or one the schema grew *after* you generated — deserializes into
396
- `Other`, carrying what the abstract type itself guarantees (an interface's
397
- selected interface-level fields; for a union, `__typename`). Adding a union
398
- 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.
399
378
 
400
379
  Two selections have nothing to dispatch between, so they skip the module and
401
380
  become the struct directly: **no conditions at all** (interface-level fields
402
381
  only) → one shared struct; **exactly one condition and nothing else** → that
403
382
  type's struct, always nilable, since a non-matching runtime type comes back as
404
383
  `nil` — so narrowing doubles as filtering. "Nothing else" is what keeps the miss
405
- legible: a field every member answers — spelled bare, or inside a fragment on
406
- the abstract type itself, which is the same selection — puts the field back on
407
- the dispatch path, so the other members keep what they sent. Narrowing reads the
408
- match off `__typename` when the selection carries one unaliased and unguarded,
409
- and off "the object came back empty" when it doesn't — so an
410
- all-`@skip`/`@include` narrowed fragment, or one whose `__typename` is itself
411
- 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.
412
391
 
413
392
  When a whole union field is selected as one named *shared* fragment
414
393
  (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
415
394
  for the fragment — and each query aliases it, so the same union is one Ruby type
416
- family across queries, not a fresh dispatch module per query. Like shared
417
- 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.
418
397
 
419
398
  ### Consuming a union — dispatch on the class, not `__typename`
420
399
 
@@ -436,28 +415,25 @@ end
436
415
  Two things a `case` on the `__typename` string can't give you. `when Book`
437
416
  *narrows*: inside the branch `item` is statically a `Book`, so its fields
438
417
  typecheck and a `Disc` field is a compile error. And after every branch the
439
- `T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
440
- **write a fragment for another member, regenerate, and the `T.absurd` stops
441
- compiling until you handle it.**
442
-
443
- Exhaustive over the members *this query asked about*, plus `Other` —
444
- deliberately not "every type in the schema", which is what keeps a `case` you
445
- wrote today compiling when upstream adds a member. To make the compiler force
446
- 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.
447
423
 
448
424
  `__typename` is still there as a plain `String`, with one use the class can't
449
- cover: two *differently-selected* occurrences of the same union are distinct
450
- type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
451
- `case` written for one won't span the other. Select the union through a shared
452
- fragment to hold it as one type across queries ([above](#abstract-types)); if
453
- 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.
454
430
 
455
431
  ## Type helpers
456
432
 
457
- Derived values (display names, emoji, predicates) belong next to the data but
458
- not *in* it — rewriting wire values on the way in destroys the raw truth.
459
- Register a plain module and every struct generated from that GraphQL type
460
- 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:
461
437
 
462
438
  ```ruby
463
439
  module PetHelpers
@@ -473,12 +449,15 @@ pet.name # => "Shelby" — the wire value stays honest
473
449
 
474
450
  The methods live on the struct, so they see its wire fields at runtime and
475
451
  fakes/cassettes get the behavior automatically; registrations are additive
476
- (repeated ones stack). Editing the *mixin* in development needs a restart,
477
- unlike a `.graphql` edit: a reload hands the constant a new module object, and
478
- the `include` that took the old one doesn't run again.
479
- For quick decoration, build the mixin inline the block
480
- is `module_eval`'d into a fresh module auto-named under
481
- `GraphWeaver::TypeHelpers`:
452
+ (repeated ones stack). The mixin is one of your own constants, so in Rails the
453
+ registration goes in a `to_prepare` block like `register_enum` does, and for the
454
+ same reason [getting started](getting_started.md#2-run-the-generator) has the
455
+ rule and the boot order behind it. Editing the *mixin* in development needs a
456
+ restart, unlike a `.graphql` edit: a reload hands the constant a new module
457
+ object, and the `include` that took the old one doesn't run again.
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`:
482
461
 
483
462
  ```ruby
484
463
  GraphWeaver.extend_type("Pet") do
@@ -486,12 +465,23 @@ GraphWeaver.extend_type("Pet") do
486
465
  end
487
466
  ```
488
467
 
489
- **Neither form is statically checked**, for the same reason: `srb tc` checks a
490
- mixin's method bodies in the module's own scope, not the including struct's, so
491
- a helper reading a wire field (`name`, `birthday`) fails with "method does not
492
- exist on the module" and the block form has no source on disk for `srb tc` to
493
- read at all. Write such a helper at `# typed: false`, or reach the field through
494
- `T.unsafe(self)`.
468
+ The name is where the block is written and what it extends:
469
+ `GraphWeaver::TypeHelpers::Pet` at the top level,
470
+ `GraphWeaver::TypeHelpers::Billing::Pet` inside `GraphWeaver.graph :billing`.
471
+ Generated code spells it, so it depends on your source and nothing else two
472
+ graphs can extend the same type name, and the name a `generate` bakes in is the
473
+ one a boot creates.
474
+
475
+ **Neither form is statically checked as written**, for the same reason: `srb tc`
476
+ checks a mixin's method bodies in the module's own scope, not the including
477
+ struct's, so a helper reading a wire field (`name`, `birthday`) fails with
478
+ "method does not exist on the module" — and the block form has no source on disk
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.
483
+ `T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
484
+ beats `# typed: false` for a helper you want checked.
495
485
 
496
486
  ### Flat accessors with `alias:`
497
487
 
@@ -539,16 +529,14 @@ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional:
539
529
  `optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
540
530
  the path just omits the accessor instead of failing generation. Reach for it when
541
531
  the alias lives on a universal type like `Query` — where a strict alias would
542
- force *every* query to select the path — or when it only fits some selections.
543
- It excuses a field the query didn't select, not a segment the schema doesn't
544
- have: a typo or a wire-cased name (`findPets` for `find_pets`) still raises,
545
- 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.
546
536
 
547
537
  For anything beyond a passthrough projection — real logic, still typed — reopen
548
538
  the generated struct in your own file and add sig'd methods; Sorbet merges the
549
- bodies.
550
-
551
- 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
552
540
  [`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
553
541
 
554
542
  ## Clients
@@ -557,23 +545,28 @@ A client is anything satisfying the [execute contract](transports.md) — a
557
545
  `GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
558
546
  Resolution is per call (`client:`) → per module → baked constant →
559
547
  `GraphWeaver.client`; the canonical list is in
560
- [transports](transports.md#client-resolution).
561
-
562
- Generate *without* a baked constant when you want modules to follow the
563
- app default (`GraphWeaver.client =` in an initializer) that's also what
564
- lets [testing's `graphql:` tag](testing.md) swap in a client per example.
565
-
566
- `client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
567
- every generated module). A baked constant is emitted as a private
568
- `DEFAULT_CLIENT`, resolved on first use so a module can load before the
569
- initializer that builds its client.
548
+ [transports](transports.md#client-resolution). Generate *without* a baked
549
+ constant when you want modules to follow the app default (`GraphWeaver.client =`
550
+ in an initializer). A baked one is no reason a module escapes
551
+ [testing's `graphql:` tag](testing.md), which is exactly the instruction to
552
+ replace the client generation chose; what the *example* says still wins.
553
+
554
+ `client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by every
555
+ generated module). A baked constant is emitted as a private `DEFAULT_CLIENT`,
556
+ resolved on first use so a module can load before the initializer that builds its
557
+ client. A module generated from a
558
+ [declared graph](getting_started.md#more-than-one-schema) also carries a private
559
+ `GRAPH` naming it — so with two graphs, `graphql: :fake` fabricates each module's
560
+ own schema instead of having to be told which one you meant, and it is the
561
+ `:graph` on every [instrumentation event](logging.md#the-payload) the module's
562
+ `execute` produces.
570
563
 
571
564
  ## Deserializing a response from another client
572
565
 
573
566
  `execute` is two steps: make the request, then cast the JSON into the typed
574
567
  structs. Only the second step is GraphWeaver-specific, and it's exposed on its
575
- own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
576
- a batching layer, a recorded fixture) and hand the result over:
568
+ own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post, a
569
+ batching layer, a recorded fixture) and hand the result over:
577
570
 
578
571
  ```ruby
579
572
  raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
@@ -585,27 +578,161 @@ person = response.data!.person # typed, no network
585
578
  person = PersonQuery.from_response!(raw).person # or skip the envelope
586
579
  ```
587
580
 
588
- `execute` *is* `from_response(client.execute(...))`, so the envelope is
589
- identical. The one requirement: pass the response **verbatim** — a hash (or
590
- anything with `#to_h`) with the standard GraphQL shape and **wire-cased string
591
- keys** (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
592
- `"extensions"` keys included. Don't symbolize or snake_case it first.
581
+ `execute` *is* `from_response(client.execute(...))`, so the envelope is identical.
582
+ The one requirement: pass the response **verbatim** — a hash (or anything with
583
+ `#to_h`) with the standard GraphQL shape and **wire-cased string keys**
584
+ (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
585
+ `"extensions"` keys included. Don't symbolize or snake_case it first. Which is
586
+ checked, since symbolizing is the likeliest thing to go wrong at this seam: a
587
+ hash carrying neither `"data"` nor `"errors"` raises a `GraphWeaver::CastError`
588
+ naming the keys it *did* find, rather than handing back an envelope that reports
589
+ success with no data. `nil` and a bare String are refused the same way.
590
+
591
+ ## Generating
592
+
593
+ Queries live as `.graphql` files (the source of truth), generation writes the
594
+ Ruby, and verification fails when the two drift. The conventional layout
595
+ (configurable via `GraphWeaver.queries_paths` / `generated_paths` /
596
+ `schema_path`):
597
+
598
+ ```text
599
+ app/graphql/
600
+ schema.json # introspection dump (or schema.graphql SDL)
601
+ queries/ # *.graphql / *.gql, nested — hand-written, reviewed
602
+ fragments/ # shared fragments, spread by name from any query
603
+ generated/
604
+ types.rb # manifest: requires + forward declarations, in load order
605
+ types/ # one file per shared type
606
+ *_query.rb # one module per query — generated, checked in, never edited
607
+ *_mutation.rb # ...and per mutation
608
+ ```
609
+
610
+ ```sh
611
+ rake graph_weaver:generate # queries_paths -> generated_paths.first
612
+ rake graph_weaver:verify # fail if anything is stale — run in CI
613
+ ```
593
614
 
594
- Which is checked, since symbolizing is the likeliest thing to go wrong at this
595
- seam: a hash carrying neither `"data"` nor `"errors"` raises a
596
- `GraphWeaver::TypeError` naming the keys it *did* find, rather than handing back
597
- an envelope that reports success with no data. `nil` and a bare String are
598
- refused the same way.
615
+ The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"` to
616
+ your Rakefile. Scalar/enum/type registrations are baked into generated source, so
617
+ they must run first in Rails they do, since the tasks depend on `:environment`.
618
+ Or call the same APIs directly:
619
+
620
+ ```ruby
621
+ schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
622
+ GraphWeaver.generate!(schema:) # write the modules
623
+ GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
624
+ ```
625
+
626
+ **`verify_generated!` costs what `generate!` costs**, minus the writes — it
627
+ recomputes the whole plan whether nothing is stale or everything is. So it
628
+ belongs in *one* example per suite run, not in a `before` or an assertion per
629
+ example, where it reads like a cheap check and isn't.
630
+
631
+ `generate!` returns every file the plan produces, but rewrites only the ones whose
632
+ bytes changed; `GraphWeaver.changed_files` is that subset. So
633
+ `rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
634
+ date` for the rest, and a watching dev server has one module to reload instead of
635
+ all of them. The unregistered-scalar report is the rake task's `puts`, so off rake
636
+ read `GraphWeaver.untyped_scalars` for the unioned list — or set
637
+ [`GraphWeaver.logger`](logging.md), which `generate!` names them on at `info` as
638
+ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
639
+ generating without one fails pointing at exactly that.
640
+
641
+ **A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
642
+ Input types, schema enums, and unions hoisted from shared fragments are all one
643
+ kind of thing — a type that would otherwise be copied into every query that
644
+ touches it — so they live in one module, one file each, and a query module that
645
+ uses any of them opens with `require_relative "types"`. Rename the constant
646
+ (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
647
+ generates against two schemas. One module is one namespace, so a shared fragment
648
+ whose name is already a schema type in that module is refused at generation,
649
+ naming both.
650
+
651
+ **Generation prunes.** Rename or delete a `.graphql` and the module it used to
652
+ produce is deleted on the next `generate!` — which says so, since a deletion you
653
+ didn't expect is the one worth reading; `verify` flags it as stale until you
654
+ regenerate. Only files carrying GraphWeaver's header
655
+ (`# Generated by GraphWeaver <version> — do not edit.`) are ever deleted, so
656
+ hand-written files in the output directory are safe. A run that finds **no**
657
+ queries says where it looked rather than exiting 0 in silence, and
658
+ `verify_generated!` fails outright.
659
+
660
+ **A refusal writes nothing at all** — not even the files that planned cleanly —
661
+ so a failed run leaves the tree exactly as it was, and it reports *every* query it
662
+ refused rather than the first.
663
+
664
+ **Generation is deterministic.** The same schema and queries produce
665
+ byte-identical files, on any machine, in any order — everything with a
666
+ non-obvious order (schema members, enum values, requires, hoisted names) is
667
+ sorted, and a spec asserts it both across calls and against the checked-in
668
+ fixtures. So regenerating a file you didn't change produces no diff,
669
+ `verify_generated!` never fails spuriously, and a generated file is worth
670
+ reviewing line by line.
671
+
672
+ Regenerate when: a query changes, the schema changes, a registration changes, or
673
+ GraphWeaver itself upgrades — **any release can change what codegen emits**, patch
674
+ releases included, and `verify_generated!` is what catches it. The rake tasks that
675
+ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:check`
676
+ — are in [getting started](getting_started.md#5-verify-in-ci); a
677
+ [`schema_stale?`](errors.md) error in production is the late signal.
678
+
679
+ ### Loading what it wrote
680
+
681
+ In Rails, loading is automatic — the Railtie requires every generated file at
682
+ boot from a `to_prepare` block, after your initializers and after any
683
+ registrations of your own in one. Elsewhere it's explicit, factory_bot-style:
684
+ `GraphWeaver.load_generated!` requires every file under `generated_paths`.
685
+
686
+ **Outside Rails, four things have to agree**, and nothing wires them together for
687
+ you — a script that generates its own modules sets all four:
688
+
689
+ 1. `queries_paths` — where `generate!` reads `.graphql` files.
690
+ 2. `generated_paths` — where it writes, and where `load_generated!` reads. Point
691
+ them at the same directory or generation is invisible.
692
+ 3. the call above, before the first `execute` — nothing else requires the files.
693
+ 4. `GraphWeaver.client =` — a module generated without a baked
694
+ [`client:`](#clients) has none of its own.
695
+
696
+ Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
697
+ gets `PersonQuery: client must respond to #execute(query, variables:), got
698
+ NilClass` from a module that otherwise looks fine.
699
+
700
+ Every directory setting is a list — `queries_paths`, `generated_paths`,
701
+ `fragments_paths` — and every entry is read (entries may be globs; the generated
702
+ default includes `app/graphql/*/generated`, so per-schema layouts load too).
703
+ Assigning a String wraps it, so pointing at one directory stays a one-liner.
704
+ `generate!` writes into the first `generated_paths` entry — one run, one output
705
+ directory. `schema_path` is the one singular setting: a run reads one schema, so a
706
+ list would name a dump nothing ever opens. A relative path resolves against
707
+ `GraphWeaver.root` — `Rails.root` in a Rails app, the working directory otherwise
708
+ — so where you started the process doesn't change which files it reads.
709
+
710
+ Plain requires, not Zeitwerk: Zeitwerk would expect `Generated::PersonQuery` from
711
+ `generated/person_query.rb`. In development a query edit regenerates and reloads
712
+ before the next request; everywhere else generated code changes only on
713
+ regeneration — restart, like a schema migration, or call
714
+ `GraphWeaver.reload_generated!` after regenerating in another terminal.
599
715
 
600
716
  ## Dynamic mode
601
717
 
602
- `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
603
- into an anonymous container — no global constants leak). Same runtime
604
- semantics; invisible to `srb tc`, so prefer the build step where static
605
- checking matters. `GraphWeaver.run(source, query, **variables)` — or
606
- `client.run` — is the one-shot form: parse and execute in one call, no module
607
- kept.
718
+ `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled into
719
+ an anonymous container — no global constants leak). Same runtime semantics;
720
+ invisible to `srb tc`, so prefer the build step where static checking matters.
721
+ `GraphWeaver.run(source, query, **variables)` — or `client.run` — is the one-shot
722
+ form: parse and execute in one call, no module kept. In development
723
+ `client.load_queries!` parses every query file into modules with the same names
724
+ generation would use.
725
+
726
+ In an app with [more than one graph](getting_started.md#more-than-one-schema), a
727
+ parsed module belongs to one of them — that is what a `graphql:` tag runs it
728
+ against, the same thing generation bakes into a file. It is read off the schema
729
+ you parsed against when a graph runs that class in-process; say it outright
730
+ otherwise:
731
+
732
+ ```ruby
733
+ PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)
734
+ ```
608
735
 
609
736
  Generated source is eval'd, so inputs are validated: module names must be
610
- constant names, and query heredocs can't be terminated early. Still: queries
611
- are code — don't feed untrusted strings to parse.
737
+ constant names, and query heredocs can't be terminated early. Still: queries are
738
+ code — don't feed untrusted strings to parse.