graph_weaver 0.5.1 → 0.6.1
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/CHANGELOG.md +432 -0
- data/Gemfile.lock +19 -19
- data/README.md +75 -54
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +34 -4
- data/docs/real_world.md +4 -4
- data/docs/scalars.md +206 -46
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +279 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +184 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +203 -119
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/scalars.md
CHANGED
|
@@ -2,80 +2,236 @@
|
|
|
2
2
|
|
|
3
3
|
Teach the generator how a GraphQL custom scalar deserializes into a rich
|
|
4
4
|
Ruby object (and serializes back when used as a variable). A field typed
|
|
5
|
-
`
|
|
6
|
-
`
|
|
5
|
+
`Decimal` then generates `const :price, T.nilable(BigDecimal)` and casts with
|
|
6
|
+
`BigDecimal(...)` inline — no runtime reflection:
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
|
-
GraphWeaver.register_scalar("
|
|
9
|
+
GraphWeaver.register_scalar("Decimal", BigDecimal)
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Two arguments: the scalar's name in your schema, and the Ruby type it means.
|
|
13
|
+
The second is the only part the library can't work out — how a wire value
|
|
14
|
+
becomes a `BigDecimal`, how one goes back on the wire, and the
|
|
15
|
+
`require "bigdecimal"` the generated file needs are all inferred.
|
|
16
|
+
|
|
12
17
|
Registration is global and codegen-time: `rake graph_weaver:generate` reads the
|
|
13
18
|
same registry an initializer writes, so register before you generate.
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
## Already registered
|
|
21
|
+
|
|
22
|
+
These names need no registration. graphql-ruby ships all but `DateTime` as its
|
|
23
|
+
own scalars, and `DateTime` is what GitHub, Shopify and most hand-written
|
|
24
|
+
schemas call an ISO 8601 timestamp.
|
|
25
|
+
|
|
26
|
+
| scalar | Ruby type | on the wire |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `ID` | `String` | the string (an `Integer` input is accepted) |
|
|
29
|
+
| `String` | `String` | the string |
|
|
30
|
+
| `Int` | `Integer` | a JSON integer |
|
|
31
|
+
| `Float` | `Float` | any JSON number |
|
|
32
|
+
| `Boolean` | `T::Boolean` | the boolean |
|
|
33
|
+
| `Date`, `ISO8601Date` | `Date` | `"2024-01-15"` |
|
|
34
|
+
| `DateTime`, `ISO8601DateTime` | `Time` | `"2024-01-15T10:20:30Z"` |
|
|
35
|
+
| `BigInt` | `Integer` | the decimal string graphql-ruby writes; a JSON number is read too |
|
|
36
|
+
| `JSON` | `T.untyped` | whatever it is, untouched |
|
|
37
|
+
|
|
38
|
+
A date stays a `Date` and a timestamp a `Time`, deliberately: casting a date to
|
|
39
|
+
`Time` invents a midnight the server never sent. A schema that means something
|
|
40
|
+
else by one of these names fails loudly — the cast raises, naming the field —
|
|
41
|
+
and one `register_scalar` overrides it, like any other entry. Names that are
|
|
42
|
+
*not* a convention (`Timestamp`, `UUID`, `URL`, `Decimal`, `Money`) are left to
|
|
43
|
+
you, because guessing at one would be worse than asking.
|
|
44
|
+
|
|
45
|
+
## Registering a stdlib type
|
|
46
|
+
|
|
47
|
+
Name the class and stop. What the library supplies is the part inference can't
|
|
48
|
+
reach: the wire spelling (`BigDecimal#to_s` writes `"0.125e2"`, which is not
|
|
49
|
+
what any server means by 12.5, and `Date.parse` reads far more than the ISO
|
|
50
|
+
8601 a `Date` scalar carries) and the file to require, so the generated source
|
|
51
|
+
stands alone.
|
|
52
|
+
|
|
53
|
+
| Ruby type | cast | serialize | require |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `BigDecimal` | `BigDecimal(v)` | `v.to_s("F")` | `bigdecimal` |
|
|
56
|
+
| `Date` | `Date.iso8601(v)` | `v.iso8601` | `date` |
|
|
57
|
+
| `Time` | `Time.parse(v)` | `v.iso8601` | `time` |
|
|
58
|
+
| `DateTime` | `DateTime.iso8601(v)` | `v.iso8601` | `date` |
|
|
59
|
+
|
|
60
|
+
For a timestamp, reach for `Time`; Ruby's own `DateTime` is accepted if you
|
|
61
|
+
register it, but never assumed. `BigDecimal(v)` is Ruby's own reader, so
|
|
62
|
+
it takes what Ruby takes — `"12.5"`, `"1e3"`, a JSON number — and refuses
|
|
63
|
+
`"abc"` or `"$12.50"`, naming the field or the variable.
|
|
64
|
+
|
|
65
|
+
## Registering a class of your own
|
|
66
|
+
|
|
67
|
+
Pass the class and the cast/serialize are **inferred** from it, by probing the
|
|
68
|
+
deserialize side and pairing its serializer:
|
|
69
|
+
|
|
70
|
+
| the class defines | cast | serialize |
|
|
71
|
+
|-------------------|---------------|----------------|
|
|
72
|
+
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
73
|
+
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
74
|
+
| `Kernel#Type` | `Type(v)` | — |
|
|
75
|
+
|
|
76
|
+
so a value object with a `.parse` needs nothing more:
|
|
18
77
|
|
|
19
78
|
```ruby
|
|
20
|
-
GraphWeaver.register_scalar("
|
|
21
|
-
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
79
|
+
GraphWeaver.register_scalar("Money", Money)
|
|
22
80
|
```
|
|
23
81
|
|
|
24
|
-
A
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
30
|
-
|
|
31
|
-
| the class defines | cast | serialize |
|
|
32
|
-
|-------------------|---------------|----------------|
|
|
33
|
-
| `.parse` | `Type.parse(v)` | `v.to_s` |
|
|
34
|
-
| `.load` | `Type.load(v)` | `Type.dump(v)` |
|
|
35
|
-
|
|
36
|
-
so the common case needs nothing more. A type defining neither `.parse` nor
|
|
37
|
-
`.load` stays pass-through rather than getting wrapped. Override explicitly when
|
|
38
|
-
you need to:
|
|
82
|
+
A type defining none of those stays pass-through rather than getting wrapped —
|
|
83
|
+
every object has `#to_s`, so inferring a serializer off it would wrap plain
|
|
84
|
+
types too. Override explicitly when you need to:
|
|
39
85
|
|
|
40
86
|
- a `Symbol` method name, nothing to misspell: `cast: :load` → `Money.load(expr)`,
|
|
41
87
|
`serialize: :to_json` → `expr.to_json`
|
|
88
|
+
- an `Array`, for a method with arguments: `serialize: [:to_s, "F"]` → `expr.to_s("F")`
|
|
42
89
|
- a `Proc` for anything a method name can't express: `cast: ->(expr) { "Money.new(#{expr})" }`
|
|
43
90
|
- `:itself` to force pass-through, opting out of inference (rare)
|
|
44
91
|
|
|
45
|
-
|
|
92
|
+
The type also accepts a plain string (`"Money"`) when you'd rather not
|
|
46
93
|
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
|
|
94
|
+
`require`s atop the generated source so the cast/type resolve. When the type is
|
|
48
95
|
a real class (so the runtime is loaded), each path is also `require`d at
|
|
49
96
|
registration — a typo fails now, not in the generated file.
|
|
50
97
|
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
## Overriding one field
|
|
99
|
+
|
|
100
|
+
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
101
|
+
that one field — so the same scalar can deserialize as different Ruby types
|
|
102
|
+
across fields:
|
|
53
103
|
|
|
54
104
|
```ruby
|
|
55
|
-
GraphWeaver.register_scalar("
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
GraphWeaver.register_scalar("Timestamp", Time) # the default, everywhere
|
|
106
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A field override wins over the scalar-name registration — which is also how two
|
|
110
|
+
servers that disagree about a `DateTime` coexist in one process.
|
|
111
|
+
|
|
112
|
+
Registrations are validated against the schema you generate against, and only
|
|
113
|
+
what that schema can **disprove** fails generation: a name it declares as
|
|
114
|
+
something else (`register_scalar("Species")` where `Species` is an enum), or a
|
|
115
|
+
coordinate whose field it declares as a composite. A name it simply can't match
|
|
116
|
+
only warns — one registry serves a whole graph, so that name may belong to the
|
|
117
|
+
subgraph next door (see
|
|
118
|
+
[federation](federation.md#generating-for-a-federated-graph)).
|
|
119
|
+
|
|
120
|
+
The testing harness can't invent a wire value for a scalar registered as your
|
|
121
|
+
own class — only `Money.parse` knows what it accepts — so it refuses rather than
|
|
122
|
+
guess. Say it in test config, where that answer belongs: a pin for the type,
|
|
123
|
+
`GraphWeaver::Testing.config.overrides = { "Money" => "12.00" }`, or per example
|
|
124
|
+
([testing → pins](testing.md#pins)). A scalar registered as one of the types
|
|
125
|
+
above — `BigDecimal`, `Time`, `Date`, `Integer`, `Float`, `String`,
|
|
126
|
+
`T::Boolean` — needs nothing.
|
|
127
|
+
|
|
128
|
+
A registration whose type is a class **JSON can't parse into**, with nothing to
|
|
129
|
+
build one, is refused where a query reads that scalar back: the prop would be
|
|
130
|
+
unsatisfiable for every response, and finding that out at runtime is worse.
|
|
131
|
+
Generation names the field:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
register_scalar("Money", Wallet) has no cast, so nothing builds a Wallet
|
|
135
|
+
out of the JSON at Product.price — give it one ...
|
|
59
136
|
```
|
|
60
137
|
|
|
61
|
-
|
|
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.
|
|
138
|
+
A registration used only for a variable is untouched: nothing casts it.
|
|
64
139
|
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
140
|
+
`cast:` is also what a *variable* of this scalar coerces through, so the same
|
|
141
|
+
registration gets you both directions with nothing to switch on:
|
|
71
142
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
143
|
+
```ruby
|
|
144
|
+
GraphWeaver.register_scalar("Money", Money)
|
|
145
|
+
StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
|
|
146
|
+
StoreQuery.execute(budget: Money.new(1200)) # already a Money — passed straight through
|
|
147
|
+
```
|
|
75
148
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
149
|
+
The kwarg is still typed `Money`, not `T.any(Money, String)`: `execute`'s sig
|
|
150
|
+
stays as narrow as the schema and the conversion happens in its body (see
|
|
151
|
+
[typed variables](generated_modules.md#variables-become-typed-kwargs)). So
|
|
152
|
+
`budget: "12.00"` written literally in a `# typed:` file is still an `srb tc`
|
|
153
|
+
error — as it should be, since you have a `Money` right there — while
|
|
154
|
+
`budget: params[:budget]` typechecks and converts.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
## What the wire carries
|
|
158
|
+
|
|
159
|
+
The rule is one sentence: **generated code takes every JSON spelling a
|
|
160
|
+
spec-compliant server may write, and refuses the rest.** The tables below are
|
|
161
|
+
the whole of it, and [`bin/round-trip`](../bin/round-trip) fuzzes both
|
|
162
|
+
directions against them — the accepted spellings as real values, the refused
|
|
163
|
+
ones under `--hostile`, where generated code has to name what it turned down.
|
|
164
|
+
|
|
165
|
+
The one place "spec-compliant" is doing real work is `Float`. JSON has a single
|
|
166
|
+
number type and encoders write the shortest form, so `1.0` reaches Ruby as `1`
|
|
167
|
+
from graphql-js and from Go. Nothing does the reverse: `2.0` for an `Int` is the
|
|
168
|
+
server writing a non-integer where the spec says integer, so it is refused.
|
|
169
|
+
|
|
170
|
+
### Coming back — what `from_h` accepts
|
|
171
|
+
|
|
172
|
+
| scalar | accepted | refused |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `Int` | any JSON integer, including past 2³¹ and 2⁵³ (lossless in Ruby) | `2.0`, `1.5`, `"1"`, `true` |
|
|
175
|
+
| `Float` | any JSON number, `3` and `-0.0` and `1e308` included; also a decimal string | a non-numeric string, `true`, a list/object |
|
|
176
|
+
| `String` | any JSON string — empty, unicode, newlines, control characters | a number, `true`, a list/object |
|
|
177
|
+
| `ID` | any JSON string | a number or `true` — **refused with a hint**: the server didn't quote it |
|
|
178
|
+
| `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
|
|
179
|
+
| `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
|
|
180
|
+
| `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 |
|
|
181
|
+
| `BigInt` | the decimal string graphql-ruby writes, past 2⁵³ included; also a JSON integer | `1.5`, `"1.5"`, a non-numeric string, `true` |
|
|
182
|
+
| an enum | a declared value, as a string | an undeclared value, a non-string |
|
|
183
|
+
| `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
|
|
184
|
+
|
|
185
|
+
A refusal is a [`GraphWeaver::TypeError`](errors.md) naming the field and the
|
|
186
|
+
generated struct (which names the query). Two refusals carry advice rather than
|
|
187
|
+
only sorbet's words: an unquoted `ID`, and a registration with no cast (above).
|
|
188
|
+
|
|
189
|
+
Numeric strings — here, and in the going-out table below — are read as a wire
|
|
190
|
+
format, not as Ruby source: `"010"` is ten, and `"0x1f"` and `"1_0"` are
|
|
191
|
+
refused. `Kernel#Integer` and `Kernel#Float` accept all three as literals,
|
|
192
|
+
which would let a zero-padded form field silently mean something else.
|
|
193
|
+
|
|
194
|
+
### Going out — what a variable kwarg accepts
|
|
195
|
+
|
|
196
|
+
The kwarg's **type** is what `srb tc` holds a call site to, and it is exactly
|
|
197
|
+
what the schema says. The **value** reaching `execute` at runtime is coerced,
|
|
198
|
+
because a Rails param is a String whatever the sig says (see
|
|
199
|
+
[typed variables](generated_modules.md#variables-become-typed-kwargs) for why
|
|
200
|
+
the sig is `.checked(:never)`).
|
|
201
|
+
|
|
202
|
+
| scalar | kwarg is typed | also accepts, at runtime | on the wire |
|
|
203
|
+
|---|---|---|---|
|
|
204
|
+
| `Int` | `Integer` | a decimal string, a whole `Float` | the integer |
|
|
205
|
+
| `Float` | `Float` | a decimal string, an `Integer` | the float |
|
|
206
|
+
| `String` | `String` | nothing | the string |
|
|
207
|
+
| `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
|
|
208
|
+
| `Boolean` | `true`/`false` | nothing | the boolean |
|
|
209
|
+
| `Date` | `Date` | an ISO-8601 string | `iso8601` |
|
|
210
|
+
| `Time` | `Time` | a string `Time.parse` takes | `iso8601` |
|
|
211
|
+
| `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
|
|
212
|
+
| an enum | the member **or** its wire value | — | the wire value |
|
|
213
|
+
| an input object | the struct **or** a Hash | — | the wire hash |
|
|
214
|
+
| a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
|
|
215
|
+
| `JSON`, or unregistered | `T.untyped` | anything | straight through |
|
|
216
|
+
|
|
217
|
+
Two rows are judgment calls worth stating. **`ID` takes an `Integer`** because
|
|
218
|
+
the GraphQL spec says an ID serializes as a string but accepts an integer input,
|
|
219
|
+
and `execute(id: user.id)` off a model is the everyday call; `String` gets no
|
|
220
|
+
such license, since an `Integer` where a `String` belongs is more often a bug
|
|
221
|
+
than a spelling. **`Boolean` takes no string** — Ruby has no `Kernel#Boolean`,
|
|
222
|
+
so every rule for reading `"0"`, `"off"`, `"no"` is somebody's convention, and
|
|
223
|
+
the library will not pick one for you; convert at the call site.
|
|
224
|
+
|
|
225
|
+
Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
|
|
226
|
+
the operation and the value — `$count of Compute: expected an Int, got "lots"`
|
|
227
|
+
— which is the same [422 rescue point](errors.md) as a bad input-object field.
|
|
228
|
+
Input-object fields go through this table too, so `{first: "20"}` inside a
|
|
229
|
+
filter hash reads the same as `first: "20"` as a kwarg.
|
|
230
|
+
|
|
231
|
+
`GraphWeaver.reset_registrations!` is the clean slate between tests, or between
|
|
232
|
+
generations for different schemas: built-in scalars restored, enum mappings and
|
|
233
|
+
type helpers dropped. To reset one registry rather than all of them,
|
|
234
|
+
`GraphWeaver::Codegen` has the pieces —
|
|
79
235
|
`reset_scalars!` (restore the built-ins), `clear_scalars!` (empty the registry
|
|
80
236
|
entirely), `reset_enums!`, `reset_type_helpers!`.
|
|
81
237
|
|
|
@@ -88,6 +244,10 @@ otherwise exact result type, so generation names the holes at `info` (see
|
|
|
88
244
|
3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json (register with GraphWeaver.register_scalar)
|
|
89
245
|
```
|
|
90
246
|
|
|
247
|
+
A scalar that is *meant* to be untyped belongs in the registry too —
|
|
248
|
+
`GraphWeaver.register_scalar("Json", "T.untyped")` says so once, and it leaves
|
|
249
|
+
the report. `JSON` is registered that way already.
|
|
250
|
+
|
|
91
251
|
## Enums: map onto your own T::Enum
|
|
92
252
|
|
|
93
253
|
By default a schema enum generates one `T::Enum` per schema, shared by every
|