graph_weaver 0.7.2 → 0.7.4
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 +19 -19
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +52 -10
- data/docs/getting_started.md +18 -2
- data/docs/i18n.md +4 -4
- data/docs/scalars.md +123 -32
- data/docs/testing.md +21 -2
- data/docs/upgrading.md +31 -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/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/emit.rb +22 -7
- data/lib/graph_weaver/codegen/enum_type.rb +132 -19
- data/lib/graph_weaver/codegen/nodes.rb +20 -9
- 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 +61 -16
- data/lib/graph_weaver/coerce.rb +24 -5
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +4 -1
- data/lib/graph_weaver/input_struct.rb +13 -7
- data/lib/graph_weaver/internal/values.rb +5 -2
- data/lib/graph_weaver/internal.rb +67 -0
- data/lib/graph_weaver/railtie.rb +202 -147
- data/lib/graph_weaver/testing.rb +101 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +83 -67
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f631eac2f0c9bbd78b50cfe1b7a0178911a8939cc697d57328bc63b3b60cd0d7
|
|
4
|
+
data.tar.gz: 771837c86ab744d47de6b1f58e990575ee7618d9cb362e871614f6aeebcded18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f24e3485d8ff839de3a9fd626b2c0038c0617c322b6249742aa90eae3552073d3cbf3335de430364072bfa990304689a7e9ad3db652ae527d46ac711573a276
|
|
7
|
+
data.tar.gz: a484029fbbdd141af8be0586d8a830e2439f13fdbbc1b9f4826840f289af3e000a81457749657e5573cd67c4ae2017aa2929bfb34a300e9777669b9265c22eb5
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.7.
|
|
4
|
+
graph_weaver (0.7.4)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -195,16 +195,16 @@ GEM
|
|
|
195
195
|
rubydex (0.2.7-x86_64-darwin)
|
|
196
196
|
rubydex (0.2.7-x86_64-linux)
|
|
197
197
|
securerandom (0.4.1)
|
|
198
|
-
simplecov (1.
|
|
199
|
-
sorbet (0.6.
|
|
200
|
-
sorbet-static (= 0.6.
|
|
201
|
-
sorbet-runtime (0.6.
|
|
202
|
-
sorbet-static (0.6.
|
|
203
|
-
sorbet-static (0.6.
|
|
204
|
-
sorbet-static (0.6.
|
|
205
|
-
sorbet-static-and-runtime (0.6.
|
|
206
|
-
sorbet (= 0.6.
|
|
207
|
-
sorbet-runtime (= 0.6.
|
|
198
|
+
simplecov (1.3.0)
|
|
199
|
+
sorbet (0.6.13498)
|
|
200
|
+
sorbet-static (= 0.6.13498)
|
|
201
|
+
sorbet-runtime (0.6.13498)
|
|
202
|
+
sorbet-static (0.6.13498-aarch64-linux)
|
|
203
|
+
sorbet-static (0.6.13498-universal-darwin)
|
|
204
|
+
sorbet-static (0.6.13498-x86_64-linux)
|
|
205
|
+
sorbet-static-and-runtime (0.6.13498)
|
|
206
|
+
sorbet (= 0.6.13498)
|
|
207
|
+
sorbet-runtime (= 0.6.13498)
|
|
208
208
|
spoom (1.8.3)
|
|
209
209
|
erubi (>= 1.10.0)
|
|
210
210
|
prism (>= 0.28.0)
|
|
@@ -299,7 +299,7 @@ CHECKSUMS
|
|
|
299
299
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
300
300
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
301
301
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
302
|
-
graph_weaver (0.7.
|
|
302
|
+
graph_weaver (0.7.4)
|
|
303
303
|
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
304
304
|
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
|
|
305
305
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
@@ -350,13 +350,13 @@ CHECKSUMS
|
|
|
350
350
|
rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
|
|
351
351
|
rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
|
|
352
352
|
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
|
353
|
-
simplecov (1.
|
|
354
|
-
sorbet (0.6.
|
|
355
|
-
sorbet-runtime (0.6.
|
|
356
|
-
sorbet-static (0.6.
|
|
357
|
-
sorbet-static (0.6.
|
|
358
|
-
sorbet-static (0.6.
|
|
359
|
-
sorbet-static-and-runtime (0.6.
|
|
353
|
+
simplecov (1.3.0) sha256=d9886307863c1ead47657dcbed869bfffd82318808b09014d8f0ff77dcfe7492
|
|
354
|
+
sorbet (0.6.13498) sha256=cb5279f6a9dbfc8d526bc9fb71d05020afb8043c40708e4aff2244ba329ce224
|
|
355
|
+
sorbet-runtime (0.6.13498) sha256=2da4627f9341a1fc6e4148cc891989b7b108106c206c46ca69940e10a21e9133
|
|
356
|
+
sorbet-static (0.6.13498-aarch64-linux) sha256=cbee732c49379d38029f295b45edaeb5757ab180d308d8383dc79f03fd4991be
|
|
357
|
+
sorbet-static (0.6.13498-universal-darwin) sha256=4cc97aa0da3dd175b8278e1617954d6dd9ba1f5aeb62981f21616b9ec791fb75
|
|
358
|
+
sorbet-static (0.6.13498-x86_64-linux) sha256=75d02e8af7d6c1081d4693e2ebefe679760327f1304377519970784faf9f913d
|
|
359
|
+
sorbet-static-and-runtime (0.6.13498) sha256=5f5686869dceae893b4171b05e3db159fe12f1aadf04aa8c2bf9dfa3edc0d902
|
|
360
360
|
spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
|
|
361
361
|
tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
|
|
362
362
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
data/docs/federation.md
CHANGED
|
@@ -690,8 +690,9 @@ the rest.
|
|
|
690
690
|
|
|
691
691
|
The table is also what a good error message wants. When the schema dump is a
|
|
692
692
|
composed supergraph, `rake graph_weaver:queries:check` brands each validation error
|
|
693
|
-
with the subgraphs behind the type it names, and `check_queries`
|
|
694
|
-
|
|
693
|
+
with the subgraphs behind the type it names, and `check_queries` — or
|
|
694
|
+
`client.check_query(source)`, for a client built from that dump — carries the
|
|
695
|
+
same list as a `"subgraphs"` key:
|
|
695
696
|
|
|
696
697
|
```
|
|
697
698
|
app/graphql/queries/product.graphql
|
data/docs/generated_modules.md
CHANGED
|
@@ -308,6 +308,19 @@ camelizes to nothing — `_` and `__` are both legal GraphQL — is refused at
|
|
|
308
308
|
generation: there is no constant to name it. Map the enum onto one of yours
|
|
309
309
|
instead.
|
|
310
310
|
|
|
311
|
+
Two values that camelize to the *same* constant are refused for the same reason.
|
|
312
|
+
A schema mid-rename declares exactly that — `LEGACY_MODE` alongside
|
|
313
|
+
`legacy_mode`, so old clients keep working — and if they are one value, say
|
|
314
|
+
which spelling goes on the wire:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Both spellings then cast to `Status::LegacyMode`, only the target gets a
|
|
321
|
+
constant, and a variable sends the target — see
|
|
322
|
+
[scalars.md](scalars.md#two-spellings-one-value).
|
|
323
|
+
|
|
311
324
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
312
325
|
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
|
|
313
326
|
enums into the query module itself; there's no cross-query set to share against,
|
|
@@ -470,16 +483,23 @@ The name is where the block is written and what it extends:
|
|
|
470
483
|
`GraphWeaver::TypeHelpers::Billing::Pet` inside `GraphWeaver.graph :billing`.
|
|
471
484
|
Generated code spells it, so it depends on your source and nothing else — two
|
|
472
485
|
graphs can extend the same type name, and the name a `generate` bakes in is the
|
|
473
|
-
one a boot creates.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
486
|
+
one a boot creates. The module is minted at registration, so no file declares
|
|
487
|
+
it; generation writes a `type_helpers.rbi` beside the modules that does, which
|
|
488
|
+
is what lets your `srb tc` resolve the `include`. Ruby never loads an `.rbi`, so
|
|
489
|
+
dropping the registration still fails loudly at require rather than quietly
|
|
490
|
+
handing the struct an empty module.
|
|
491
|
+
|
|
492
|
+
**Neither form has its method bodies statically checked**, for the same reason:
|
|
493
|
+
`srb tc` checks a mixin's method bodies in the module's own scope, not the
|
|
494
|
+
including struct's, so a helper reading a wire field (`name`, `birthday`) fails
|
|
495
|
+
with "method does not exist on the module" — and the block form has no source on
|
|
496
|
+
disk for `srb tc` to read at all. A *named* module can carry real sigs, though, by
|
|
480
497
|
declaring the fields it leans on: `abstract!` plus a
|
|
481
498
|
`sig { abstract.returns(String) }; def name; end` is how a mixin says "whatever
|
|
482
|
-
includes me has these", and the struct's `const`s satisfy them
|
|
499
|
+
includes me has these", and the struct's `const`s satisfy them — generation
|
|
500
|
+
declares the override Sorbet demands there (`const :name, String, override:
|
|
501
|
+
true`, and `sig { override.returns(...) }` on an `alias:` accessor), which is
|
|
502
|
+
the one thing you couldn't add by hand: a `const` has no sig to put it in.
|
|
483
503
|
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
484
504
|
beats `# typed: false` for a helper you want checked.
|
|
485
505
|
|
|
@@ -534,6 +554,26 @@ excuses a field the query didn't select, not a segment the schema doesn't have:
|
|
|
534
554
|
typo or a wire-cased name (`findPets` for `find_pets`) still raises, since no
|
|
535
555
|
selection could ever satisfy it.
|
|
536
556
|
|
|
557
|
+
**One call does both halves.** `alias:` and a block are independent parts of the
|
|
558
|
+
same registration — the accessor is emitted into the struct body, the block
|
|
559
|
+
becomes a mixin the struct includes — so a block method can call an accessor the
|
|
560
|
+
same call declared. Inside the block the alias is spelled `alias_field`, which
|
|
561
|
+
is the same keyword said next to the methods that use it: same paths, same
|
|
562
|
+
errors, same accessor. Two positions, one thing.
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
GraphWeaver.extend_type("Widget") do
|
|
566
|
+
alias_field :tag, "meta.tag" # or alias_field "meta.tag" — accessor named `tag`
|
|
567
|
+
def shout = tag&.upcase
|
|
568
|
+
end
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The verb is `alias_field` because the block can't spell `alias` itself: it is a
|
|
572
|
+
Ruby keyword, and the block is `module_eval`'d Ruby, so writing it there would
|
|
573
|
+
define a method alias rather than a projection. A block says one alias per line
|
|
574
|
+
and nothing else — the compact `{ }`/`[ ]` forms and `optional: true` stay on
|
|
575
|
+
the keyword, since leniency describes the registration rather than one accessor.
|
|
576
|
+
|
|
537
577
|
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
538
578
|
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
539
579
|
bodies. Every form above, and every error it raises, is a named example in
|
|
@@ -602,6 +642,7 @@ app/graphql/
|
|
|
602
642
|
generated/
|
|
603
643
|
types.rb # manifest: requires + forward declarations, in load order
|
|
604
644
|
types/ # one file per shared type
|
|
645
|
+
type_helpers.rbi # only with a block-form extend_type — declares its module
|
|
605
646
|
*_query.rb # one module per query — generated, checked in, never edited
|
|
606
647
|
*_mutation.rb # ...and per mutation
|
|
607
648
|
```
|
|
@@ -678,8 +719,9 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
|
|
|
678
719
|
### Loading what it wrote
|
|
679
720
|
|
|
680
721
|
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
681
|
-
|
|
682
|
-
|
|
722
|
+
the end of boot, after your initializers and after any registrations of your own
|
|
723
|
+
in a `to_prepare` block, and again after each development reload. Elsewhere it's
|
|
724
|
+
explicit, factory_bot-style:
|
|
683
725
|
`GraphWeaver.load_generated!` requires every file under `generated_paths`.
|
|
684
726
|
|
|
685
727
|
**Outside Rails, four things have to agree**, and nothing wires them together for
|
data/docs/getting_started.md
CHANGED
|
@@ -99,8 +99,9 @@ What it wrote:
|
|
|
99
99
|
|
|
100
100
|
Rake needs no wiring: in Rails the `graph_weaver:*` tasks register themselves and
|
|
101
101
|
depend on `:environment`, so your initializer runs first. The generated modules
|
|
102
|
-
load at
|
|
103
|
-
already in place when the
|
|
102
|
+
load at the end of boot, after your initializers and after any `to_prepare`
|
|
103
|
+
block of yours, so a helper or enum you registered is already in place when the
|
|
104
|
+
file that names it loads.
|
|
104
105
|
|
|
105
106
|
All of that describes **one** schema, which is the usual case; an app with a
|
|
106
107
|
second one declares each as a graph ([more than one
|
|
@@ -299,6 +300,21 @@ it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
|
|
|
299
300
|
a path) and nothing touches the network, which is how you check your queries
|
|
300
301
|
against a proposed subgraph before it's live.
|
|
301
302
|
|
|
303
|
+
A query you have as a **string** rather than on disk asks the same client the
|
|
304
|
+
same question, and gets the same entries back:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
client.check_query('query($id: ID!) { person(id: $id) { nmae } }')
|
|
308
|
+
# => [{ "message" => "Field 'nmae' doesn't exist on type 'Person' (Did you mean `name`?)",
|
|
309
|
+
# "line" => 1, "column" => 37 }]
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Empty means it validates. It checks against that client's own schema — the one
|
|
313
|
+
`execute` would run against — so nothing re-introspects, and an unparseable
|
|
314
|
+
source comes back as an entry rather than an exception. Shared fragments are
|
|
315
|
+
inlined from `fragments:`, defaulting to `GraphWeaver.fragments_paths` the way
|
|
316
|
+
`parse` does.
|
|
317
|
+
|
|
302
318
|
### The selections nothing reads
|
|
303
319
|
|
|
304
320
|
`rake graph_weaver:unused` asks the one question the others can't: not "is the
|
data/docs/i18n.md
CHANGED
|
@@ -121,10 +121,10 @@ I18n.t("graph_weaver.input.#{error.kind}", field: error.path.join("."), **error.
|
|
|
121
121
|
Arrays stay arrays: `details[:members]` is `["CAT", "DOG"]`, so join it where you
|
|
122
122
|
know the language — `members: e.details[:members].to_sentence`.
|
|
123
123
|
|
|
124
|
-
`details[:type]` is the **GraphQL**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
|
|
124
|
+
`details[:type]` is the **GraphQL** spelling of the type — a name (`Int`,
|
|
125
|
+
`Money`) or a whole signature (`[JSON!]`, `[Float!]!`) — so a sentence built
|
|
126
|
+
from it still says "Int" in the middle of the French. Translate the spelling
|
|
127
|
+
yourself under a key of your own and pass it *after* the splat, which wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
|
|
128
128
|
|
|
129
129
|
`@oneOf` violations are `:refused`, except exactly one field explicitly null,
|
|
130
130
|
which is `:missing` on that field — a form gets the one slot to highlight. The
|
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 0.123456789123456789e9, got back 0.1234567891234567e9
|
|
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
|
|
|
@@ -439,5 +512,23 @@ Two safety properties do the real work:
|
|
|
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
|
+
### Two spellings, one value
|
|
516
|
+
|
|
517
|
+
A schema mid-rename declares both `LEGACY_MODE` and `legacy_mode` so old clients
|
|
518
|
+
keep working. `alias:` says they are one value — both spellings cast, and the
|
|
519
|
+
target is what goes back on the wire:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
That is the whole registration when there is no enum of your own to map onto; it
|
|
526
|
+
rides along with one when there is, where it also settles which spelling a
|
|
527
|
+
member serializes to — inference is case/underscore-insensitive, so a rename
|
|
528
|
+
pair lands on a single member. Either way generation refuses rather than pick:
|
|
529
|
+
two values that name one Ruby constant, or that map onto one member, are
|
|
530
|
+
ambiguous until you say. Delete the alias when the server drops the old
|
|
531
|
+
spelling.
|
|
532
|
+
|
|
442
533
|
Decorating a generated *struct* with your own methods is the sibling API —
|
|
443
534
|
`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
|
|
@@ -635,6 +644,16 @@ the fly? The client in play exposes it as `GraphWeaver.client.schema`, and
|
|
|
635
644
|
`GraphWeaver::Testing.config.schema` reads back what `config.schema =` set,
|
|
636
645
|
falling back to the committed dump.
|
|
637
646
|
|
|
647
|
+
A query built that way is worth an assertion of its own:
|
|
648
|
+
|
|
649
|
+
```ruby
|
|
650
|
+
expect(GraphWeaver.client.check_query(source)).to be_empty
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
[`check_query`](getting_started.md#5-verify-in-ci) answers with the
|
|
654
|
+
errors rather than a boolean, so a failure prints what is wrong and where; a
|
|
655
|
+
predicate would only say the query isn't valid.
|
|
656
|
+
|
|
638
657
|
## Test-only generated modules
|
|
639
658
|
|
|
640
659
|
They don't have to live in `app/` — `generated_paths` is an appendable list, so a
|
data/docs/upgrading.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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.6.0 or older, the path is that
|
|
7
|
-
|
|
8
|
-
0.6.1 down.
|
|
4
|
+
the one section that is yours: from [0.7.3](#upgrading-from-073), from
|
|
5
|
+
[0.7.1](#upgrading-from-071), from [0.7.0](#upgrading-from-070) or from
|
|
6
|
+
[0.6.1](#upgrading-from-061). Coming from 0.6.0 or older, the path is that
|
|
7
|
+
version's own upgrade notes — read them at the tag they shipped under
|
|
8
|
+
(`git show v0.7.1:docs/upgrading.md`), then this page from 0.6.1 down.
|
|
9
9
|
|
|
10
10
|
## Regenerate on every upgrade
|
|
11
11
|
|
|
@@ -23,6 +23,32 @@ That's the reminder working, not a false alarm. Generation is deterministic, so
|
|
|
23
23
|
the diff is exactly what the new version emits differently and nothing else —
|
|
24
24
|
worth reading rather than rubber-stamping.
|
|
25
25
|
|
|
26
|
+
## Upgrading from 0.7.3
|
|
27
|
+
|
|
28
|
+
A patch release. One change can reach an app that never touched it — how a
|
|
29
|
+
timestamp is read off the wire — one more reaches an app that maps an enum onto
|
|
30
|
+
its own, and the rest is in the regenerate. Read the left column and skip what
|
|
31
|
+
isn't yours; the [changelog](../CHANGELOG.md) says why each one moved.
|
|
32
|
+
|
|
33
|
+
| applies if you… | what changed |
|
|
34
|
+
|---|---|
|
|
35
|
+
| 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)` |
|
|
36
|
+
| 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 |
|
|
37
|
+
| 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 |
|
|
38
|
+
|
|
39
|
+
Then regenerate, and the gate:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# the timestamp cast is emitted, the input-struct FIELDS table grew a column,
|
|
43
|
+
# and a graph with a block-form extend_type gains a type_helpers.rbi — commit
|
|
44
|
+
# it with the rest
|
|
45
|
+
rake graph_weaver:generate
|
|
46
|
+
|
|
47
|
+
# red while any checked-in file is still what 0.7.3 wrote; 0.7.3's input
|
|
48
|
+
# structs raise `ArgumentError: missing keyword: :type` at load until then
|
|
49
|
+
rake graph_weaver:verify
|
|
50
|
+
```
|
|
51
|
+
|
|
26
52
|
## Upgrading from 0.7.1
|
|
27
53
|
|
|
28
54
|
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:))
|