graph_weaver 0.5.0 → 0.6.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -1,21 +1,25 @@
1
1
  # Generated modules
2
2
 
3
- `GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
4
- Ruby module. Everything `srb tc` knows about your query results comes from this
5
- file there is no runtime schema, no lazy wrapper, no reflection.
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.
6
7
 
7
- This is the production path checked in, reviewed, statically checked
8
- (assembled step by step in the [getting started](getting_started.md), including
9
- [what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without)).
8
+ `GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
9
+ 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).
10
14
  For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
11
15
  scripts, `client.run!` skips modules entirely.
12
16
 
13
17
  ## Generating
14
18
 
15
- The workflow that keeps generated code honest: queries live as `.graphql`
16
- files (the source of truth), generation writes the Ruby, and verification
17
- fails when the two drift. The conventional layout (configurable via
18
- `GraphWeaver.queries_paths` / `generated_paths` / `schema_path`):
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`):
19
23
 
20
24
  ```text
21
25
  app/graphql/
@@ -29,60 +33,15 @@ app/graphql/
29
33
  *_mutation.rb # ...and per mutation
30
34
  ```
31
35
 
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
-
68
- The schema dump is step 0 — codegen reads it, never a live endpoint.
69
- `cache: true` on a url client writes it on first introspection
70
- (`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
71
- generating without one fails pointing at exactly that.
72
-
73
- Rake tasks (self-registering in Rails; elsewhere add
74
- `require "graph_weaver/tasks"` to your Rakefile):
75
-
76
36
  ```sh
77
37
  rake graph_weaver:generate # queries_paths -> generated_paths.first
78
38
  rake graph_weaver:verify # fail if anything is stale — run in CI
79
39
  ```
80
40
 
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.
84
-
85
- Or call the same APIs directly:
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:
86
45
 
87
46
  ```ruby
88
47
  schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
@@ -90,14 +49,40 @@ GraphWeaver.generate!(schema:) # write the modules
90
49
  GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
91
50
  ```
92
51
 
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.
57
+
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.
62
+
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
+
93
73
  **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.
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.
98
82
 
99
83
  In Rails, loading is automatic — the Railtie requires every generated file at
100
- boot, after your initializers (so registrations run first). Elsewhere it's
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
101
86
  explicit, factory_bot-style:
102
87
 
103
88
  ```ruby
@@ -118,16 +103,23 @@ GraphWeaver.generated_paths << "spec/graphql/generated"
118
103
  Assigning a String wraps it, so pointing at one directory stays a one-liner.
119
104
  `generate!` writes into the first `generated_paths` entry — one run, one output
120
105
  directory. `schema_path` is the one singular setting: a run reads one schema,
121
- so a list would name a dump nothing ever opens.
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.
122
110
 
123
111
  (Plain requires, not Zeitwerk: Zeitwerk would expect
124
- `Generated::PersonQuery` from `generated/person_query.rb`, and generated
125
- code only changes on regeneration restart, like a schema migration.)
126
-
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
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
131
123
  [getting started](getting_started.md#5-verify-in-ci); a
132
124
  [`schema_stale?`](errors.md) error in production is the late signal.
133
125
 
@@ -135,19 +127,13 @@ In development, skip the build entirely — `client.load_queries!` parses
135
127
  every query file into modules with the same names generation would use
136
128
  (see [dynamic mode](#dynamic-mode)).
137
129
 
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
130
+ **Generation is deterministic.** The same schema and queries produce
131
+ byte-identical files, on any machine, in any order — everything with a
142
132
  non-obvious order (schema members, enum values, requires, hoisted names) is
143
133
  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.)
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.
151
137
 
152
138
  ## Anatomy
153
139
 
@@ -178,11 +164,10 @@ end
178
164
 
179
165
  - `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
180
166
  `#data`, `#data!`, `#errors`, `#extensions` — so partial data and
181
- cost/throttle metadata survive.
182
- - `execute!` is the shortcut: the typed result or a raised
183
- `GraphWeaver::QueryError`.
167
+ cost/throttle metadata survive. `execute!` is the shortcut: the typed
168
+ **result**, or a raised `GraphWeaver::QueryError`. See [errors](errors.md).
184
169
  - `from_response` / `from_response!` are the **network-free half** of the
185
- pair — same envelope, but from a response hash you already have (see below).
170
+ pair — same envelope, but from a response hash you already have (below).
186
171
  - `OPERATION_NAME` rides along on every request as the spec's
187
172
  `operationName`, so Apollo Studio, Hasura and your APM key traces, rate
188
173
  limits and slow-query reports on the operation instead of lumping every
@@ -192,34 +177,75 @@ end
192
177
  document doesn't declare. A document that names its own operation is left
193
178
  exactly as written.
194
179
 
195
- ## Deserializing a response from another client
180
+ ## Naming
196
181
 
197
- `execute` is two steps: make the request, then cast the JSON into the typed
198
- structs. Only the second step is GraphWeaver-specific, and it's exposed on its
199
- own so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
200
- a batching layer, a recorded fixture) and hand the result to GraphWeaver:
182
+ **A module is named after its file**, suffixed with the operation the file
183
+ defines `person.graphql` `PersonQuery` in `person_query.rb`,
184
+ `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!`.
201
190
 
202
- ```ruby
203
- raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
204
- # => {"data" => {"person" => {...}}, "errors" => [...], "extensions" => {...}}
191
+ Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
192
+ 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.
205
198
 
206
- response = PersonQuery.from_response(raw) # GraphWeaver::Response[Result]
207
- person = response.data!.person # typed, no network
199
+ Parsing a raw query *string* has no file to name it after, so it uses the
200
+ operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
201
+ `Query` for an anonymous one (its constants are container-scoped, so collisions
202
+ 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.
209
+
210
+ **Every nested type is named for the response key that selects it**, camelized
211
+ (`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
212
+ `Entities`). Structs nest the way the selection does, so the constant path
213
+ reads like the query:
208
214
 
209
- # or skip the envelope:
210
- person = PersonQuery.from_response!(raw).person
215
+ ```graphql
216
+ query { repository { stargazers { edges { node { login } } } } }
211
217
  ```
212
218
 
213
- `execute` *is* `from_response(client.execute(...))`, so the envelope is
214
- identical: errors and extensions preserved, `#data!` / `from_response!` raising
215
- `QueryError` on top-level errors.
219
+ ```ruby
220
+ StargazersQuery::Result::Repository::Stargazers::Edges::Node
221
+ ```
216
222
 
217
- The one requirement: pass the response **verbatim** a hash (or anything with
218
- `#to_h`) with the standard GraphQL shape and **wire-cased string keys**
219
- (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
220
- `"extensions"` keys included. Don't symbolize or snake_case it first;
221
- `from_response` reads `raw["data"]` and the casting reads camelCase field keys.
222
- The module must, of course, be generated for the query you ran.
223
+ The name is a function of that field's own position and nothing else, which is
224
+ the property that matters when generated code is checked in and referenced from
225
+ app code: **adding, removing, or reordering an unrelated selection can never
226
+ rename a struct you already use.**
227
+ [`spec/naming_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/naming_spec.rb) asserts each of those three
228
+ edits leaves the name alone.
229
+
230
+ The key is used verbatim — no pluralization heuristics, so a list field `pets`
231
+ generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
232
+ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
233
+
234
+ Two kinds of name don't come from a key, both equally position-determined:
235
+
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.
223
249
 
224
250
  ## Variables become typed kwargs
225
251
 
@@ -232,12 +258,39 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
232
258
  ```
233
259
 
234
260
  - required vs optional falls out of nullability and defaults: nullable or
235
- defaulted variables become optional kwargs (nil is omitted from the wire,
236
- so server-side defaults apply)
261
+ 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.
237
268
  - enum variables accept the enum or its wire value (`species: Species::Dog`
238
269
  or `species: "DOG"`)
239
270
  - custom scalars serialize through the [scalar registry](scalars.md)
240
271
 
272
+ **The kwarg is typed exactly as the schema types it, and the value is coerced
273
+ anyway.** Those aren't in tension, because they answer different questions:
274
+
275
+ ```ruby
276
+ StargazersQuery.execute(first: 10) # typechecks
277
+ StargazersQuery.execute(first: "10") # srb tc error — you know it's a literal
278
+ StargazersQuery.execute(first: params[:first]) # typechecks, and "10" becomes 10
279
+ ```
280
+
281
+ `first:` is `Integer`, never `T.any(Integer, String)`, so `srb tc` still catches
282
+ a call site that has the wrong thing. But a Rails param is `T.untyped` — sorbet
283
+ lets it through, and `execute` converts it in its body from what the scalar
284
+ already knows ([the table is in scalars.md](scalars.md#going-out--what-a-variable-kwarg-accepts)).
285
+ A value that won't convert raises `GraphWeaver::InputError` naming the variable,
286
+ the operation and the value.
287
+
288
+ 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
+
241
294
  One kwarg per declared variable, always — so adding a variable to a query
242
295
  adds a kwarg and leaves every existing call site alone. Two names are refused at
243
296
  generation, `$client` and `$variables`: the generated `execute` body already
@@ -262,7 +315,11 @@ AdoptMutation.execute!(input: AdoptMutation::AdoptionInput.new(name: "Rex", spec
262
315
 
263
316
  A struct is typed consts plus a compact per-field `FIELDS` table the
264
317
  `GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`) builds
265
- the wire hash with nil optionals omitted, `coerce` builds from a plain hash.
318
+ the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
319
+ the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
320
+ struct built with `.new` can't tell the two apart — every unset prop is nil
321
+ either way — so `nil` there means omit; reach for `coerce` to send an explicit
322
+ null.
266
323
  Nested inputs work, including recursive ones — a self-referential filter
267
324
  generates cleanly, with `_and:`/`_not:` typed as the struct itself:
268
325
 
@@ -297,6 +354,10 @@ So a value read out of one query hands straight back into another's variable,
297
354
  `case`/`T.absurd` is exhaustive across your app, and the class a field gets
298
355
  doesn't depend on what else the query happened to reference.
299
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.
360
+
300
361
  `register_enum` replaces the generated `T::Enum` with your own app enum — see
301
362
  [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
302
363
  the enums into the query module itself; there's no cross-query set to share
@@ -307,8 +368,6 @@ against, but one enum is still one class within that module.
307
368
  - **Fragments** — inline fragments and named spreads flatten into the
308
369
  selection; type conditions match exact names or interfaces/unions the type
309
370
  belongs to.
310
- - **Unions and interfaces** — one struct per type condition the selection
311
- names, plus a catch-all `Other`. Detail [below](#abstract-types).
312
371
  - **`@skip` / `@include`** — a directive-conditional field may be absent from
313
372
  the response regardless of schema nullability, so its generated type is
314
373
  always nilable.
@@ -340,12 +399,16 @@ member upstream is a non-breaking change, and it stays one here.
340
399
 
341
400
  Two selections have nothing to dispatch between, so they skip the module and
342
401
  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.
402
+ only) → one shared struct; **exactly one condition and nothing else** → that
403
+ type's struct, always nilable, since a non-matching runtime type comes back as
404
+ `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.
349
412
 
350
413
  When a whole union field is selected as one named *shared* fragment
351
414
  (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
@@ -389,44 +452,6 @@ type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
389
452
  fragment to hold it as one type across queries ([above](#abstract-types)); if
390
453
  all you have is the bare tag, `__typename` is the common denominator, unchecked.
391
454
 
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
455
  ## Type helpers
431
456
 
432
457
  Derived values (display names, emoji, predicates) belong next to the data but
@@ -448,7 +473,10 @@ pet.name # => "Shelby" — the wire value stays honest
448
473
 
449
474
  The methods live on the struct, so they see its wire fields at runtime and
450
475
  fakes/cassettes get the behavior automatically; registrations are additive
451
- (repeated ones stack). For quick decoration, build the mixin inline the block
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
452
480
  is `module_eval`'d into a fresh module auto-named under
453
481
  `GraphWeaver::TypeHelpers`:
454
482
 
@@ -521,14 +549,14 @@ the generated struct in your own file and add sig'd methods; Sorbet merges the
521
549
  bodies.
522
550
 
523
551
  Every form above, and every error it raises, is a named example in
524
- [`spec/aliases_spec.rb`](../spec/aliases_spec.rb).
552
+ [`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
525
553
 
526
554
  ## Clients
527
555
 
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
556
+ A client is anything satisfying the [execute contract](transports.md) a
557
+ `GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
558
+ Resolution is per call (`client:`) → per module → baked constant →
559
+ `GraphWeaver.client`; the canonical list is in
532
560
  [transports](transports.md#client-resolution).
533
561
 
534
562
  Generate *without* a baked constant when you want modules to follow the
@@ -536,9 +564,38 @@ app default (`GraphWeaver.client =` in an initializer) — that's also what
536
564
  lets [testing's `graphql:` tag](testing.md) swap in a client per example.
537
565
 
538
566
  `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.
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.
570
+
571
+ ## Deserializing a response from another client
572
+
573
+ `execute` is two steps: make the request, then cast the JSON into the typed
574
+ 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:
577
+
578
+ ```ruby
579
+ raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
580
+ # => {"data" => {"person" => {...}}, "errors" => [...], "extensions" => {...}}
581
+
582
+ response = PersonQuery.from_response(raw) # GraphWeaver::Response[Result]
583
+ person = response.data!.person # typed, no network
584
+
585
+ person = PersonQuery.from_response!(raw).person # or skip the envelope
586
+ ```
587
+
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.
593
+
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.
542
599
 
543
600
  ## Dynamic mode
544
601