graph_weaver 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
@@ -8,25 +8,63 @@ This is the production path — checked in, reviewed, statically checked
8
8
  (assembled step by step in the [getting started](getting_started.md), including
9
9
  [what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without)).
10
10
  For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
11
- scripts, `client.execute!` skips modules entirely.
11
+ scripts, `client.run!` skips modules entirely.
12
12
 
13
13
  ## Generating
14
14
 
15
15
  The workflow that keeps generated code honest: queries live as `.graphql`
16
16
  files (the source of truth), generation writes the Ruby, and verification
17
17
  fails when the two drift. The conventional layout (configurable via
18
- `GraphWeaver.queries_path` / `generated_path` / `schema_path`):
18
+ `GraphWeaver.queries_paths` / `generated_paths` / `schema_path`):
19
19
 
20
20
  ```text
21
21
  app/graphql/
22
22
  schema.json # introspection dump (or schema.graphql SDL)
23
- queries/ # *.graphql — hand-written, reviewed
23
+ queries/ # *.graphql / *.gql, nested — hand-written, reviewed
24
+ fragments/ # shared fragments, spread by name from any query
24
25
  generated/
25
- inputs.rb # manifest: requires + forward declarations
26
- inputs/ # one file per shared type (input structs, enums)
26
+ types.rb # manifest: requires + forward declarations, in load order
27
+ types/ # one file per shared type
27
28
  *_query.rb # one module per query — generated, checked in, never edited
29
+ *_mutation.rb # ...and per mutation
28
30
  ```
29
31
 
32
+ **A type shared across query modules lives in `GraphQLTypes` and is aliased
33
+ in.** Input types, schema enums, and unions hoisted from shared fragments are
34
+ all one kind of thing — a type that would otherwise be copied into every query
35
+ that touches it — so they live in one module, one file each, and a query module
36
+ that uses any of them opens with `require_relative "types"`. Rename the constant
37
+ (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
38
+ generates against two schemas, in the same initializer that already gives each
39
+ its own paths.
40
+
41
+ One module is one namespace, so a shared fragment whose name is already a schema
42
+ type in that module is refused at generation, naming both.
43
+
44
+ **Naming.** A module is named after its **file**, suffixed with the operation
45
+ the file defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
46
+ `save_list_entry.graphql` → `SaveListEntryMutation` in
47
+ `save_list_entry_mutation.rb`. The operation name written *inside* the file
48
+ never names the module (it goes on the wire as `operationName`); leave it off
49
+ and the module's name is written into the document instead. The same rule
50
+ runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
51
+ `client.load_queries!`.
52
+
53
+ Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
54
+ found, but the module name still comes from the file name alone, so it is
55
+ `PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
56
+ generation, naming both, rather than one silently overwriting the other; so is a
57
+ file holding two operations, since one file can't name two modules.
58
+
59
+ Change a file's `query` to `mutation` and its constant changes with it; the
60
+ next `generate!` prunes the old file, and `verify` fails until you regenerate.
61
+
62
+ Parsing a raw query *string* has no file to name it after, so it uses the
63
+ operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
64
+ `Query` for an anonymous one (its constants are container-scoped, so collisions
65
+ are impossible) while `Codegen.generate` insists on a deliberate name. Pass
66
+ `module_name:`/`name:` to override any of this.
67
+
30
68
  The schema dump is step 0 — codegen reads it, never a live endpoint.
31
69
  `cache: true` on a url client writes it on first introspection
32
70
  (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
@@ -36,14 +74,13 @@ Rake tasks (self-registering in Rails; elsewhere add
36
74
  `require "graph_weaver/tasks"` to your Rakefile):
37
75
 
38
76
  ```sh
39
- rake graph_weaver:generate # queries_path -> generated_path
77
+ rake graph_weaver:generate # queries_paths -> generated_paths.first
40
78
  rake graph_weaver:verify # fail if anything is stale — run in CI
41
79
  ```
42
80
 
43
- Scalar/enum/type registrations are baked into generated source, so they
44
- must run before the tasks do. In Rails they will — the tasks depend on
45
- `:environment`, which runs your initializers. Outside Rails, require the
46
- file that does your registrations from the Rakefile yourself.
81
+ Scalar/enum/type registrations are baked into generated source, so they must run
82
+ first. In Rails they do — the tasks depend on `:environment`. Outside Rails,
83
+ require the file that does your registrations from the Rakefile yourself.
47
84
 
48
85
  Or call the same APIs directly:
49
86
 
@@ -53,57 +90,71 @@ GraphWeaver.generate!(schema:) # write the modules
53
90
  GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
54
91
  ```
55
92
 
56
- In Rails, loading is automatic the Railtie requires every file under
57
- `generated_path` at boot, after your initializers (so registrations run
58
- first). Elsewhere it's explicit, factory_bot-style:
93
+ **Generation prunes.** Rename or delete a `.graphql` and the module it used
94
+ to produce is deleted on the next `generate!`; `verify` flags it as stale
95
+ until you regenerate. Only files carrying GraphWeaver's `# Generated by
96
+ GraphWeaver — do not edit.` header are ever deleted, so hand-written files
97
+ in the output directory are safe.
98
+
99
+ In Rails, loading is automatic — the Railtie requires every generated file at
100
+ boot, after your initializers (so registrations run first). Elsewhere it's
101
+ explicit, factory_bot-style:
59
102
 
60
103
  ```ruby
61
- GraphWeaver.load_generated! # require every file under generated_path
104
+ GraphWeaver.load_generated! # require every file under generated_paths
62
105
  ```
63
106
 
64
- The conventional paths are lists (entries may be globs the default
65
- includes `app/graphql/*/generated`, so per-schema layouts load too).
66
- Append extra locations (a test-only schema, an engine's queries) and
67
- every loader walks them all:
107
+ Every directory setting is a list`queries_paths`, `generated_paths`,
108
+ `fragments_paths` and every entry is read (entries may be globs; the
109
+ generated default includes `app/graphql/*/generated`, so per-schema layouts
110
+ load too). Append a test-only schema or an engine's queries and every reader
111
+ walks them all:
68
112
 
69
113
  ```ruby
70
114
  # e.g. in spec/support/graph_weaver.rb
71
- GraphWeaver.generated_paths << "spec/support/graphql/generated"
72
- GraphWeaver.queries_paths << "spec/support/graphql/queries"
115
+ GraphWeaver.generated_paths << "spec/graphql/generated"
73
116
  ```
74
117
 
75
- The singular accessors (`generated_path` etc.) read and replace the
76
- first entry the default target for `generate!` and the rake tasks.
118
+ Assigning a String wraps it, so pointing at one directory stays a one-liner.
119
+ `generate!` writes into the first `generated_paths` entry one run, one output
120
+ directory. `schema_path` is the one singular setting: a run reads one schema,
121
+ so a list would name a dump nothing ever opens.
77
122
 
78
123
  (Plain requires, not Zeitwerk: Zeitwerk would expect
79
124
  `Generated::PersonQuery` from `generated/person_query.rb`, and generated
80
125
  code only changes on regeneration — restart, like a schema migration.)
81
126
 
82
- Regenerate when: a query changes, the schema changes (a
83
- [`schema_stale?`](errors.md) error in production is the late signal refresh
84
- the schema dump and regenerate), a scalar registration changes, or GraphWeaver
85
- itself upgrades (emission may differ across versions; `verify_generated!`
86
- catches it).
87
-
88
- Introspected dumps record their source url, so drift is checkable ahead
89
- of the late signal: `rake graph_weaver:schema:verify` re-introspects the
90
- recorded url and fails when the server has moved;
91
- `rake graph_weaver:schema:refresh` rewrites the dump
92
- (`GRAPHWEAVER_AUTH` supplies a token for private APIs). Don't confuse
93
- the two verifies: `graph_weaver:verify` asks "is the generated code
94
- fresh?" — local, every CI run; `graph_weaver:schema:verify` asks "has the
95
- *server* drifted from the dump?" — network, needs the recorded url, run
96
- on a schedule.
127
+ Regenerate when: a query changes, the schema changes, a scalar registration
128
+ changes, or GraphWeaver itself upgrades (emission may differ across versions;
129
+ `verify_generated!` catches it). The rake tasks that spot a schema change for
130
+ you `schema:diff`, `schema:refresh`, `queries:check` are in
131
+ [getting started](getting_started.md#5-verify-in-ci); a
132
+ [`schema_stale?`](errors.md) error in production is the late signal.
97
133
 
98
134
  In development, skip the build entirely — `client.load_queries!` parses
99
135
  every query file into modules with the same names generation would use
100
136
  (see [dynamic mode](#dynamic-mode)).
101
137
 
138
+ ### Generation is deterministic
139
+
140
+ The same schema and the same queries produce **byte-identical files** — on any
141
+ machine, in any order, however many times you run it. Everything with a
142
+ non-obvious order (schema members, enum values, requires, hoisted names) is
143
+ sorted, and a spec asserts it both across calls and against the checked-in
144
+ fixtures.
145
+
146
+ Lean on it: regenerating a file you didn't change produces no diff, so a
147
+ `graph_weaver:generate` in a PR shows exactly what moved, `verify_generated!`
148
+ never fails spuriously, and a generated file is worth reviewing line by line.
149
+ (A GraphWeaver upgrade may legitimately change emission — that's a version
150
+ bump, and the changelog says when to regenerate.)
151
+
102
152
  ## Anatomy
103
153
 
104
154
  ```ruby
105
155
  module PersonQuery
106
156
  QUERY = "..." # the operation, verbatim
157
+ OPERATION_NAME = "PersonQuery" # its name — the module's, when the file's is anonymous
107
158
 
108
159
  class Result < T::Struct # the response shape, exactly as selected
109
160
  class Person < T::Struct
@@ -116,9 +167,9 @@ module PersonQuery
116
167
  const :person, T.nilable(Person)
117
168
  end
118
169
 
119
- def self.client ... # default client (see below)
120
- def self.execute(client = nil, id:) # -> GraphWeaver::Response[Result]
121
- def self.execute!(client = nil, id:) # -> Result, or raises QueryError
170
+ extend GraphWeaver::QueryModule # client / client= (see below)
171
+ def self.execute(id:, client: nil) # -> GraphWeaver::Response[Result]
172
+ def self.execute!(id:, client: nil) # -> Result, or raises QueryError
122
173
 
123
174
  def self.from_response(response) # deserialize a raw hash -> Response[Result]
124
175
  def self.from_response!(response) # -> Result, or raises QueryError
@@ -132,6 +183,14 @@ end
132
183
  `GraphWeaver::QueryError`.
133
184
  - `from_response` / `from_response!` are the **network-free half** of the
134
185
  pair — same envelope, but from a response hash you already have (see below).
186
+ - `OPERATION_NAME` rides along on every request as the spec's
187
+ `operationName`, so Apollo Studio, Hasura and your APM key traces, rate
188
+ limits and slow-query reports on the operation instead of lumping every
189
+ request together. **You don't have to name your operations**: an anonymous
190
+ document is named after the module in the emitted `QUERY` *and* in
191
+ `OPERATION_NAME` — both, since a server rejects an `operationName` its
192
+ document doesn't declare. A document that names its own operation is left
193
+ exactly as written.
135
194
 
136
195
  ## Deserializing a response from another client
137
196
 
@@ -151,9 +210,8 @@ person = response.data!.person # typed, no network
151
210
  person = PersonQuery.from_response!(raw).person
152
211
  ```
153
212
 
154
- `from_response` builds the exact same `GraphWeaver::Response[Result]` envelope
155
- `execute` does in fact `execute` *is* `from_response(transport.execute(...))`.
156
- Errors and extensions are preserved; `#data!` / `from_response!` raise
213
+ `execute` *is* `from_response(client.execute(...))`, so the envelope is
214
+ identical: errors and extensions preserved, `#data!` / `from_response!` raising
157
215
  `QueryError` on top-level errors.
158
216
 
159
217
  The one requirement: pass the response **verbatim** — a hash (or anything with
@@ -170,67 +228,43 @@ mutation($name: String!, $species: Species!, $note: String) { ... }
170
228
  ```
171
229
 
172
230
  ```ruby
173
- AddPetQuery.execute!(name: "Rex", species: AddPetQuery::Species::Dog)
231
+ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
174
232
  ```
175
233
 
176
234
  - required vs optional falls out of nullability and defaults: nullable or
177
235
  defaulted variables become optional kwargs (nil is omitted from the wire,
178
236
  so server-side defaults apply)
179
- - enum variables generate module-level `T::Enum`s and accept the enum or
180
- its wire value (`species: Species::Dog` or `species: "DOG"`)
237
+ - enum variables accept the enum or its wire value (`species: Species::Dog`
238
+ or `species: "DOG"`)
181
239
  - custom scalars serialize through the [scalar registry](scalars.md)
182
240
 
183
- **Input objects**: when an operation's only variable is a required input
184
- object (the Relay convention), the input's fields flatten straight into
185
- `execute`'s kwargs no wrapper at the call site:
241
+ One kwarg per declared variable, always — so adding a variable to a query
242
+ adds a kwarg and leaves every existing call site alone. Two names are refused at
243
+ generation, `$client` and `$variables`: the generated `execute` body already
244
+ owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
245
+ Rename the variable in the query.
246
+
247
+ **Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
248
+ normalizes underscored Symbol/String keys, enums accept wire values, nested
249
+ inputs accept hashes, and an unknown key raises with a spellchecked hint rather
250
+ than silently dropping:
186
251
 
187
252
  ```graphql
188
253
  mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
189
254
  ```
190
255
 
191
256
  ```ruby
192
- AdoptQuery.execute!(name: "Rex", species: "DOG", nickname: "Rexy")
193
- ```
194
-
195
- The wrapping level is rebuilt on the wire, and each field type-checks
196
- exactly like a variable would. Operations with more than one variable (or
197
- a nullable input) keep the variable-per-kwarg surface — there the input
198
- kwarg accepts the generated `T::Struct` or a plain hash (`.coerce`
199
- normalizes underscored Symbol/String keys; enums accept wire values;
200
- nested inputs accept hashes; unknown keys raise with a spellchecked
201
- hint rather than silently dropping):
257
+ AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
202
258
 
203
- ```ruby
204
- AdoptQuery.execute!(input: { name: "Rex", species: "DOG" }, detail: true)
259
+ # the struct form is the one srb tc checks field by field
260
+ AdoptMutation.execute!(input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog))
205
261
  ```
206
262
 
207
- In the generate! workflow, input types (and the enums they use) are
208
- emitted **once per schema** one file per type under
209
- `generated/inputs/`, with `inputs.rb` as the manifest. The module is
210
- named from the output path: the conventional layout gets
211
- `GraphQLInputs`, while a multi-schema layout names each schema's module
212
- after its directory (`app/graphql/github/generated` → `GithubInputs`).
213
- Override the module name globally with `GraphWeaver.inputs_module=` or per run
214
- with `generate!(inputs_module:)`. Per-type files keep schema drift
215
- reviewable: a migration diffs exactly the types it touched, and types
216
- the schema drops are pruned on regeneration (`verify` flags strays).
217
- Query modules alias what they touch,
218
- so `AdoptQuery::AdoptionInput` still works and shared types keep one
219
- identity across modules — three filtered Hasura queries cost one ~11k-line
220
- inputs file plus ~90 lines each, instead of ~35k lines of duplicates.
221
- Deeply nested types live unaliased in the shared module
222
- (`GraphQLInputs::PetFilter`). Dynamic `parse` stays self-contained.
223
-
224
- The structs themselves are module-level (`AdoptQuery::AdoptionInput`):
225
- typed consts plus a compact per-field `FIELDS` table that the
226
- `GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`)
227
- produces the wire hash with nil optionals omitted, `coerce` builds from
228
- plain hashes. The conversions ship in the generated file as data
229
- (lambdas in the table), so a Hasura `bool_exp` pulling hundreds of input
230
- types stays ~2 lines per field instead of unrolled methods. Nested inputs work (dependencies emit
231
- first), including recursive ones — Hasura's self-referential `bool_exp`
232
- filters generate cleanly (`_and:`/`_not:` fields typed as the struct
233
- itself), so variable-driven filtering works:
263
+ A struct is typed consts plus a compact per-field `FIELDS` table the
264
+ `GraphWeaver::InputStruct` runtime drives`serialize` (aliased `to_h`) builds
265
+ the wire hash with nil optionals omitted, `coerce` builds from a plain hash.
266
+ Nested inputs work, including recursive ones a self-referential filter
267
+ generates cleanly, with `_and:`/`_not:` typed as the struct itself:
234
268
 
235
269
  ```ruby
236
270
  where = mod::PokemonBoolExp.coerce(
@@ -239,25 +273,42 @@ where = mod::PokemonBoolExp.coerce(
239
273
  mod.execute!(where:)
240
274
  ```
241
275
 
276
+ In the `generate!` workflow input types are emitted **once per schema**, one
277
+ file per type under `generated/types/` with `types.rb` as the manifest. Query
278
+ modules alias what they touch, so `AdoptMutation::AdoptionInput` still works and
279
+ a shared type keeps one identity across modules. A query module aliases only its
280
+ *variable root* types, so a deeply nested one is reached as
281
+ `GraphQLTypes::<Type>`. Per-type files keep drift reviewable: a schema migration
282
+ diffs exactly the types it touched, and types the schema drops are pruned on
283
+ regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
284
+
285
+ ## Enums: one GraphQL enum, one Ruby type
286
+
287
+ Every schema enum a query touches — as a variable, in a result, or both —
288
+ becomes exactly one Ruby type in the shared module, named for the enum
289
+ (`GraphQLTypes::Species`), and every query module aliases it:
290
+
291
+ ```ruby
292
+ species = SearchQuery.execute!(term: "Shelby").search.first.species
293
+ AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
294
+ ```
295
+
296
+ So a value read out of one query hands straight back into another's variable,
297
+ `case`/`T.absurd` is exhaustive across your app, and the class a field gets
298
+ doesn't depend on what else the query happened to reference.
299
+
300
+ `register_enum` replaces the generated `T::Enum` with your own app enum — see
301
+ [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
302
+ the enums into the query module itself; there's no cross-query set to share
303
+ against, but one enum is still one class within that module.
304
+
242
305
  ## Selections
243
306
 
244
307
  - **Fragments** — inline fragments and named spreads flatten into the
245
308
  selection; type conditions match exact names or interfaces/unions the type
246
309
  belongs to.
247
- - **Unions and interfaces** — when the selection *varies by concrete
248
- type*, each abstract site emits a module: one member struct per
249
- possible type, `Type = T.type_alias { T.any(...) }`, and a `from_h`
250
- dispatching on `__typename` — which generation therefore *requires* in
251
- the selection (the wire response carries no type tag unless you ask).
252
- Two narrower shapes skip the dispatch (and the `__typename`) entirely:
253
- interface-level fields only → one shared struct; a single `... on X`
254
- condition and nothing else → `X`'s struct, always nilable — a
255
- non-matching runtime type comes back as `nil`, so narrowing doubles as
256
- filtering. When a whole union field is selected as one named *shared*
257
- fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
258
- `GraphQLUnions` module and each query aliases it — so the same union is one
259
- Ruby type family across queries, not a fresh dispatch module per query. Like
260
- shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
310
+ - **Unions and interfaces** — one struct per type condition the selection
311
+ names, plus a catch-all `Other`. Detail [below](#abstract-types).
261
312
  - **`@skip` / `@include`** — a directive-conditional field may be absent from
262
313
  the response regardless of schema nullability, so its generated type is
263
314
  always nilable.
@@ -270,6 +321,38 @@ mode) the struct raises a NoMethodError naming the prop that does exist —
270
321
  `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
271
322
  a near-miss typo in either casing.
272
323
 
324
+ ### Abstract types
325
+
326
+ An abstract field emits **one struct per type condition the selection names**,
327
+ plus a catch-all `Other`, wrapped in a module with
328
+ `Type = T.type_alias { T.any(...) }` and a `from_h` that dispatches on
329
+ `__typename`. Generation therefore *requires* `__typename` in such a selection,
330
+ unaliased and unconditional — the wire response carries no type tag unless you
331
+ ask, and `from_h` reads it on every response.
332
+
333
+ Size follows the query, not the schema: two `... on` conditions against GitHub's
334
+ `Node` — an interface with a few hundred implementations — emit three structs,
335
+ not a few hundred. Anything the query didn't name — a member you have no
336
+ fragment on, or one the schema grew *after* you generated — deserializes into
337
+ `Other`, carrying what the abstract type itself guarantees (an interface's
338
+ selected interface-level fields; for a union, `__typename`). Adding a union
339
+ member upstream is a non-breaking change, and it stays one here.
340
+
341
+ Two selections have nothing to dispatch between, so they skip the module and
342
+ become the struct directly: **no conditions at all** (interface-level fields
343
+ only) → one shared struct; **exactly one condition** → that type's struct,
344
+ always nilable, since a non-matching runtime type comes back as `nil` — so
345
+ narrowing doubles as filtering. Narrowing reads the match off `__typename` when
346
+ the selection carries it and off "the object came back empty" when it doesn't,
347
+ which is why an all-`@skip`/`@include` narrowed fragment without a `__typename`
348
+ is refused: a match would be indistinguishable from a miss.
349
+
350
+ When a whole union field is selected as one named *shared* fragment
351
+ (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
352
+ for the fragment — and each query aliases it, so the same union is one Ruby type
353
+ family across queries, not a fresh dispatch module per query. Like shared
354
+ inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
355
+
273
356
  ### Consuming a union — dispatch on the class, not `__typename`
274
357
 
275
358
  `from_h` already reads `__typename` off the wire and builds the right member
@@ -277,62 +360,194 @@ struct, so what you hold is a real `Book` or `Disc`, not a tag. Branch on the
277
360
  class and let Sorbet do the rest:
278
361
 
279
362
  ```ruby
280
- items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc)
363
+ items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc, Result::Item::Other)
281
364
  case item
282
365
  when Result::Item::Book then item.title # narrowed to Book — .title is available
283
366
  when Result::Item::Disc then item.runtime # narrowed to Disc — .runtime is available
367
+ when Result::Item::Other then item.__typename # something this query names no fields on
284
368
  else T.absurd(item)
285
369
  end
286
370
  end
287
371
  ```
288
372
 
289
- Two things a `case item.__typename` on the string can't give you. `when Book`
373
+ Two things a `case` on the `__typename` string can't give you. `when Book`
290
374
  *narrows*: inside the branch `item` is statically a `Book`, so its fields
291
- typecheck (a `Disc` field would be a compile error) a string value narrows
292
- nothing. And after every member, the `T.any` is exhausted, so `T.absurd` asserts
293
- the `else` is unreachable: add a member to the union, regenerate, and the
294
- `T.absurd` stops compiling until you handle it. Dispatching on the string tag
295
- gets you neither — mistakes and schema growth fall through to a runtime raise.
296
-
297
- `__typename` is still there as a plain `String` if you want the raw tag, but you
298
- rarely need it to dispatch. Its one real use is the case the class can't cover:
299
- two *differently-selected* occurrences of the same union are distinct type
300
- families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
301
- written for one won't span the other. To hold "the same union" as one type
302
- across queries, select it through a shared fragment
303
- ([shared unions](#selections)); if all you have is the bare tag, `__typename` is
304
- the common denominator (but an unchecked one).
305
-
306
- ## Naming
307
-
308
- Module names derive from the operation name (`query GetPerson` → `GetPerson`);
309
- `GraphWeaver.parse` on a `.graphql` file derives from the file name
310
- (`person.graphql` `PersonQuery`). Pass `module_name:`/`name:` to override.
311
- `Codegen.generate` requires a deliberate name for anonymous operations; dynamic
312
- `parse` defaults to `Query` (its constants are container-scoped, so collisions
313
- are impossible).
314
-
315
- Nested struct names come from GraphQL type names, disambiguated one level by
316
- field name on collision.
375
+ typecheck and a `Disc` field is a compile error. And after every branch the
376
+ `T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
377
+ **write a fragment for another member, regenerate, and the `T.absurd` stops
378
+ compiling until you handle it.**
379
+
380
+ Exhaustive over the members *this query asked about*, plus `Other` —
381
+ deliberately not "every type in the schema", which is what keeps a `case` you
382
+ wrote today compiling when upstream adds a member. To make the compiler force
383
+ your hand on a new one, name it in the query.
384
+
385
+ `__typename` is still there as a plain `String`, with one use the class can't
386
+ cover: two *differently-selected* occurrences of the same union are distinct
387
+ type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
388
+ `case` written for one won't span the other. Select the union through a shared
389
+ fragment to hold it as one type across queries ([above](#abstract-types)); if
390
+ all you have is the bare tag, `__typename` is the common denominator, unchecked.
391
+
392
+ ## Naming nested types
393
+
394
+ Module names come from the file ([above](#generating)). **Every nested type is
395
+ named for the response key that selects it**, camelized
396
+ (`stargazers` `Stargazers`, `nameWithOwner` `NameWithOwner`, `_entities`
397
+ `Entities`). Structs nest the way the selection does, so the constant path
398
+ reads like the query:
399
+
400
+ ```graphql
401
+ query { repository { stargazers { edges { node { login } } } } }
402
+ ```
403
+
404
+ ```ruby
405
+ StargazersQuery::Result::Repository::Stargazers::Edges::Node
406
+ ```
407
+
408
+ The name is a function of that field's own position and nothing else, which is
409
+ the property that matters when generated code is checked in and referenced from
410
+ app code: **adding, removing, or reordering an unrelated selection can never
411
+ rename a struct you already use.**
412
+ [`spec/naming_spec.rb`](../spec/naming_spec.rb) asserts each of those three
413
+ edits leaves the name alone.
414
+
415
+ The key is used verbatim — no pluralization heuristics, so a list field `pets`
416
+ generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
417
+ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
418
+
419
+ Two kinds of name don't come from a key, both equally position-determined:
420
+
421
+ - **Union and interface members** are named for the type condition that
422
+ produces them (`... on Book` → `Book`) inside the container named for the
423
+ field, plus the catch-all `Other`. A union hoisted out of a shared fragment
424
+ is named for the fragment.
425
+ - Where several fields share one collapsed union type (identical selections),
426
+ it takes the first of their keys alphabetically; and a name that would shadow
427
+ the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
428
+ (`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
429
+
430
+ ## Type helpers
431
+
432
+ Derived values (display names, emoji, predicates) belong next to the data but
433
+ not *in* it — rewriting wire values on the way in destroys the raw truth.
434
+ Register a plain module and every struct generated from that GraphQL type
435
+ includes it, whatever query it appears in:
436
+
437
+ ```ruby
438
+ module PetHelpers
439
+ def adult? = birthday && birthday < Date.today << 24
440
+ def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
441
+ end
442
+
443
+ GraphWeaver.extend_type("Pet", PetHelpers)
444
+
445
+ pet.display_name # => "Shelby 🦴"
446
+ pet.name # => "Shelby" — the wire value stays honest
447
+ ```
448
+
449
+ The methods live on the struct, so they see its wire fields at runtime and
450
+ fakes/cassettes get the behavior automatically; registrations are additive
451
+ (repeated ones stack). For quick decoration, build the mixin inline — the block
452
+ is `module_eval`'d into a fresh module auto-named under
453
+ `GraphWeaver::TypeHelpers`:
454
+
455
+ ```ruby
456
+ GraphWeaver.extend_type("Pet") do
457
+ def display_name = "#{name} 🐶"
458
+ end
459
+ ```
460
+
461
+ **Neither form is statically checked**, for the same reason: `srb tc` checks a
462
+ mixin's method bodies in the module's own scope, not the including struct's, so
463
+ a helper reading a wire field (`name`, `birthday`) fails with "method does not
464
+ exist on the module" — and the block form has no source on disk for `srb tc` to
465
+ read at all. Write such a helper at `# typed: false`, or reach the field through
466
+ `T.unsafe(self)`.
467
+
468
+ ### Flat accessors with `alias:`
469
+
470
+ The one derivation the generator can type for you is a plain projection — a
471
+ selected field, possibly nested, exposed under a flat accessor. `alias:` emits a
472
+ sig'd delegator *into the struct body*, where the field is in scope, so it's
473
+ fully checked (the thing a mixin can't be):
474
+
475
+ ```ruby
476
+ GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
477
+
478
+ # generated on the Widget struct:
479
+ # sig { returns(T.nilable(String)) }
480
+ # def tag = meta&.tag
481
+ ```
482
+
483
+ Forms:
484
+
485
+ ```ruby
486
+ alias: { tag: "meta.tag" } # explicit accessor name
487
+ alias: "meta.tag" # accessor named after the last segment (`tag`)
488
+ alias: ["meta.tag", "meta.color"] # several at once
489
+ alias: { label: "name", tag: "meta.tag" }
490
+ ```
491
+
492
+ The path is the **Ruby** accessor chain, so its segments are snake_case props
493
+ (`name_with_owner.tag`), not wire names. It's typed from the selection: any
494
+ nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
495
+ scalar, enum, or nested struct. It's validated against each query at generation —
496
+ an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
497
+ non-list, or a name that collides with a real field all fail with a pointed
498
+ error. Registrations stack, like the mixin forms.
499
+
500
+ A segment can also be `first` or `last` to pick one element out of a list hop —
501
+ always nilable, since the list may be empty. This is what turns an
502
+ `_entities`-style "array that logically holds one thing" into a clean accessor:
503
+
504
+ ```ruby
505
+ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
506
+
507
+ # sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
508
+ # def entity = _entities&.first # (a multi-fragment selection types it as the union)
509
+ ```
510
+
511
+ `optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
512
+ the path just omits the accessor instead of failing generation. Reach for it when
513
+ the alias lives on a universal type like `Query` — where a strict alias would
514
+ force *every* query to select the path — or when it only fits some selections.
515
+ It excuses a field the query didn't select, not a segment the schema doesn't
516
+ have: a typo or a wire-cased name (`findPets` for `find_pets`) still raises,
517
+ since no selection could ever satisfy it.
518
+
519
+ For anything beyond a passthrough projection — real logic, still typed — reopen
520
+ the generated struct in your own file and add sig'd methods; Sorbet merges the
521
+ bodies.
522
+
523
+ Every form above, and every error it raises, is a named example in
524
+ [`spec/aliases_spec.rb`](../spec/aliases_spec.rb).
317
525
 
318
526
  ## Clients
319
527
 
320
- A client is anything with `execute(query, variables:)` whose result `to_h`s
321
- into `{"data" => ..., "errors" => ...}`. Resolution: per call per module →
322
- baked constant `GraphWeaver.client` the
323
- canonical list lives in [transports](transports.md#client-resolution).
528
+ A client is anything with `execute(query, variables:, operation_name:)` whose result `to_h`s
529
+ into `{"data" => ..., "errors" => ...}` a `GraphWeaver::Client`, a transport,
530
+ a `Retry`, a live schema class, a fake. Resolution: per call (`client:`) per
531
+ module → baked constant → `GraphWeaver.client` — the canonical list lives in
532
+ [transports](transports.md#client-resolution).
324
533
 
325
534
  Generate *without* a baked constant when you want modules to follow the
326
535
  app default (`GraphWeaver.client =` in an initializer) — that's also what
327
- lets [testing's auto_fake](testing.md) swap in a fake per example.
536
+ lets [testing's `graphql:` tag](testing.md) swap in a client per example.
537
+
538
+ `client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
539
+ every generated module). A baked constant is emitted as `DEFAULT_CLIENT`,
540
+ resolved on first use so a module can load before the initializer that builds
541
+ its client.
328
542
 
329
543
  ## Dynamic mode
330
544
 
331
545
  `GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
332
546
  into an anonymous container — no global constants leak). Same runtime
333
547
  semantics; invisible to `srb tc`, so prefer the build step where static
334
- checking matters. `GraphWeaver.execute(schema:, query:, variables: {})` is
335
- the one-shot form.
548
+ checking matters. `GraphWeaver.run(source, query, **variables)` — or
549
+ `client.run` — is the one-shot form: parse and execute in one call, no module
550
+ kept.
336
551
 
337
552
  Generated source is eval'd, so inputs are validated: module names must be
338
553
  constant names, and query heredocs can't be terminated early. Still: queries