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,8 +1,8 @@
1
1
  # Getting started: the production path (Rails)
2
2
 
3
- The setup that ships: queries live as `.graphql` files, generation writes
4
- `# typed: strict` Ruby you check in, and CI fails when anything drifts.
5
- In Rails one generator does the setup. (Exploring an API from a console
3
+ The setup that ships, end to end: queries live as `.graphql` files, generation
4
+ writes `# typed: strict` Ruby you check in, and CI fails when anything drifts.
5
+ Follow it once when you add the gem to an app. (Exploring an API from a console
6
6
  instead? Start with [dynamic mode](real_world.md) — no build step.)
7
7
 
8
8
  Rails is assumed below; the [non-Rails note](#not-rails) at the bottom
@@ -24,6 +24,7 @@ rails g graph_weaver:install https://api.example.com/graphql
24
24
  ```
25
25
  create config/initializers/graph_weaver.rb
26
26
  create app/graphql/queries/.keep
27
+ create app/graphql/fragments/.keep
27
28
  create app/graphql/generated/.keep
28
29
  create graphql.config.yml
29
30
  introspect app/graphql/schema.json from https://api.example.com/graphql
@@ -41,7 +42,7 @@ initializer that fits:
41
42
 
42
43
  | flag | |
43
44
  |---|---|
44
- | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. Name a different one and the initializer follows, but `schema:refresh`/`schema:diff` still read `GRAPHWEAVER_AUTH` set both |
45
+ | `--auth` | name of the ENV var holding the auth token — default `GRAPHWEAVER_AUTH`. Url only. The name is recorded into the dump, so `schema:refresh`/`schema:diff`/`queries:check` read the same one the initializer does |
45
46
  | `--no-schema` | skip writing the dump; `rake graph_weaver:schema:refresh URL=...` does it later |
46
47
 
47
48
  Re-running is safe — every file goes through the usual Rails conflict
@@ -84,13 +85,15 @@ What it wrote:
84
85
  RubyMine schema autocomplete, hover docs, and validation as you type in
85
86
  `.graphql` files — no JS project, no `npm install`. Details and the honest
86
87
  limits in [editors](editors.md).
87
- - **`app/graphql/queries/`, `app/graphql/generated/`.** Where you write
88
- queries and where generation writes Ruby.
88
+ - **`app/graphql/queries/`, `app/graphql/fragments/`, `app/graphql/generated/`.**
89
+ Where you write queries, where shared fragments live, and where generation
90
+ writes Ruby.
89
91
 
90
92
  Rake needs no wiring either: in Rails the `graph_weaver:*` tasks register
91
93
  themselves (a Railtie) and depend on `:environment`, so your initializer —
92
- and its registrations — runs first. The generated modules load at boot the
93
- same way, after `config/initializers`.
94
+ and its registrations — runs first. The generated modules load at boot from
95
+ a `to_prepare` block, so a helper or enum you registered in one is already
96
+ in place when the file that names it loads.
94
97
 
95
98
  ### Your app's own schema, in-process
96
99
 
@@ -122,11 +125,16 @@ client = GraphWeaver.new(MyApp::Schema, context: { current_user: })
122
125
  PetQuery.execute!(client:, id: "1").pet.owner # => the context's user
123
126
  ```
124
127
 
128
+ Each query gets its own copy of that hash, so a resolver writing
129
+ `context[:loader] =` can't hand what it wrote to the next request — which
130
+ matters because one in-process client is normally the whole app's.
131
+
125
132
  **Keep the dump in step with the schema.** Codegen reads the committed
126
133
  dump at `GraphWeaver.schema_path`, never the live class — that's what
127
134
  makes `rake graph_weaver:verify` a deterministic CI check. The generator
128
135
  writes the first dump; after that it's an artifact derived from code in
129
- your own repo, so rebuild it with graphql-ruby's own rake task:
136
+ your own repo, so rebuild it with graphql-ruby's own rake task, ahead of
137
+ `verify` in CI:
130
138
 
131
139
  ```ruby
132
140
  # lib/tasks/graphql.rake
@@ -140,13 +148,16 @@ rake graphql:schema:json # rewrites app/graphql/schema.json
140
148
  rake graph_weaver:generate
141
149
  ```
142
150
 
143
- Run the dump step ahead of `rake graph_weaver:verify` in CI that check
144
- compares committed Ruby against the committed dump, so a stale dump makes
145
- it fail on a query that is fine. `rake graph_weaver:queries:check` is
146
- unaffected: when `GraphWeaver.client` runs in-process it validates
147
- against the live class, not the dump. (`graph_weaver:schema:diff` and
148
- `:refresh` are for servers you *don't* own; a dump taken from a schema
149
- class records no url, and they say so.)
151
+ A stale dump makes `verify` fail on a query that is fine. `queries:check` is
152
+ unaffected: running in-process it validates against the live class, not the
153
+ dump. (`schema:diff` and `:refresh` are for servers you *don't* own; a dump
154
+ taken from a schema class records no url, and they say so.)
155
+
156
+ **Scaffolding the app too?** On a `rails new --skip-active-record`,
157
+ `rails g graphql:install` writes `config.active_record.query_log_tags` lines
158
+ into `config/application.rb` that an app without ActiveRecord can't boot
159
+ with — a graphql-ruby bug. Run it as
160
+ `rails g graphql:install --skip-query-logs`, or delete the lines it wrote.
150
161
 
151
162
  ### A schema dump you already have
152
163
 
@@ -174,19 +185,28 @@ query($id: ID!) {
174
185
  rake graph_weaver:generate # writes app/graphql/generated/person_query.rb
175
186
  ```
176
187
 
177
- Commit the schema dump and the generated files. Generated code is
178
- reviewed like any other code — and never edited by hand.
179
-
180
188
  ```ruby
181
189
  PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
182
190
  ```
183
191
 
184
- ### Autocomplete while you write the query
192
+ Commit the schema dump and the generated files. Generated code is reviewed like
193
+ any other code — and never edited by hand. The module name comes from the file
194
+ name; the full set of naming rules is in
195
+ [generated modules](generated_modules.md#naming).
185
196
 
186
197
  `graphql.config.yml` is already there, so VS Code and RubyMine validate the
187
198
  `.graphql` files as you type, with schema autocomplete and hover docs — see
188
199
  [editors](editors.md).
189
200
 
201
+ **In development you don't type that command again.** While the server is
202
+ running, a `.graphql` edit — or a refreshed schema dump — regenerates before
203
+ the next request, the way a route or a locale change takes effect. A query that
204
+ doesn't compile is logged with its file and position while the modules already
205
+ loaded keep serving, so a file saved mid-edit doesn't take the server down.
206
+ Development only, and `config.graph_weaver.watch = false` turns it off. The
207
+ generated files are still what ships: commit them, and keep `rake
208
+ graph_weaver:verify` in CI.
209
+
190
210
  ### Shared fragments
191
211
 
192
212
  Define reusable fragments once and spread them from any query:
@@ -205,29 +225,10 @@ Fragment files hold only fragments (no operations), and names are unique across
205
225
  them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
206
226
  default `app/graphql/fragments`).
207
227
 
208
- ### Shared unions
209
-
210
- When a shared fragment *is* the whole selection on a union field, its type is
211
- hoisted once into the shared `GraphQLTypes` module and every query that spreads
212
- it aliases the same type — so a `union` selected across many queries becomes one
213
- Ruby type family, and you write one exhaustive `case … when … T.absurd` that
214
- works everywhere:
215
-
216
- ```graphql
217
- # app/graphql/fragments/feed_item.graphql
218
- fragment FeedItemFields on FeedItem {
219
- __typename
220
- ... on Post { title }
221
- ... on Photo { url }
222
- }
223
-
224
- # any query
225
- query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
226
- ```
227
-
228
- There's no flag: hoisting triggers when the union field's selection is exactly
229
- that one spread. Mix in other fields, or shadow the fragment with a query-local
230
- one of the same name, and the union stays inlined in that query — see
228
+ One payoff worth knowing about: when a shared fragment *is* the whole selection
229
+ on a union field, its type is hoisted once into `GraphQLTypes` and every query
230
+ that spreads it gets the same Ruby type so one exhaustive `case … T.absurd`
231
+ works everywhere. See
231
232
  [abstract types](generated_modules.md#abstract-types).
232
233
 
233
234
  ## 4. Test against fakes
@@ -241,47 +242,74 @@ require "graph_weaver/rspec"
241
242
  it "renders the empty state", graphql: :fake do … end # or tag the describe
242
243
  ```
243
244
 
245
+ The tag installs a seeded, schema-correct `FakeClient` for that example — no
246
+ server, no stubs, and `rspec --seed 1234` reproduces the fake data along with
247
+ test order. The schema it fabricates from is derived (the committed dump, or
248
+ your client's), so there's nothing to configure. Tag `graphql: :in_process`
249
+ instead and the same example runs against your real resolvers. Pinning values,
250
+ simulating failures, and the federated `graphql: :router` are in
251
+ [testing](testing.md).
252
+
244
253
  A fresh `rails g rspec:install` leaves the `spec/support` glob commented
245
254
  out in `spec/rails_helper.rb`, so uncomment it — or put the require in
246
255
  `rails_helper.rb` itself. Nothing warns you that a support file went
247
256
  unread.
248
257
 
249
- The tag installs a seeded, schema-correct `FakeClient` for that example —
250
- no server, no stubs, and `rspec --seed 1234` reproduces the fake data along
251
- with test order. The schema it fabricates from is derived (the committed
252
- dump, or your client's), so there's nothing to configure. Tag
253
- `graphql: :in_process` instead and the same example runs against your real
254
- resolvers; pin values with `overrides:`, simulate failures with `Failure.*`
255
- — see [testing](testing.md).
256
-
257
258
  ## 5. Verify in CI
258
259
 
259
- ```sh
260
- rake graph_weaver:verify # generated code fresh? fails on any drift
261
- rake graph_weaver:schema:diff # server drifted? re-introspects and compares
262
- rake graph_weaver:queries:check # did that drift break any of your queries?
260
+ Four questions, four tasks — the last only on a federated graph:
261
+
262
+ | ask | task | needs network |
263
+ |---|---|---|
264
+ | is the checked-in Ruby fresh? | `rake graph_weaver:verify` | no |
265
+ | has the server's schema drifted from the dump? | `rake graph_weaver:schema:diff` | yes |
266
+ | did that drift break any of my queries? | `rake graph_weaver:queries:check` | yes |
267
+ | did a subgraph change without a recompose? | `rake graph_weaver:federation:diff` | no |
268
+
269
+ `verify` compares the committed generated files against what the current
270
+ schema + queries + registrations would produce, so it belongs in every CI
271
+ build. `schema:diff` needs a dump with a recorded source url (introspected
272
+ dumps have one) and `GRAPHWEAVER_AUTH` for private APIs — run it on a
273
+ schedule and repair with `rake graph_weaver:schema:refresh`.
274
+ `federation:diff` needs no network either, so it goes in the same PR run;
275
+ see [federation](federation.md#has-the-supergraph-been-recomposed).
276
+
277
+ `schema:diff` names what moved, breaking changes first — breaking meaning
278
+ a query written against your dump stops validating, or stops casting:
279
+
263
280
  ```
281
+ app/graphql/schema.json vs https://api.example.com/graphql: 8 changes, 5 breaking
282
+
283
+ breaking:
284
+ AdoptionInput.nickname String -> String!
285
+ Person.email removed
286
+ Person.pets [Pet!]! -> [Pet!]
287
+ Query.person(includeArchived:) argument added: Boolean! — required
288
+ Species.CAT enum value removed
264
289
 
265
- Three different questions — four on a federated graph, where
266
- `rake graph_weaver:federation:diff` asks whether anyone changed a subgraph
267
- without recomposing the supergraph you committed. It needs no network
268
- either, so it belongs in the same PR run; see
269
- [federation](federation.md#has-the-supergraph-been-recomposed).
290
+ other:
291
+ Person.birthday deprecated: use bornOn
292
+ Pet.nickname added: String
293
+ Species.BIRD enum value added
270
294
 
271
- `graph_weaver:verify` compares the committed generated files against what the
272
- current schema + queries + registrations would produce. No network — run it in
273
- every CI build.
295
+ app/graphql/schema.json is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)
296
+ ```
274
297
 
275
- `graph_weaver:schema:diff` asks whether the *server* has moved since the
276
- dump was taken. It needs network, a dump with a recorded source url
277
- (introspected dumps have one), and `GRAPHWEAVER_AUTH` for private APIs;
278
- run it on a schedule and refresh with `rake graph_weaver:schema:refresh`.
298
+ Nullability is judged from your side, which is why the two above point
299
+ opposite ways: `Person.pets` losing its `!` hands a generated struct the
300
+ nil it declared it wouldn't get, while `AdoptionInput.nickname` gaining
301
+ one rejects a query that omits it. Any drift exits non-zero whether a
302
+ change matters is yours to judge.
279
303
 
280
- `graph_weaver:queries:check` answers the question that actually matters
281
- when it *has* moved: **which of your queries no longer validate, and
282
- why.** It re-introspects the recorded url (without rewriting the dump) and
283
- validates every `.graphql` file against the schema as it is right now,
284
- naming each error's line and column:
304
+ `GraphWeaver::SchemaLoader.diff(path)` is the same summary as an object —
305
+ `#breaking`, `#compatible`, `#to_h`, and `#empty?` for the plain "has it
306
+ drifted" question.
307
+
308
+ `queries:check` answers the question that actually matters when the schema
309
+ *has* moved: **which of your queries no longer validate, and why.** It
310
+ re-introspects the recorded url (without rewriting the dump) and validates
311
+ every `.graphql` file against the schema as it is right now, naming each
312
+ error's line and column, and exits non-zero:
285
313
 
286
314
  ```
287
315
  app/graphql/queries/person.graphql
@@ -290,9 +318,6 @@ app/graphql/queries/person.graphql
290
318
  1 invalid query
291
319
  ```
292
320
 
293
- It exits non-zero when anything fails, so it drops straight into CI or a
294
- scheduled job.
295
-
296
321
  The Ruby behind it returns the same thing as data, so you can wire it into
297
322
  whatever you already have (a spec, a Slack ping, an issue):
298
323
 
@@ -303,18 +328,17 @@ GraphWeaver.check_queries
303
328
  # "line" => 4, "column" => 5 }] }
304
329
  ```
305
330
 
306
- Empty means everything validates. Pass `schema:` a loaded schema and nothing
307
- touches the network — handy for checking a *proposed* schema (a subgraph about
308
- to ship) before it's live:
331
+ Empty means everything validates. Pass `schema:` a *loaded* schema (not a path)
332
+ and nothing touches the network — handy for checking a proposed subgraph before
333
+ it's live:
309
334
 
310
335
  ```ruby
311
336
  GraphWeaver.check_queries(schema: GraphWeaver::SchemaLoader.load("proposed.graphql"))
312
337
  ```
313
338
 
314
- It wants the loaded schema, not the path. Left off, it re-introspects the url
315
- the dump records and when that dump is a composed supergraph, each error also
316
- names the subgraphs behind the type it points at
317
- ([federation](federation.md#the-routing-table)).
339
+ Left off, it re-introspects the url the dump records and when that dump is a
340
+ composed supergraph, each error also names the subgraphs behind the type it
341
+ points at ([federation](federation.md#the-routing-table)).
318
342
 
319
343
  ## Sorbet, with or without
320
344
 
data/docs/logging.md CHANGED
@@ -14,8 +14,9 @@ What logs at which level — pick the level, get the story:
14
14
  | Level | What you see |
15
15
  |-------|--------------|
16
16
  | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes, request timing, connection open/drop, dynamically parsed modules |
17
- | `info` | schema introspection (with timing) and cache hits/misses, generated files written and any unregistered scalars, query modules loaded |
18
- | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` |
17
+ | `info` | schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
+ | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
19
+ | `error` | development only: a `.graphql` edit that won't compile, with its file and position — the modules already loaded keep serving |
19
20
 
20
21
  Every line carries `graph_weaver` as the progname, so formatter-based
21
22
  filtering works out of the box. Wire lines are tagged
@@ -24,8 +25,37 @@ name — so a request's lines stay paired when threads interleave.
24
25
 
25
26
  **PII note**: queries, variables, and response sizes appear at debug
26
27
  only — variables can carry user data, so keep production loggers at
27
- info or above (or scrub in your formatter). Auth headers never log at
28
- any level.
28
+ info or above. Auth headers never log at any level.
29
+
30
+ ## Filtered variables
31
+
32
+ Debug gets switched on during an incident, which is exactly when a
33
+ `login(password:)` mutation's variables must not land in the log. So the
34
+ values of sensitive keys are replaced with `[FILTERED]` before the line is
35
+ written — at any depth, including inside input objects.
36
+
37
+ In Rails you configure nothing: the railtie adopts the app's own
38
+ `config.filter_parameters`, so GraphWeaver scrubs whatever the request logs
39
+ already scrub.
40
+
41
+ Everywhere else, one list:
42
+
43
+ ```ruby
44
+ GraphWeaver.filter_parameters = [:password, /token/]
45
+ ```
46
+
47
+ Strings and Symbols match as case-insensitive substrings — `:token` covers
48
+ `apiToken` — and Regexps match themselves. The default is `[:password,
49
+ :token, :secret, :authorization]`, which covers the usual names before
50
+ anyone configures anything; assigning replaces it rather than adding to it,
51
+ and `[]` turns filtering off. Anything answering `#filter(hash)` is used
52
+ as-is, which is how the railtie hands over an
53
+ `ActiveSupport::ParameterFilter`.
54
+
55
+ The same list scrubs error messages, which reach the log at `warn` rather
56
+ than `debug`: a variable, input field, or entity key whose name is filtered
57
+ is rejected with `[FILTERED]` in place of the value — everything else keeps
58
+ quoting it, since `expected an Int, got "lots"` is the whole diagnosis.
29
59
 
30
60
  ## Instrumentation
31
61
 
@@ -52,7 +82,7 @@ response. Its payload carries:
52
82
  | Key | |
53
83
  |-----|--|
54
84
  | `:url` | the endpoint — nil in-process |
55
- | `:schema` | the schema class, in-process only |
85
+ | `:schema` | the schema class's name, in-process only — a String, so a payload logs as it stands |
56
86
  | `:operation` | the operation name sent with the request (a generated module always has one) — what a trace keys on |
57
87
  | `:status` | the HTTP status, added once the response lands |
58
88
 
data/docs/scalars.md CHANGED
@@ -22,11 +22,18 @@ GraphWeaver.register_scalar("User.birthday", Date) # this field only
22
22
  ```
23
23
 
24
24
  A field override wins over the scalar-name registration — which is also how two
25
- servers that disagree about a `DateTime` coexist in one process. Coordinates are
26
- validated against the schema, so a typo'd field raises.
25
+ servers that disagree about a `DateTime` coexist in one process.
27
26
 
28
- Pass a real class as `type:` and the cast/serialize are **inferred** from it by
29
- probing the deserialize side and pairing its serializer:
27
+ Registrations are validated against the schema you generate against, and only
28
+ what that schema can **disprove** fails generation: a name it declares as
29
+ something else (`register_scalar("Species")` where `Species` is an enum), or a
30
+ coordinate whose field it declares as a composite. A name it simply can't match
31
+ only warns — one registry serves a whole graph, so that name may belong to the
32
+ subgraph next door (see
33
+ [federation](federation.md#generating-for-a-federated-graph)).
34
+
35
+ Pass a real class as the second argument and the cast/serialize are
36
+ **inferred** from it by probing the deserialize side and pairing its serializer:
30
37
 
31
38
  | the class defines | cast | serialize |
32
39
  |-------------------|---------------|----------------|
@@ -42,40 +49,128 @@ you need to:
42
49
  - a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
43
50
  - `:itself` to force pass-through, opting out of inference (rare)
44
51
 
45
- `type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
52
+ The type also accepts a plain string (`"BigDecimal"`) when you'd rather not
46
53
  reference the class. `requires:` (a string or array) names files emitted as
47
- `require`s atop the generated source so the cast/type resolve. When `type:` is
54
+ `require`s atop the generated source so the cast/type resolve. When the type is
48
55
  a real class (so the runtime is loaded), each path is also `require`d at
49
56
  registration — a typo fails now, not in the generated file.
50
57
 
51
- Pass `coerce: true` to let a variable of this scalar accept **either** the value
52
- object **or** its raw input, normalizing the latter before it goes on the wire:
58
+ The testing harness can't invent a wire value for a scalar registered as your
59
+ own class only `Money.parse` knows what it accepts — so it refuses rather than
60
+ guess. Say it in test config, where that answer belongs: a pin for the type,
61
+ `GraphWeaver::Testing.config.overrides = { "Money" => "12.00" }`, or per example
62
+ ([testing → pins](testing.md#pins)). A scalar registered as `Time`, `Date`,
63
+ `Integer`, `Float`, `String` or `T::Boolean` needs nothing.
64
+
65
+ A registration whose type is a class **JSON can't parse into** needs a
66
+ `cast:` to build one — `BigDecimal` is the one people reach for, and it defines
67
+ neither `.parse` nor `.load`, so inference finds no codec and the prop would be
68
+ unsatisfiable. Generation refuses it where a query reads that scalar back,
69
+ naming the field:
70
+
71
+ ```
72
+ register_scalar("Money", BigDecimal) has no cast, so nothing builds a BigDecimal
73
+ out of the JSON at Product.price — give it one ...
74
+ ```
75
+
76
+ A registration used only for a variable is untouched: nothing casts it.
77
+
78
+ `cast:` is also what a *variable* of this scalar coerces through, so the same
79
+ registration gets you both directions with nothing to switch on:
53
80
 
54
81
  ```ruby
55
- GraphWeaver.register_scalar("Money", Money, coerce: true)
56
- # generated execute now takes T.any(Money, String); "12.00" is parsed
82
+ GraphWeaver.register_scalar("Money", Money)
57
83
  StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
58
- StoreQuery.execute(budget: Money.new(1200)) # passed straight through
84
+ StoreQuery.execute(budget: Money.new(1200)) # already a Money — passed straight through
59
85
  ```
60
86
 
61
- `GraphWeaver.auto_coerce = true` is the same switch for every scalar at once —
62
- set it any time before you generate; an explicit `coerce:` on a registration
63
- always wins. Off by default either way: the strict typed kwarg is the norm.
64
-
65
- *How* a scalar coerces isn't yours to pick the scalar already knows. `Int` and
66
- `Float` convert (`"5"` `5`, sent as a native number); anything with a full
67
- cast/serialize pair (`Date`, your `Money`) parses, and bad input still explodes
68
- because the cast raises. A pass-through scalar — `String`, `ID`, `Boolean` — has
69
- neither a conversion nor a codec pair, so it can't coerce at all: `coerce: true`
70
- on one raises rather than emitting a no-op.
87
+ The kwarg is still typed `Money`, not `T.any(Money, String)`: `execute`'s sig
88
+ stays as narrow as the schema and the conversion happens in its body (see
89
+ [typed variables](generated_modules.md#variables-become-typed-kwargs)). So
90
+ `budget: "12.00"` written literally in a `# typed:` file is still an `srb tc`
91
+ error as it should be, since you have a `Money` right there while
92
+ `budget: params[:budget]` typechecks and converts.
71
93
 
72
94
  The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
73
95
  same path (`Date` even carries its own `require "date"`), so a later
74
96
  `register_scalar` overrides them.
75
97
 
76
- `GraphWeaver.reset_registrations!` is the clean slate between tests: built-in
77
- scalars restored, enum mappings and type helpers dropped. To reset one registry
78
- rather than all of them, `GraphWeaver::Codegen` has the pieces
98
+ ## What the wire carries
99
+
100
+ The rule is one sentence: **generated code takes every JSON spelling a
101
+ spec-compliant server may write, and refuses the rest.** The tables below are
102
+ the whole of it, and [`bin/round-trip`](../bin/round-trip) fuzzes both
103
+ directions against them — the accepted spellings as real values, the refused
104
+ ones under `--hostile`, where generated code has to name what it turned down.
105
+
106
+ The one place "spec-compliant" is doing real work is `Float`. JSON has a single
107
+ number type and encoders write the shortest form, so `1.0` reaches Ruby as `1`
108
+ from graphql-js and from Go. Nothing does the reverse: `2.0` for an `Int` is the
109
+ server writing a non-integer where the spec says integer, so it is refused.
110
+
111
+ ### Coming back — what `from_h` accepts
112
+
113
+ | scalar | accepted | refused |
114
+ |---|---|---|
115
+ | `Int` | any JSON integer, including past 2³¹ and 2⁵³ (lossless in Ruby) | `2.0`, `1.5`, `"1"`, `true` |
116
+ | `Float` | any JSON number, `3` and `-0.0` and `1e308` included; also a decimal string | a non-numeric string, `true`, a list/object |
117
+ | `String` | any JSON string — empty, unicode, newlines, control characters | a number, `true`, a list/object |
118
+ | `ID` | any JSON string | a number or `true` — **refused with a hint**: the server didn't quote it |
119
+ | `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
120
+ | `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
121
+ | `DateTime`/`Time` (registered as `Time`) | RFC 3339 with `Z` or an offset, with or without fractional seconds, seconds optional; also a bare date and `Time.parse`'s looser forms | an epoch integer, an unparseable string |
122
+ | an enum | a declared value, as a string | an undeclared value, a non-string |
123
+ | unregistered | anything — `T.untyped`, straight through | nothing |
124
+
125
+ A refusal is a [`GraphWeaver::TypeError`](errors.md) naming the field and the
126
+ generated struct (which names the query). Two refusals carry advice rather than
127
+ only sorbet's words: an unquoted `ID`, and a registration with no cast (above).
128
+
129
+ Numeric strings — here, and in the going-out table below — are read as a wire
130
+ format, not as Ruby source: `"010"` is ten, and `"0x1f"` and `"1_0"` are
131
+ refused. `Kernel#Integer` and `Kernel#Float` accept all three as literals,
132
+ which would let a zero-padded form field silently mean something else.
133
+
134
+ ### Going out — what a variable kwarg accepts
135
+
136
+ The kwarg's **type** is what `srb tc` holds a call site to, and it is exactly
137
+ what the schema says. The **value** reaching `execute` at runtime is coerced,
138
+ because a Rails param is a String whatever the sig says (see
139
+ [typed variables](generated_modules.md#variables-become-typed-kwargs) for why
140
+ the sig is `.checked(:never)`).
141
+
142
+ | scalar | kwarg is typed | also accepts, at runtime | on the wire |
143
+ |---|---|---|---|
144
+ | `Int` | `Integer` | a decimal string, a whole `Float` | the integer |
145
+ | `Float` | `Float` | a decimal string, an `Integer` | the float |
146
+ | `String` | `String` | nothing | the string |
147
+ | `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
148
+ | `Boolean` | `true`/`false` | nothing | the boolean |
149
+ | `Date` | `Date` | an ISO-8601 string | `iso8601` |
150
+ | `Time` | `Time` | a string `Time.parse` takes | `iso8601` |
151
+ | an enum | the member **or** its wire value | — | the wire value |
152
+ | an input object | the struct **or** a Hash | — | the wire hash |
153
+ | a registered custom scalar | its Ruby type | whatever its `cast:` takes | its `serialize:` |
154
+ | unregistered | `T.untyped` | anything | straight through |
155
+
156
+ Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
157
+ the GraphQL spec says an ID serializes as a string but accepts an integer input,
158
+ and `execute(id: user.id)` off a model is the everyday call; `String` gets no
159
+ such license, since an `Integer` where a `String` belongs is more often a bug
160
+ than a spelling. **`Boolean` takes no string** — Ruby has no `Kernel#Boolean`,
161
+ so every rule for reading `"0"`, `"off"`, `"no"` is somebody's convention, and
162
+ the library will not pick one for you; convert at the call site.
163
+
164
+ Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
165
+ the operation and the value — `$count of Compute: expected an Int, got "lots"`
166
+ — which is the same [422 rescue point](errors.md) as a bad input-object field.
167
+ Input-object fields go through this table too, so `{first: "20"}` inside a
168
+ filter hash reads the same as `first: "20"` as a kwarg.
169
+
170
+ `GraphWeaver.reset_registrations!` is the clean slate between tests, or between
171
+ generations for different schemas: built-in scalars restored, enum mappings and
172
+ type helpers dropped. To reset one registry rather than all of them,
173
+ `GraphWeaver::Codegen` has the pieces —
79
174
  `reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
80
175
  entirely), `reset_enums!`, `reset_type_helpers!`.
81
176