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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +176 -22
- data/docs/getting_started.md +174 -14
- data/docs/i18n.md +4 -4
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +161 -35
- data/docs/testing.md +24 -3
- data/docs/upgrading.md +51 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +35 -16
- data/lib/graph_weaver/codegen/enum_type.rb +149 -19
- data/lib/graph_weaver/codegen/nodes.rb +72 -37
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +259 -106
- data/lib/graph_weaver/coerce.rb +25 -6
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +23 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +31 -10
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +12 -4
- data/lib/graph_weaver/internal.rb +84 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +101 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +80 -74
- 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.
|
|
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.
|
|
76
|
-
the
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
[
|
|
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`) |
|
|
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 `
|
|
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` |
|
|
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
|
-
##
|
|
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
|
-
|
|
376
|
-
|
|
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 "
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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.
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
407
|
-
|
|
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.
|
|
5
|
-
[0.7.
|
|
6
|
-
0.
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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:))
|