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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2cb0372c9f8505bec490fbb13344cbd586da68cb4932cbce0f86de2467d0eaf4
4
- data.tar.gz: 6bc17449eea717c8cb145c159a2395f61f484a2f585c016ee31521f979f1a6d6
3
+ metadata.gz: f631eac2f0c9bbd78b50cfe1b7a0178911a8939cc697d57328bc63b3b60cd0d7
4
+ data.tar.gz: 771837c86ab744d47de6b1f58e990575ee7618d9cb362e871614f6aeebcded18
5
5
  SHA512:
6
- metadata.gz: 63670eee3838644725166822028b4816164450c853e24d74dd6ce47212e4e18c2bda7309d5549f8830e709d547e73678425ae9984d960a5367b97f5102613f5c
7
- data.tar.gz: d7b30350095090f9c69e7b5316137ac5324b60965a242ffe5c9be4a893812a48351a172bde26f36000797d830eb01c3e15ea0352eea8ecda9ac775f55b12ac82
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.2)
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.2.0)
199
- sorbet (0.6.13485)
200
- sorbet-static (= 0.6.13485)
201
- sorbet-runtime (0.6.13485)
202
- sorbet-static (0.6.13485-aarch64-linux)
203
- sorbet-static (0.6.13485-universal-darwin)
204
- sorbet-static (0.6.13485-x86_64-linux)
205
- sorbet-static-and-runtime (0.6.13485)
206
- sorbet (= 0.6.13485)
207
- sorbet-runtime (= 0.6.13485)
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.2)
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.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e
354
- sorbet (0.6.13485) sha256=b885d6a2fcde57bc46c3cdd597b9014b82619dee0f811a6fdf36f57c8f82c380
355
- sorbet-runtime (0.6.13485) sha256=2982504e662eb424e515b3aa1c8139070451a014b6c1af2302250ed82495b4cc
356
- sorbet-static (0.6.13485-aarch64-linux) sha256=5d802793633363442f189615fdb6c6d1be726b402d78a3d63eab96ca74b98d40
357
- sorbet-static (0.6.13485-universal-darwin) sha256=6c7c4551872d556b37bf1f1a3495509b9fd9479b4a080bd57f9c5db0e8116658
358
- sorbet-static (0.6.13485-x86_64-linux) sha256=e22d88df0bba5a97e664a329a2212ea59e6a2020626597a162c60c5ddfb4f9b4
359
- sorbet-static-and-runtime (0.6.13485) sha256=b893346f9e1a244133071ad3c3d06b8ddf70c1b8246bb594c9aaa5cfc778df5f
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` carries the same
694
- list as a `"subgraphs"` key:
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
@@ -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
- **Neither form is statically checked as written**, for the same reason: `srb tc`
476
- checks a mixin's method bodies in the module's own scope, not the including
477
- struct's, so a helper reading a wire field (`name`, `birthday`) fails with
478
- "method does not exist on the module" — and the block form has no source on disk
479
- for `srb tc` to read at all. A *named* module can carry real sigs, though, by
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
- boot from a `to_prepare` block, after your initializers and after any
682
- registrations of your own in one. Elsewhere it's explicit, factory_bot-style:
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
@@ -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 boot from a `to_prepare` block, so a helper or enum you registered is
103
- already in place when the file that names it loads.
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** name for the type — `Int`, `Money` — so a
125
- sentence built from it still says "Int" in the middle of the French. Translate
126
- the name yourself under a key of your own and pass it *after* the splat, which
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.parse(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
72
+ | `Time` | `Time.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `time` |
72
73
  | `DateTime` | `DateTime.iso8601(v)` | `GraphWeaver::Coerce.timestamp(v)` | `date` |
74
+ | `Integer` | `GraphWeaver::Coerce.integer(v, "Count")` | — | — |
75
+ | `Float` | `GraphWeaver::Coerce.float(v, "Count")` | — | — |
76
+ | `String` | `GraphWeaver::Coerce.string(v, "Count")` | — | — |
77
+ | `T::Boolean` | `GraphWeaver::Coerce.boolean(v, "Count")` | — | — |
78
+ | `Hash`, `Array` | — | — | — |
73
79
 
74
80
  For a timestamp reach for `Time`; Ruby's own `DateTime` is accepted if you
75
- register it, but never assumed. `Time.parse` is the tolerant reader, and about
76
- the cost of a strict one noise until you are casting thousands of timestamps per
77
- response, where `register_scalar("Timestamp", Time, cast: :iso8601)` is both
78
- cheaper and narrower.
81
+ register it, but never assumed. All three read ISO 8601 and nothing else, which
82
+ is the one spelling a spec-compliant server writes. To take `Time.parse`'s looser
83
+ forms as well — a space instead of the `T`, a zone name, `"Jan 15 2024 10:20"`
84
+ say so: `register_scalar("Timestamp", Time, cast: :parse)`, at about 3× the cost
85
+ per timestamp.
79
86
 
80
87
  **A trailing zero doesn't survive the round trip.** A `BigDecimal` holds the
81
88
  *number*, so `"10.00"` in comes back `"10.0"` — numerically identical, textually
82
89
  different, which matters only where the bytes are: diffing a request body, or
83
90
  hashing one for a signature.
84
91
 
92
+ **The last five rows are the types JSON already holds**, so they write
93
+ themselves — nothing to serialize — and naming one says the scalar *is* that
94
+ Ruby type. The library's own rule for the class then runs in both directions,
95
+ refusing in the scalar's name: `register_scalar("Count", Integer)` reads and
96
+ writes exactly as `Int` coerces a variable, so `5`, `"5"` and `5.0` all arrive
97
+ as `5`, while `"abc"`, `1.5` and `true` raise naming `Count`. `Hash` and `Array`
98
+ pass through untouched — `Coerce` has no rule for either, and anything else
99
+ would be a guess.
100
+
101
+ Coming back that is the **lenient** reading, unlike the spec's own `Int`, which
102
+ [refuses `"1"`](#coming-back--what-from_h-accepts): a compliant server writes an
103
+ `Int` as a JSON number, but a custom scalar is the server's own and may well
104
+ write the number as a string. The registration is you saying "make this an
105
+ `Integer`"; refusing the garbage is what protects you.
106
+
85
107
  ## Registering a class of your own
86
108
 
87
109
  Pass the class and the cast/serialize are **inferred** from it, by probing the
@@ -102,6 +124,15 @@ response unequal, and useless as hash keys, while the `Money` inside them compar
102
124
  fine. Registration warns when it spots one; `alias_method :eql?, :==` plus a
103
125
  `hash` built from the same values is the whole fix.
104
126
 
127
+ **A `cast:` with nothing to write back is half a codec**, and registration says
128
+ so too. `Kernel#Type` is the inference that lands there — it reads the wire and
129
+ pairs with nothing — so a variable of that scalar goes out as whatever `#to_json`
130
+ makes of the object, and a result's `as_json` can't reproduce what the server
131
+ sent. Both are silent, because every object answers `#to_json`. Name a
132
+ `serialize:`, or `serialize: :itself` if the value really does go out as it is. A
133
+ type JSON already holds (a `String`, `Integer`, `Float`, `Hash` or `Array`, or a
134
+ subclass of one) writes itself, and is left alone.
135
+
105
136
  A type defining none of those probes stays pass-through rather than getting
106
137
  wrapped — every object has `#to_s`, so inferring a serializer off it would wrap
107
138
  plain types too. Override explicitly when you need to:
@@ -175,17 +206,18 @@ GraphWeaver.extend_type("Money") { def to_money = ::Money.from_amount(BigDecimal
175
206
  ```
176
207
 
177
208
  **One `serialize:` serves both directions** — the outbound variable *and* a
178
- result's [`as_json`](generated_modules.md#anatomy), which is what makes
179
- `from_h(JSON.parse(x.to_json)) == x` hold. So a scalar that sends an object and
180
- accepts a string can't have both: `as_json` writes the string the cast can't read,
181
- and the JSON round trip raises a `CastError` coming back (the wire itself is fine
182
- in both directions). The same asymmetry decides the [`:fake` pin](testing.md#pins):
183
- a pin stands in for a *result*, so pin what the server **sends**.
209
+ result's [`as_json`](generated_modules.md#anatomy) so a registration's `cast:`
210
+ has to accept what its own `serialize:` writes, and a scalar that sends an object
211
+ while accepting a string can't have both. That law, and where it is checked, is
212
+ [below](#a-cast-must-accept-what-its-own-serialize-writes). The same asymmetry
213
+ decides the [`:fake` pin](testing.md#pins): a pin stands in for a *result*, so pin
214
+ what the server **sends**.
184
215
 
185
216
  Any of this can also be flatly wrong: the *format* a `Money` string has to match
186
- lives in the server's `coerce_input`, which no schema carries, so nothing before a
187
- real request says whether the server wants `"12.50"`, `"12.50 USD"` or the object.
188
- [Send one for real](#what-no-check-can-see).
217
+ lives in the server's `coerce_input`, which no schema carries, so nothing
218
+ `generate` reads says whether the server wants `"12.50"`, `"12.50 USD"` or the
219
+ object — [check it against the schema
220
+ class](#checking-the-half-no-schema-carries), or send one for real.
189
221
 
190
222
  ## Overriding one field
191
223
 
@@ -276,9 +308,10 @@ server writing a non-integer where the spec says integer, so it is refused.
276
308
  | `ID` | any JSON string | a number or `true` — **refused with a hint**: the server didn't quote it |
277
309
  | `Boolean` | `true`, `false` | `"true"`, `1`, `0` |
278
310
  | `Date` | ISO-8601: `"2024-01-01"`, `"20240101"`, and a full timestamp (truncated) | any other spelling, an epoch integer |
279
- | `DateTime`/`Time` (registered as `Time`) | RFC 3339 with `Z` or an offset, with or without fractional seconds, seconds optional; also a bare date and `Time.parse`'s looser forms | an epoch integer, an unparseable string |
311
+ | `DateTime`/`Time` (registered as `Time`) | ISO 8601 as `Time.iso8601` reads it: `Z` or an offset, with or without fractional seconds | a bare date, seconds omitted, basic format (`"20240115T102030Z"`), `Time.parse`'s looser forms, an epoch integer |
280
312
  | `BigInt` | the decimal string graphql-ruby writes, past 2⁵³ included; also a JSON integer | `1.5`, `"1.5"`, a non-numeric string, `true` |
281
313
  | an enum | a declared value, as a string | an undeclared value, a non-string |
314
+ | a scalar registered as `Integer`, `Float`, `String` or `T::Boolean` | that Ruby type's rule, *leniently* — a decimal string reads as an `Integer` | what the rule refuses, naming your scalar |
282
315
  | `JSON`, or unregistered | anything — `T.untyped`, straight through | nothing |
283
316
 
284
317
  A refusal is a [`GraphWeaver::CastError`](errors.md) naming the field and the
@@ -297,14 +330,14 @@ sig is `.checked(:never)`).
297
330
 
298
331
  | scalar | kwarg is typed | also accepts, at runtime | on the wire |
299
332
  |---|---|---|---|
300
- | `Int` | `Integer` | a decimal string, a whole `Float` | the integer |
301
- | `Float` | `Float` | a decimal string, an `Integer` | the float |
333
+ | `Int` | `Integer` | a decimal string, a whole real `Numeric` — `2.0`, `BigDecimal("2")` | the integer |
334
+ | `Float` | `Float` | a decimal string, a real `Numeric` — an `Integer`, a `BigDecimal`, a `Rational` | the float |
302
335
  | `String` | `String` | nothing | the string |
303
336
  | `ID` | `String` | an `Integer` — `execute(id: user.id)` | the string |
304
337
  | `Boolean` | `true`/`false` | nothing | the boolean |
305
338
  | `Date` | `Date` | an ISO-8601 string | `"2024-01-15"` |
306
- | `Time` | `Time` | a string `Time.parse` takes, a `DateTime`, `Time.zone.now` | ISO 8601, with microseconds when the value carries a fraction |
307
- | `BigInt` | `Integer` | a decimal string | the decimal string, which is what the server writes |
339
+ | `Time` | `Time` | an ISO 8601 string, a `DateTime`, `Time.zone.now` | ISO 8601, with microseconds when the value carries a fraction |
340
+ | `BigInt` | `Integer` | a decimal string, a whole real `Numeric` | the decimal string, which is what the server writes |
308
341
  | an enum | the member **or** its wire value | — | the wire value |
309
342
  | an input object | the struct **or** a Hash | — | the wire hash |
310
343
  | a registered custom scalar | its Ruby type | whatever its cast takes | what its serialize writes |
@@ -321,6 +354,12 @@ no string**, because every rule for reading `"0"`, `"off"`, `"no"` is somebody's
321
354
  convention. **A `Date` and a `Time` are not each other**, as above; what *is*
322
355
  accepted for a `Time` is anything that already is one.
323
356
 
357
+ A **real `Numeric`** is the number it prints as, so a `BigDecimal` off a decimal
358
+ column needs no conversion at the call site: it reaches a `Float` through `to_f`,
359
+ at Float's own precision, and an `Int` only when the value is whole —
360
+ `BigDecimal("2")` is `2` and `BigDecimal("2.5")` is refused, exactly as `2.5` is.
361
+ `Complex` is the one `Numeric` that is not real, and no number rule takes it.
362
+
324
363
  Anything the table refuses raises `GraphWeaver::InputError` naming the variable,
325
364
  the operation and the value — `$count of Compute: expected an Int, got "lots"` —
326
365
  which is the same [422 rescue point](errors.md) as a bad input-object field. The
@@ -354,7 +393,7 @@ short-circuits — so a coercer written like the examples above raises
354
393
  `NoMethodError` on nil. Guard it, or `:in_process` will show it to you as a
355
394
  `ServerError`.
356
395
 
357
- ## What no check can see
396
+ ## Checking the half no schema carries
358
397
 
359
398
  A custom scalar has two definitions that have to agree: the server's
360
399
  `coerce_input`/`coerce_result`, and your `register_scalar`. **No schema carries the
@@ -372,22 +411,56 @@ No `CastError`, no warning — just totals quietly wrong past the seventh
372
411
  significant figure, which is the precision a string-valued `Decimal` exists to
373
412
  protect.
374
413
 
375
- The check is a request that runs the real coercers: one `graphql: :in_process`
376
- example per registered scalar, round-tripping a value through the schema class.
414
+ **Where the server runs in-process, both halves are callable, and that is the
415
+ check.** One line, for every scalar at once:
377
416
 
378
417
  ```ruby
379
- it "round-trips a Money through the real server", graphql: :in_process do
380
- price = Money.from_amount(BigDecimal("12.50"), "EUR")
381
- expect(EchoPriceQuery.execute!(price:).echo_price).to eq price
418
+ it "agrees with the server about every scalar" do
419
+ GraphWeaver::Testing.check_scalars!(Catalog::Schema)
382
420
  end
383
421
  ```
384
422
 
385
- Four lines, and it fails the moment either side moves. **`graphql: :fake` cannot
386
- stand in for it**: a fake fabricates from your *client* registration alone, so it
387
- hands back a value the real server would never send and accepts one the real
388
- server would reject. It is shape-correct, never rule-correct. A
389
- [cassette](cassettes.md) recorded against `:in_process` carries the rules to a
390
- suite that can't boot the schema class.
423
+ Per scalar the schema declares and your app registered, it fabricates a value the
424
+ way [`:fake`](testing.md) does, casts it, sends it back out through `serialize:`,
425
+ through the server's `coerce_input` and `coerce_result`, and back through `cast:`.
426
+ It raises naming every scalar that disagreed and which way:
427
+
428
+ ```
429
+ 2 scalar(s) disagree with Catalog::Schema:
430
+ Money: the server refused "12.5", the wire form serialize: writes (expected "12.50 USD")
431
+ Decimal: round-trips lossily — sent 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, or pin a real
407
- response with a [cassette](cassettes.md).
413
+ itself. Put the live schema class behind the wire for that
414
+ [`check_scalars!`](scalars.md#checking-the-half-no-schema-carries) asks the same
415
+ question of one directly — or pin a real response with a
416
+ [cassette](cassettes.md).
408
417
 
409
418
  `GraphWeaver::Testing::Endpoint` is an ordinary Rack app wrapping anything that
410
419
  satisfies the [client contract](transports.md), so mount it yourself
@@ -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.1](#upgrading-from-071), from
5
- [0.7.0](#upgrading-from-070) or from [0.6.1](#upgrading-from-061). Coming from
6
- 0.6.0 or older, the path is that version's own upgrade notes — read them at the
7
- tag they shipped under (`git show v0.7.1:docs/upgrading.md`), then this page from
8
- 0.6.1 down.
4
+ the one section that is yours: from [0.7.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.2 — do not edit.
4
+ # Generated by GraphWeaver 0.7.4 — do not edit.
5
5
 
6
6
  module StarMutation
7
7
  extend T::Sig
@@ -49,6 +49,14 @@ module StarMutation
49
49
  rescue StandardError => e
50
50
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
51
51
  end
52
+
53
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
54
+ def as_json(*_options)
55
+ {
56
+ "stargazerCount" => stargazer_count,
57
+ "viewerHasStarred" => viewer_has_starred,
58
+ }
59
+ end
52
60
  end
53
61
 
54
62
  const :starrable, T.nilable(Starrable)
@@ -63,6 +71,13 @@ module StarMutation
63
71
  rescue StandardError => e
64
72
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
65
73
  end
74
+
75
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
76
+ def as_json(*_options)
77
+ {
78
+ "starrable" => starrable&.then { |v1| v1.as_json },
79
+ }
80
+ end
66
81
  end
67
82
 
68
83
  const :add_star, T.nilable(AddStar)
@@ -77,6 +92,13 @@ module StarMutation
77
92
  rescue StandardError => e
78
93
  raise GraphWeaver::CastError.new(struct: self, message: GraphWeaver::Hints.cast_message(self, data, e))
79
94
  end
95
+
96
+ sig { params(_options: T.untyped).returns(T::Hash[String, T.untyped]) }
97
+ def as_json(*_options)
98
+ {
99
+ "addStar" => add_star&.then { |v1| v1.as_json },
100
+ }
101
+ end
80
102
  end
81
103
 
82
104
  # client — see GraphWeaver::QueryModule
@@ -92,7 +114,7 @@ module StarMutation
92
114
  sig { params(id: String, client: T.untyped).returns(GraphWeaver::Response[Result]).checked(:never) }
93
115
  def self.execute(id:, client: nil)
94
116
  variables = {
95
- "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v) },
117
+ "id" => GraphWeaver::Coerce.variable("id", OPERATION_NAME, id) { |v| GraphWeaver::Coerce.id(v, "ID") },
96
118
  }
97
119
 
98
120
  from_response(dispatch(variables, client:))