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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. 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
- `Money` then generates `const :price, T.nilable(Money)` and casts with
6
- `Money.parse(...)` inline — no runtime reflection:
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("Money", Money, requires: "bigdecimal")
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
- Pass a `Type.field` **coordinate** instead of a scalar name to override just
16
- that one field — so the same scalar can deserialize as different Ruby types
17
- across fields:
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("ISO8601DateTime", Time) # the default, everywhere
21
- GraphWeaver.register_scalar("User.birthday", Date) # this field only
79
+ GraphWeaver.register_scalar("Money", Money)
22
80
  ```
23
81
 
24
- A field override wins over the scalar-name registration which is also how two
25
- servers that disagree about a `DateTime` coexist in one process. Coordinates are
26
- validated against the schema, so a typo'd field raises.
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
- `type:` also accepts a plain string (`"BigDecimal"`) when you'd rather not
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 `type:` is
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
- Pass `coerce: true` to let a variable of this scalar accept **either** the value
52
- object **or** its raw input, normalizing the latter before it goes on the wire:
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("Money", Money, coerce: true)
56
- # generated execute now takes T.any(Money, String); "12.00" is parsed
57
- StoreQuery.execute(budget: "12.00") # Money.parse("12.00") under the hood
58
- StoreQuery.execute(budget: Money.new(1200)) # passed straight through
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
- `GraphWeaver.auto_coerce = true` is the same switch for every scalar at once —
62
- set it any time before you generate; an explicit `coerce:` on a registration
63
- always wins. Off by default either way: the strict typed kwarg is the norm.
138
+ A registration used only for a variable is untouched: nothing casts it.
64
139
 
65
- *How* a scalar coerces isn't yours to pick the scalar already knows. `Int` and
66
- `Float` convert (`"5"` `5`, sent as a native number); anything with a full
67
- cast/serialize pair (`Date`, your `Money`) parses, and bad input still explodes
68
- because the cast raises. A pass-through scalar — `String`, `ID`, `Boolean` — has
69
- neither a conversion nor a codec pair, so it can't coerce at all: `coerce: true`
70
- on one raises rather than emitting a no-op.
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
- The built-in scalars (`Date`, `ID`, `Int`, …) are pre-registered through the
73
- same path (`Date` even carries its own `require "date"`), so a later
74
- `register_scalar` overrides them.
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
- `GraphWeaver.reset_registrations!` is the clean slate between tests: built-in
77
- scalars restored, enum mappings and type helpers dropped. To reset one registry
78
- rather than all of them, `GraphWeaver::Codegen` has the pieces —
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