graph_weaver 0.7.3 → 0.7.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +5 -2
  5. data/docs/federation.md +3 -2
  6. data/docs/generated_modules.md +176 -22
  7. data/docs/getting_started.md +174 -14
  8. data/docs/i18n.md +4 -4
  9. data/docs/migrating.md +119 -0
  10. data/docs/scalars.md +161 -35
  11. data/docs/testing.md +24 -3
  12. data/docs/upgrading.md +51 -5
  13. data/examples/github/generated/star_mutation.rb +24 -2
  14. data/examples/github/generated/stargazers_query.rb +61 -5
  15. data/examples/github/generated/starred_query.rb +33 -3
  16. data/lib/generators/graph_weaver/install_generator.rb +32 -3
  17. data/lib/graph_weaver/client.rb +23 -0
  18. data/lib/graph_weaver/codegen/aliases.rb +23 -2
  19. data/lib/graph_weaver/codegen/emit.rb +35 -16
  20. data/lib/graph_weaver/codegen/enum_type.rb +149 -19
  21. data/lib/graph_weaver/codegen/nodes.rb +72 -37
  22. data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
  23. data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
  24. data/lib/graph_weaver/codegen.rb +259 -106
  25. data/lib/graph_weaver/coerce.rb +25 -6
  26. data/lib/graph_weaver/federation.rb +1 -6
  27. data/lib/graph_weaver/graph.rb +4 -1
  28. data/lib/graph_weaver/hints.rb +23 -5
  29. data/lib/graph_weaver/in_process.rb +1 -3
  30. data/lib/graph_weaver/input_struct.rb +31 -10
  31. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  32. data/lib/graph_weaver/internal/unused.rb +32 -7
  33. data/lib/graph_weaver/internal/values.rb +12 -4
  34. data/lib/graph_weaver/internal.rb +84 -0
  35. data/lib/graph_weaver/logging.rb +26 -29
  36. data/lib/graph_weaver/query_module.rb +20 -5
  37. data/lib/graph_weaver/railtie.rb +7 -2
  38. data/lib/graph_weaver/rspec.rb +0 -1
  39. data/lib/graph_weaver/schema_loader.rb +7 -8
  40. data/lib/graph_weaver/tasks.rb +60 -5
  41. data/lib/graph_weaver/testing/fake_client.rb +4 -10
  42. data/lib/graph_weaver/testing/router.rb +26 -25
  43. data/lib/graph_weaver/testing.rb +101 -1
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +80 -74
  46. metadata +3 -2
data/docs/migrating.md ADDED
@@ -0,0 +1,119 @@
1
+ # Migrating from graphql-client (or a hand-rolled client)
2
+
3
+ **The wire doesn't change.** The same query text goes out and the same JSON comes
4
+ back, so this is a sequence of small commits rather than a cutover: queries move
5
+ across one at a time while the old client keeps serving, and most of the spec
6
+ suite you already have keeps passing untouched.
7
+
8
+ Budget 3–4 weeks of one engineer for an app with ~40 queries and 25k lines of
9
+ code reading them — [what it costs](#what-it-costs) breaks that down. If the app
10
+ doesn't run Sorbet and has fewer than ten queries, don't migrate: the wins that
11
+ survive without `srb tc` are real, but they don't pay for a checked-in code
12
+ generator ([alternatives](alternatives.md#graphlient)).
13
+
14
+ ## From graphql-client, in Rails
15
+
16
+ **Both gems stay in the Gemfile until the last commit.** `rails g
17
+ graph_weaver:install` boots the app to read your configuration, so an initializer
18
+ that still requires graphql-client has to keep resolving — remove the gem first
19
+ and the generator dies on `cannot load such file -- graphql/client`. Running the
20
+ two side by side is the right shape anyway.
21
+
22
+ 1. **Install beside graphql-client**, pointed at the dump you already have:
23
+ `rails g graph_weaver:install db/petstore_schema.json` ([a schema dump you
24
+ already have](getting_started.md#a-schema-dump-you-already-have)). Commit that
25
+ and nothing else.
26
+ 2. **Adopt the dump.** It records no source url, so `schema:diff` and
27
+ `queries:check` have no server to ask. `rake graph_weaver:schema:refresh`
28
+ rewrites it from the client the app points at (or `URL=<your endpoint>` once)
29
+ and records the provenance. Do it now: until you do, `queries:check` is
30
+ re-reading the file it is meant to be checking against, and says so.
31
+ 3. **Register scalars and enums, and regenerate**, before porting any code.
32
+ Registrations are baked into generated source, so one added later reaches
33
+ nothing until the next `rake graph_weaver:generate` ([scalars](scalars.md)).
34
+ 4. **Port one query end to end** — write the `.graphql` file, generate, rewrite
35
+ its call site — and leave the specs alone. They pass untouched. That single
36
+ commit is the proof for every one after it.
37
+ 5. **Port the rest, one commit per query.** Each `Client.parse` constant stays
38
+ where it is until its last caller is gone.
39
+ 6. **Only now port the specs** to `graphql: :fake` ([testing](testing.md)). Keep
40
+ webmock until the last wire-level stub is gone, and keep one spec that really
41
+ serves HTTP.
42
+ 7. **Delete graphql-client.** Last commit.
43
+ 8. **Sigs through your own app are a separate project**, after all of the above —
44
+ [the types stop where your sigs do](getting_started.md#the-types-stop-where-your-sigs-do)
45
+ says what that work is.
46
+
47
+ Keep your own error classes. Translating `GraphWeaver::QueryError`,
48
+ `TransportError` and `ServerError` at one seam leaves a controller, its rescues
49
+ and their specs untouched ([errors](errors.md)). Keep your presenters too, and
50
+ your fixtures until step 6.
51
+
52
+ ### Why app code and specs are separate commits
53
+
54
+ Because the wire is the same, a webmock suite doesn't notice which client sent
55
+ the request. Swap the client under an untouched suite and most of it stays green
56
+ — 18 of 22 stubbed examples in one migrated Rails app, same stubs, same JSON
57
+ fixtures; the four that failed were unit specs feeding a hand-rolled `Struct` to
58
+ a presenter. That is the difference between a reviewable migration and a big-bang
59
+ one.
60
+
61
+ ## From a hand-rolled client
62
+
63
+ Same skeleton, three differences.
64
+
65
+ - **One branch, no seam.** There's no generator to keep bootable and a
66
+ hand-rolled client is sixty lines, so running two of them side by side costs
67
+ more than it saves.
68
+ - **Set the load order first**: `GraphWeaver.load_generated!` goes before your own
69
+ requires ([not Rails?](getting_started.md#not-rails)). Add `rake` to the Gemfile
70
+ while you're there.
71
+ - **Keep the old client's transport specs**, rewritten against a real
72
+ `GraphWeaver.new(url, retries: 2)` with webmock. Retries, timeouts and backoff
73
+ are the one thing fakes can't cover.
74
+
75
+ ## What you delete
76
+
77
+ - **The retry loop.** A hand-rolled one usually retries everything, mutations
78
+ included. `retries: 2` excludes mutations by default, honours `Retry-After`,
79
+ and knows 408 and 429 ([retries](transports.md#retries)) — twenty lines gone and
80
+ a correctness bug gone with them.
81
+ - **Scalar parsers.** `register_scalar("Money", Money)` is one line, and it
82
+ deletes the `"USD 12.50".split` sitting in every place that parsed one. `Date`
83
+ and `DateTime` need no registration at all ([scalars](scalars.md)).
84
+ - **Fragment unwrapping.** graphql-client masks a spread fragment's fields on the
85
+ parent, so every call site reads `Petstore::PetFields.new(data.pet).name` and
86
+ carries the parent alongside for the fields the fragment didn't cover. Generated
87
+ structs inline the fragment, so it's `pet.name`; a fragment that is a whole
88
+ selection becomes one shared Ruby type
89
+ ([hoisting](generated_modules.md#a-shared-fragment-is-one-type)).
90
+ - **JSON fixtures and stub helpers.** `graphql: :fake` fabricates a
91
+ schema-correct response and you pin the fields the example is about. Pins are
92
+ schema vocabulary (`"Pet.species" => "DOG"`), so they survive query refactors,
93
+ and they're spellchecked — a typo raises instead of leaving the example green
94
+ against random data ([testing](testing.md)).
95
+
96
+ ## The one behaviour change to plan for
97
+
98
+ **Enum drift is fatal.** A hand-rolled client hands you the raw string, so a value
99
+ the server added after you shipped falls through to whatever your code does with
100
+ an unknown one — commonly a `humanize`. A generated enum refuses instead: casting
101
+ raises and the whole response is lost, naming the value, the enum and the values
102
+ it knows about.
103
+
104
+ That is the right default for an API you own and a real risk for one you don't,
105
+ because it breaks on a day nobody deployed. Before you port a query over an enum
106
+ someone else can extend, register a fallback for it and unknown values land there
107
+ instead — [enums](scalars.md#enums-map-onto-your-own-tenum).
108
+
109
+ ## What it costs
110
+
111
+ | | how it scales | ~40 queries, 25k lines |
112
+ |---|---|---|
113
+ | `.graphql` files and generating | sublinear — the second query costs a tenth of the first | 2–3 days |
114
+ | call sites | linear, and the bulk of it: every `data["x"]["y"]` becomes `x.y`, and every defensive `nil` guard either disappears or turns out to have been wrong | ~1 day per 3–4k lines of consuming code |
115
+ | specs | linear, and pleasant — the fixture-to-pin conversion is the same edit every time | ~2 days |
116
+ | sigs through your own app | the one that decides whether the migration paid off | weeks; a separate project |
117
+
118
+ Coming off graphql-client, run the new one behind the old for the first two
119
+ weeks. The wire compatibility makes that free.
data/docs/scalars.md CHANGED
@@ -63,25 +63,47 @@ reach: the wire spelling (`BigDecimal#to_s` writes `"0.125e2"`, which is not wha
63
63
  any server means by 12.5) and the file to require, so the generated source stands
64
64
  alone.
65
65
 
66
+ Taking `register_scalar("Count", <type>)` as the example:
67
+
66
68
  | Ruby type | cast | serialize | require |
67
69
  |---|---|---|---|
68
70
  | `BigDecimal` | `BigDecimal(v)` | `v.to_s("F")` | `bigdecimal` |
69
- | `Float` | `GraphWeaver::Coerce.float(v)` | — | — |
70
71
  | `Date` | `Date.iso8601(v)` | `v.strftime("%F")` | `date` |
71
- | `Time` | `Time.parse(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
72
+ | `Time` | `Time.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
72
73
  | `DateTime` | `DateTime.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `date` |
74
+ | `Integer` | `GraphWeaver::Coerce.integer(v, "Count")` | — | — |
75
+ | `Float` | `GraphWeaver::Coerce.float(v, "Count")` | — | — |
76
+ | `String` | `GraphWeaver::Coerce.string(v, "Count")` | — | — |
77
+ | `T::Boolean` | `GraphWeaver::Coerce.boolean(v, "Count")` | — | — |
78
+ | `Hash`, `Array` | — | — | — |
73
79
 
74
80
  For a timestamp reach for `Time`; Ruby's own `DateTime` is accepted if you
75
- register it, but never assumed. `Time.parse` is the tolerant reader, and about
76
- the cost of a strict one noise until you are casting thousands of timestamps per
77
- response, where `register_scalar("Timestamp", Time, cast: :iso8601)` is both
78
- cheaper and narrower.
81
+ register it, but never assumed. All three read ISO 8601 and nothing else, which
82
+ is the one spelling a spec-compliant server writes. To take `Time.parse`'s looser
83
+ forms as well — a space instead of the `T`, a zone name, `"Jan 15 2024 10:20"`
84
+ say so: `register_scalar("Timestamp", Time, cast: :parse)`, at about 3× the cost
85
+ per timestamp.
79
86
 
80
87
  **A trailing zero doesn't survive the round trip.** A `BigDecimal` holds the
81
88
  *number*, so `"10.00"` in comes back `"10.0"` — numerically identical, textually
82
89
  different, which matters only where the bytes are: diffing a request body, or
83
90
  hashing one for a signature.
84
91
 
92
+ **The last five rows are the types JSON already holds**, so they write
93
+ themselves — nothing to serialize — and naming one says the scalar *is* that
94
+ Ruby type. The library's own rule for the class then runs in both directions,
95
+ refusing in the scalar's name: `register_scalar("Count", Integer)` reads and
96
+ writes exactly as `Int` coerces a variable, so `5`, `"5"` and `5.0` all arrive
97
+ as `5`, while `"abc"`, `1.5` and `true` raise naming `Count`. `Hash` and `Array`
98
+ pass through untouched — `Coerce` has no rule for either, and anything else
99
+ would be a guess.
100
+
101
+ Coming back that is the **lenient** reading, unlike the spec's own `Int`, which
102
+ [refuses `"1"`](#coming-back--what-from_h-accepts): a compliant server writes an
103
+ `Int` as a JSON number, but a custom scalar is the server's own and may well
104
+ write the number as a string. The registration is you saying "make this an
105
+ `Integer`"; refusing the garbage is what protects you.
106
+
85
107
  ## Registering a class of your own
86
108
 
87
109
  Pass the class and the cast/serialize are **inferred** from it, by probing the
@@ -102,6 +124,15 @@ response unequal, and useless as hash keys, while the `Money` inside them compar
102
124
  fine. Registration warns when it spots one; `alias_method :eql?, :==` plus a
103
125
  `hash` built from the same values is the whole fix.
104
126
 
127
+ **A `cast:` with nothing to write back is half a codec**, and registration says
128
+ so too. `Kernel#Type` is the inference that lands there — it reads the wire and
129
+ pairs with nothing — so a variable of that scalar goes out as whatever `#to_json`
130
+ makes of the object, and a result's `as_json` can't reproduce what the server
131
+ sent. Both are silent, because every object answers `#to_json`. Name a
132
+ `serialize:`, or `serialize: :itself` if the value really does go out as it is. A
133
+ type JSON already holds (a `String`, `Integer`, `Float`, `Hash` or `Array`, or a
134
+ subclass of one) writes itself, and is left alone.
135
+
105
136
  A type defining none of those probes stays pass-through rather than getting
106
137
  wrapped — every object has `#to_s`, so inferring a serializer off it would wrap
107
138
  plain types too. Override explicitly when you need to:
@@ -175,17 +206,18 @@ GraphWeaver.extend_type("Money") { def to_money = ::Money.from_amount(BigDecimal
175
206
  ```
176
207
 
177
208
  **One `serialize:` serves both directions** — the outbound variable *and* a
178
- result's [`as_json`](generated_modules.md#anatomy), which is what makes
179
- `from_h(JSON.parse(x.to_json)) == x` hold. So a scalar that sends an object and
180
- accepts a string can't have both: `as_json` writes the string the cast can't read,
181
- and the JSON round trip raises a `CastError` coming back (the wire itself is fine
182
- in both directions). The same asymmetry decides the [`:fake` pin](testing.md#pins):
183
- a pin stands in for a *result*, so pin what the server **sends**.
209
+ result's [`as_json`](generated_modules.md#anatomy) so a registration's `cast:`
210
+ has to accept what its own `serialize:` writes, and a scalar that sends an object
211
+ while accepting a string can't have both. That law, and where it is checked, is
212
+ [below](#a-cast-must-accept-what-its-own-serialize-writes). The same asymmetry
213
+ decides the [`:fake` pin](testing.md#pins): a pin stands in for a *result*, so pin
214
+ what the server **sends**.
184
215
 
185
216
  Any of this can also be flatly wrong: the *format* a `Money` string has to match
186
- lives in the server's `coerce_input`, which no schema carries, so nothing before a
187
- real request says whether the server wants `"12.50"`, `"12.50 USD"` or the object.
188
- [Send one for real](#what-no-check-can-see).
217
+ lives in the server's `coerce_input`, which no schema carries, so nothing
218
+ `generate` reads says whether the server wants `"12.50"`, `"12.50 USD"` or the
219
+ object — [check it against the schema
220
+ class](#checking-the-half-no-schema-carries), or send one for real.
189
221
 
190
222
  ## Overriding one field
191
223
 
@@ -276,9 +308,10 @@ server writing a non-integer where the spec says integer, so it is refused.
276
308
  | `ID` | any JSON string | a number or `true` — **refused with a hint**: the server didn't quote it |
277
309
  | `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
278
310
  | `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
279
- | `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 |
311
+ | `DateTime`/`Time` (registered as `Time`) | ISO 8601 as `Time.iso8601` reads it: `Z` or an offset, with or without fractional seconds | a bare date, seconds omitted, basic format (`"20240115T102030Z"`), `Time.parse`'s looser forms, an epoch integer |
280
312
  | `BigInt` | the decimal string graphql-ruby writes, past 2⁵³ included; also a JSON integer | `1.5`, `"1.5"`, a non-numeric string, `true` |
281
313
  | an enum | a declared value, as a string | an undeclared value, a non-string |
314
+ | a scalar registered as `Integer`, `Float`, `String` or `T::Boolean` | that Ruby type's rule, *leniently* — a decimal string reads as an `Integer` | what the rule refuses, naming your scalar |
282
315
  | `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
283
316
 
284
317
  A refusal is a [`GraphWeaver::CastError`](errors.md) naming the field and the
@@ -297,14 +330,14 @@ sig is `.checked(:never)`).
297
330
 
298
331
  | scalar | kwarg is typed | also accepts, at runtime | on the wire |
299
332
  |---|---|---|---|
300
- | `Int` | `Integer` | a decimal string, a whole `Float` | the integer |
301
- | `Float` | `Float` | a decimal string, an `Integer` | the float |
333
+ | `Int` | `Integer` | a decimal string, a whole real `Numeric` — `2.0`, `BigDecimal("2")` | the integer |
334
+ | `Float` | `Float` | a decimal string, a real `Numeric` — an `Integer`, a `BigDecimal`, a `Rational` | the float |
302
335
  | `String` | `String` | nothing | the string |
303
336
  | `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
304
337
  | `Boolean` | `true`/`false` | nothing | the boolean |
305
338
  | `Date` | `Date` | an ISO-8601 string | `"2024-01-15"` |
306
- | `Time` | `Time` | a string `Time.parse` takes, a `DateTime`, `Time.zone.now` | ISO 8601, with microseconds when the value carries a fraction |
307
- | `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
339
+ | `Time` | `Time` | an ISO 8601 string, a `DateTime`, `Time.zone.now` | ISO 8601, with microseconds when the value carries a fraction |
340
+ | `BigInt` | `Integer` | a decimal string, a whole real `Numeric` | the decimal string, which is what the server writes |
308
341
  | an enum | the member **or** its wire value | — | the wire value |
309
342
  | an input object | the struct **or** a Hash | — | the wire hash |
310
343
  | a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
@@ -321,6 +354,12 @@ no string**, because every rule for reading `"0"`, `"off"`, `"no"` is somebody's
321
354
  convention. **A `Date` and a `Time` are not each other**, as above; what *is*
322
355
  accepted for a `Time` is anything that already is one.
323
356
 
357
+ A **real `Numeric`** is the number it prints as, so a `BigDecimal` off a decimal
358
+ column needs no conversion at the call site: it reaches a `Float` through `to_f`,
359
+ at Float's own precision, and an `Int` only when the value is whole —
360
+ `BigDecimal("2")` is `2` and `BigDecimal("2.5")` is refused, exactly as `2.5` is.
361
+ `Complex` is the one `Numeric` that is not real, and no number rule takes it.
362
+
324
363
  Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
325
364
  the operation and the value — `$count of Compute: expected an Int, got "lots"` —
326
365
  which is the same [422 rescue point](errors.md) as a bad input-object field. The
@@ -354,7 +393,7 @@ short-circuits — so a coercer written like the examples above raises
354
393
  `NoMethodError` on nil. Guard it, or `:in_process` will show it to you as a
355
394
  `ServerError`.
356
395
 
357
- ## What no check can see
396
+ ## Checking the half no schema carries
358
397
 
359
398
  A custom scalar has two definitions that have to agree: the server's
360
399
  `coerce_input`/`coerce_result`, and your `register_scalar`. **No schema carries the
@@ -372,22 +411,56 @@ No `CastError`, no warning — just totals quietly wrong past the seventh
372
411
  significant figure, which is the precision a string-valued `Decimal` exists to
373
412
  protect.
374
413
 
375
- The check is a request that runs the real coercers: one `graphql: :in_process`
376
- example per registered scalar, round-tripping a value through the schema class.
414
+ **Where the server runs in-process, both halves are callable, and that is the
415
+ check.** One line, for every scalar at once:
377
416
 
378
417
  ```ruby
379
- it "round-trips a Money through the real server", graphql: :in_process do
380
- price = Money.from_amount(BigDecimal("12.50"), "EUR")
381
- expect(EchoPriceQuery.execute!(price:).echo_price).to eq price
418
+ it "agrees with the server about every scalar" do
419
+ GraphWeaver::Testing.check_scalars!(Catalog::Schema)
382
420
  end
383
421
  ```
384
422
 
385
- Four lines, and it fails the moment either side moves. **`graphql: :fake` cannot
386
- stand in for it**: a fake fabricates from your *client* registration alone, so it
387
- hands back a value the real server would never send and accepts one the real
388
- server would reject. It is shape-correct, never rule-correct. A
389
- [cassette](cassettes.md) recorded against `:in_process` carries the rules to a
390
- suite that can't boot the schema class.
423
+ Per scalar the schema declares and your app registered, it fabricates a value the
424
+ way [`:fake`](testing.md) does, casts it, sends it back out through `serialize:`,
425
+ through the server's `coerce_input` and `coerce_result`, and back through `cast:`.
426
+ It raises naming every scalar that disagreed and which way:
427
+
428
+ ```
429
+ 2 scalar(s) disagree with Catalog::Schema:
430
+ Money: the server refused "12.5", the wire form serialize: writes (expected "12.50 USD")
431
+ Decimal: round-trips lossily — sent 123456789.123456789, got back 123456789.1234567
432
+ ```
433
+
434
+ The fabricated value is all it has to work with, so pin the one that matters:
435
+ `config.overrides = { "Decimal" => "123456789.123456789" }` is how the precision
436
+ case gets exercised at all — two decimal places always survive a Float. Pass the
437
+ schema **class**; a dump's scalars pass values through, so against one this checks
438
+ only that a registration's `cast:` accepts what its own `serialize:` writes, which
439
+ is a different question (see below).
440
+
441
+ For a **remote** server the limit stands: nothing before a real request can say.
442
+ Send one — a `graphql: :in_process` example against the same schema class if your
443
+ app has one, otherwise a [cassette](cassettes.md) recorded against the real
444
+ endpoint, which carries the server's rules to a suite that can't reach it.
445
+ **`graphql: :fake` cannot stand in for either**: a fake fabricates from your
446
+ *client* registration alone, so it hands back a value the real server would never
447
+ send and accepts one the real server would reject. It is shape-correct, never
448
+ rule-correct.
449
+
450
+ ### A cast must accept what its own serialize writes
451
+
452
+ One `serialize:` serves both directions — the outbound variable and a result's
453
+ [`as_json`](generated_modules.md#anatomy) — so a registration has a law to keep:
454
+ **its `cast:` must accept what its `serialize:` writes.** That is what makes
455
+ `from_h(JSON.parse(x.to_json)) == x` hold, and it is the innermost leg of
456
+ `check_scalars!` above, which is where it is checked.
457
+
458
+ A server whose `coerce_result` writes one shape and whose `coerce_input` accepts
459
+ another can't be served by one `serialize:`, so don't try: write the **result**
460
+ form, the one `cast:` reads, and have the server's `coerce_input` accept that too.
461
+ If it can't, the asymmetry is the server's to fix — a second registration keyword
462
+ for the result form would put a knob where a law belongs, and `as_json` would
463
+ still have no way to choose between them.
391
464
 
392
465
  ## Enums: map onto your own T::Enum
393
466
 
@@ -432,12 +505,65 @@ Two safety properties do the real work:
432
505
  - **`fallback:` for forward-compat**: `fallback: PetKind::Unknown` makes *casting*
433
506
  absorb wire values the server added after you generated, so responses keep
434
507
  flowing instead of raising. Inputs stay strict either way: a typo'd input is your
435
- bug, not drift. A union or interface absorbs the same drift with no registration
436
- a member added upstream lands in the catch-all `Other` its dispatch always
437
- carries ([generated modules](generated_modules.md#abstract-types)).
508
+ bug, not drift. You don't need an enum of your own for that:
509
+ `GraphWeaver.register_enum("Species", fallback: true)` gives the *generated* enum
510
+ the same forward-compat, [below](#values-the-server-hasnt-told-you-about-yet).
438
511
 
439
512
  The translation tables are emitted into the generated source (`SPECIES_FROM_WIRE` /
440
513
  `SPECIES_TO_WIRE`) — reviewable in the diff, no runtime registry.
441
514
 
515
+ ### Values the server hasn't told you about yet
516
+
517
+ A server adding an enum value is a deploy you weren't part of, and by default the
518
+ next response carrying it raises. `fallback: true` says take it anyway:
519
+
520
+ ```ruby
521
+ GraphWeaver.register_enum("Species", fallback: true)
522
+ ```
523
+
524
+ The generated `Species` gains one member, `Other`, and every wire value the schema
525
+ doesn't declare casts to it — the leniency a union or interface already has for
526
+ free, where a member added upstream lands in the catch-all `Other` its dispatch
527
+ always carries ([generated modules](generated_modules.md#abstract-types)).
528
+
529
+ ```ruby
530
+ pet.species # => GraphQLTypes::Species::Other, for "AXOLOTL"
531
+ ```
532
+
533
+ Three things follow from that, and they are the whole rule:
534
+
535
+ - **`Other` doesn't carry the value it absorbed.** A `T::Enum` member is a
536
+ singleton, so there is nowhere to put it; the cast writes one debug line
537
+ (`GraphQLTypes::Species absorbed "AXOLOTL" into Other`) and that is the record
538
+ — so a presenter humanising the server's spelling can't, and falls back to a
539
+ label of its own.
540
+ - **Inputs stay strict.** No wire value means `Other`, so a variable carrying it
541
+ is refused rather than sent. Everything else is unchanged: a typo'd input is
542
+ your bug, not drift.
543
+ - **A schema that already declares `OTHER` keeps it.** The declared value stays
544
+ `Other`, and the fallback member is `Other2` — the same rule a union's
545
+ catch-all struct follows when a member is already named `Other`.
546
+
547
+ It rides along with `alias:` on the one registration, and like every registration
548
+ it belongs to one graph.
549
+
550
+ ### Two spellings, one value
551
+
552
+ A schema mid-rename declares both `LEGACY_MODE` and `legacy_mode` so old clients
553
+ keep working. `alias:` says they are one value — both spellings cast, and the
554
+ target is what goes back on the wire:
555
+
556
+ ```ruby
557
+ GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })
558
+ ```
559
+
560
+ That is the whole registration when there is no enum of your own to map onto; it
561
+ rides along with one when there is, where it also settles which spelling a
562
+ member serializes to — inference is case/underscore-insensitive, so a rename
563
+ pair lands on a single member. Either way generation refuses rather than pick:
564
+ two values that name one Ruby constant, or that map onto one member, are
565
+ ambiguous until you say. Delete the alias when the server drops the old
566
+ spelling.
567
+
442
568
  Decorating a generated *struct* with your own methods is the sibling API —
443
569
  `extend_type`, in [generated modules](generated_modules.md#type-helpers).
data/docs/testing.md CHANGED
@@ -64,6 +64,13 @@ client for anything, so a
64
64
  [client-side `InputError`](errors.md#what-an-inputerror-says-without-reading-english)
65
65
  raises the same way under every tag and under none.)
66
66
 
67
+ A fake also can't reach a **custom scalar's** rules, for the same reason: it
68
+ fabricates from your own [registration](scalars.md), so the round trip agrees
69
+ with itself whatever the server thinks. Where the server is a schema class,
70
+ `GraphWeaver::Testing.check_scalars!(Catalog::Schema)` runs both halves against
71
+ each other and names every scalar that disagrees
72
+ ([how](scalars.md#checking-the-half-no-schema-carries)).
73
+
67
74
  Everything here is a *client* — the one interface queries run through (the
68
75
  contract is in [transports](transports.md)) — so fakes, the router, failures and
69
76
  cassettes work outside rspec too (`require "graph_weaver/testing"`, never from
@@ -403,8 +410,10 @@ middleware wrote, the headers it sent, the retry it does on a 500, and that
403
410
  `from_h` reads real JSON off a socket. What it can't tell you is whether your
404
411
  `cast:` agrees with the real server: the fabricated bytes are written to match
405
412
  your own [scalar registrations](scalars.md), so the round trip agrees with
406
- itself. Put the live schema class behind the wire for that, or pin a real
407
- response with a [cassette](cassettes.md).
413
+ itself. Put the live schema class behind the wire for that
414
+ [`check_scalars!`](scalars.md#checking-the-half-no-schema-carries) asks the same
415
+ question of one directly — or pin a real response with a
416
+ [cassette](cassettes.md).
408
417
 
409
418
  `GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
410
419
  satisfies the [client contract](transports.md), so mount it yourself
@@ -414,7 +423,9 @@ time** — answering one means setting the client's context for the length of th
414
423
  dispatch, so the identity a request asked for is the identity it gets, whatever
415
424
  else is in flight. The lock is the client's own, so it holds however the endpoint
416
425
  is mounted. A `context:` hash is served **concurrently**: nothing writes it, so
417
- there is nothing to serialize.
426
+ there is nothing to serialize. Either way a query reads its context once, so
427
+ every subgraph it hops through runs as the identity it started with, even if
428
+ the router's context is reassigned while it is in flight.
418
429
 
419
430
  ### Making the served endpoint fail
420
431
 
@@ -635,6 +646,16 @@ the fly? The client in play exposes it as `GraphWeaver.client.schema`, and
635
646
  `GraphWeaver::Testing.config.schema` reads back what `config.schema =` set,
636
647
  falling back to the committed dump.
637
648
 
649
+ A query built that way is worth an assertion of its own:
650
+
651
+ ```ruby
652
+ expect(GraphWeaver.client.check_query(source)).to be_empty
653
+ ```
654
+
655
+ [`check_query`](getting_started.md#5-verify-in-ci) answers with the
656
+ errors rather than a boolean, so a failure prints what is wrong and where; a
657
+ predicate would only say the query isn't valid.
658
+
638
659
  ## Test-only generated modules
639
660
 
640
661
  They don't have to live in `app/` — `generated_paths` is an appendable list, so a
data/docs/upgrading.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # Upgrading
2
2
 
3
3
  [Regenerate](#regenerate-on-every-upgrade) whichever version you're on, then read
4
- the one section that is yours: from [0.7.1](#upgrading-from-071), from
5
- [0.7.0](#upgrading-from-070) or from [0.6.1](#upgrading-from-061). Coming from
6
- 0.6.0 or older, the path is that version's own upgrade notes — read them at the
7
- tag they shipped under (`git show v0.7.1:docs/upgrading.md`), then this page from
8
- 0.6.1 down.
4
+ the one section that is yours: from [0.7.4](#upgrading-from-074), from
5
+ [0.7.3](#upgrading-from-073), from
6
+ [0.7.1](#upgrading-from-071), from [0.7.0](#upgrading-from-070) or from
7
+ [0.6.1](#upgrading-from-061). Coming from 0.6.0 or older, the path is that
8
+ version's own upgrade notes — read them at the tag they shipped under
9
+ (`git show v0.7.1:docs/upgrading.md`), then this page from 0.6.1 down.
9
10
 
10
11
  ## Regenerate on every upgrade
11
12
 
@@ -23,6 +24,51 @@ That's the reminder working, not a false alarm. Generation is deterministic, so
23
24
  the diff is exactly what the new version emits differently and nothing else —
24
25
  worth reading rather than rubber-stamping.
25
26
 
27
+ ## Upgrading from 0.7.4
28
+
29
+ Read the left column and skip what isn't yours; the
30
+ [changelog](../CHANGELOG.md) says why each one moved.
31
+
32
+ | applies if you… | what changed |
33
+ |---|---|
34
+ | spread a shared fragment as a whole field — `grep -rln '{ *\.\.\.[A-Za-z]* *}' app/graphql/queries` | on an object type it now [hoists](generated_modules.md#a-shared-fragment-is-one-type) into `GraphQLTypes` under the fragment's name, as it already did on a union. **App code naming the struct it used to produce (`PetQuery::Result::Pet`) moves to `GraphQLTypes::PetFields`** — one type instead of one per query, so a `T.type_alias { T.any(…) }` written to paper over that goes away. Regenerating and running `srb tc` finds every site |
35
+ | register an `abstract!` mixin — `grep -rn 'extend_type' app config lib`, then check which of those modules call `abstract!` | **generation refuses** where a query doesn't select everything the mixin declares, instead of leaving it for your `srb tc`. The message names the struct, the members and the fixes: select them in that query, or select the type through one shared fragment so a single hoisted struct answers for every query |
36
+ | keep a hand-maintained schema dump on a graph that also names a `client` — `rake graph_weaver:graphs` lists both per graph | `rake graph_weaver:schema:refresh` **rewrites that dump from the client** where it used to refuse and exit 1. If the file is the source of truth, don't run `refresh` for that graph, or drop the client from it |
37
+ | call `Codegen#used_union_names` | it is `used_fragment_names` — the set it reports now includes hoisted object fragments |
38
+
39
+ Then regenerate, and the gate:
40
+
41
+ ```sh
42
+ rake graph_weaver:generate
43
+ rake graph_weaver:verify
44
+ ```
45
+
46
+ ## Upgrading from 0.7.3
47
+
48
+ A patch release. One change can reach an app that never touched it — how a
49
+ timestamp is read off the wire — one more reaches an app that maps an enum onto
50
+ its own, and the rest is in the regenerate. Read the left column and skip what
51
+ isn't yours; the [changelog](../CHANGELOG.md) says why each one moved.
52
+
53
+ | applies if you… | what changed |
54
+ |---|---|
55
+ | register a timestamp scalar — `grep -rn 'register_scalar.*Time' app config lib`, which finds `Time` and `DateTime` alike | it reads the wire with `Time.iso8601` rather than `Time.parse`, so a spelling graphql-ruby's `coerce_result` never writes is refused: a bare date, seconds omitted, basic format (`"20240115T102030Z"`), a space and a zone name, `"Jan 15 2024 10:20"`. Same for a variable going out. **A server that writes one of those starts failing the cast** — `cast: :parse` keeps the tolerant reader: `register_scalar("DateTime", Time, cast: :parse)` |
56
+ | have `DateTime` or `ISO8601DateTime` in your schema and register neither | same change: the gem registers both as `Time`, so the row above is yours without a line of your own |
57
+ | map an enum onto your own `T::Enum` — `grep -rn 'register_enum' app config lib` — where the schema declares one value under two spellings (`LEGACY_MODE` beside `legacy_mode`) | inference is case/underscore-insensitive, so both landed on one member and the wire table silently sent whichever sorted last: the deprecated one. **Generation refuses now**, naming the pair — `alias: { "legacy_mode" => "LEGACY_MODE" }` says which spelling goes out |
58
+
59
+ Then regenerate, and the gate:
60
+
61
+ ```sh
62
+ # the timestamp cast is emitted, the input-struct FIELDS table grew a column,
63
+ # and a graph with a block-form extend_type gains a type_helpers.rbi — commit
64
+ # it with the rest
65
+ rake graph_weaver:generate
66
+
67
+ # red while any checked-in file is still what 0.7.3 wrote; 0.7.3's input
68
+ # structs raise `ArgumentError: missing keyword: :type` at load until then
69
+ rake graph_weaver:verify
70
+ ```
71
+
26
72
  ## Upgrading from 0.7.1
27
73
 
28
74
  A patch release, and one change with a shape: a generated module no longer
@@ -1,7 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- # Generated by GraphWeaver 0.7.3 — do not edit.
4
+ # Generated by GraphWeaver 0.7.4 — do not edit.
5
5
 
6
6
  module StarMutation
7
7
  extend T::Sig
@@ -49,6 +49,14 @@ module StarMutation
49
49
  rescue StandardError => e
50
50
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
51
51
  end
52
+
53
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
54
+ def as_json(*_options)
55
+ {
56
+ "stargazerCount" => stargazer_count,
57
+ "viewerHasStarred" => viewer_has_starred,
58
+ }
59
+ end
52
60
  end
53
61
 
54
62
  const :starrable, T.nilable(Starrable)
@@ -63,6 +71,13 @@ module StarMutation
63
71
  rescue StandardError => e
64
72
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
65
73
  end
74
+
75
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
76
+ def as_json(*_options)
77
+ {
78
+ "starrable" => starrable&.then { |v1| v1.as_json },
79
+ }
80
+ end
66
81
  end
67
82
 
68
83
  const :add_star, T.nilable(AddStar)
@@ -77,6 +92,13 @@ module StarMutation
77
92
  rescue StandardError => e
78
93
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
79
94
  end
95
+
96
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
97
+ def as_json(*_options)
98
+ {
99
+ "addStar" => add_star&.then { |v1| v1.as_json },
100
+ }
101
+ end
80
102
  end
81
103
 
82
104
  # client — see GraphWeaver::QueryModule
@@ -92,7 +114,7 @@ module StarMutation
92
114
  sig { params(id: String, client: T.untyped).returns(GraphWeaver::Response[Result]).checked(:never) }
93
115
  def self.execute(id:, client: nil)
94
116
  variables = {
95
- "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v) },
117
+ "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v, "ID") },
96
118
  }
97
119
 
98
120
  from_response(dispatch(variables, client:))