graph_weaver 0.7.4 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/docs/errors.md +5 -2
- data/docs/generated_modules.md +127 -14
- data/docs/getting_started.md +159 -14
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +39 -4
- data/docs/testing.md +3 -1
- data/docs/upgrading.md +21 -1
- data/lib/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +13 -9
- data/lib/graph_weaver/codegen/enum_type.rb +26 -9
- data/lib/graph_weaver/codegen/nodes.rb +55 -31
- data/lib/graph_weaver/codegen.rb +198 -90
- data/lib/graph_weaver/coerce.rb +1 -1
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/hints.rb +20 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +21 -6
- data/lib/graph_weaver/internal/subgraphs.rb +1 -10
- data/lib/graph_weaver/internal/unused.rb +32 -7
- data/lib/graph_weaver/internal/values.rb +7 -2
- data/lib/graph_weaver/internal.rb +17 -0
- data/lib/graph_weaver/logging.rb +26 -29
- data/lib/graph_weaver/query_module.rb +20 -5
- data/lib/graph_weaver/railtie.rb +7 -2
- data/lib/graph_weaver/rspec.rb +0 -1
- data/lib/graph_weaver/schema_loader.rb +7 -8
- data/lib/graph_weaver/tasks.rb +60 -5
- data/lib/graph_weaver/testing/fake_client.rb +4 -10
- data/lib/graph_weaver/testing/router.rb +26 -25
- data/lib/graph_weaver/testing.rb +1 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +13 -7
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53034719b6dcd597d098091606e08b6f4a33a15a89826374cf6b14df841aa0fc
|
|
4
|
+
data.tar.gz: cb64c3e12070f522a44353066aaac80ea7faf9df7d8a564a1abfcd053584049b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b320bc47a5839ef8561f44ac12b78eba36cc8b33078397f2818db59ce1268fef5f3b6b4aa347ef1e21443c9e213dd1ea6c5a297d14d8fa5c651ff8bcacd3052
|
|
7
|
+
data.tar.gz: 35abfec468ca9f50327c7f6a33a7f881884c004daf294019dd0a27c12498c010ec4383404a86d7015ed74616d114978e62e081703acffbcba58b9b3abad69add
|
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.5)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -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.5)
|
|
303
303
|
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
304
304
|
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
|
|
305
305
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
data/README.md
CHANGED
|
@@ -146,6 +146,7 @@ when you've skipped it.
|
|
|
146
146
|
- **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
|
|
147
147
|
- **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
|
|
148
148
|
- **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what each release moved
|
|
149
|
+
- **[Migrating](docs/migrating.md)** — coming off graphql-client or a hand-rolled client, commit by commit
|
|
149
150
|
- **[Alternatives](docs/alternatives.md)** — graphql-client, graphlient, artemis, hand-rolled: what each is good at, and where this one loses
|
|
150
151
|
|
|
151
152
|
----
|
data/docs/errors.md
CHANGED
|
@@ -172,7 +172,10 @@ A nested filter reports the innermost input type, so the error points at the inp
|
|
|
172
172
|
that actually held the bad field; passing something that is neither — a bare
|
|
173
173
|
`String` where the input goes — reports the same way. A call site that *spells* the
|
|
174
174
|
wrong type is caught earlier and better, by `srb tc`
|
|
175
|
-
([why](generated_modules.md#variables-become-typed-kwargs)).
|
|
175
|
+
([why](generated_modules.md#variables-become-typed-kwargs)). And once a boundary
|
|
176
|
+
of your own between that call site and `execute` carries a sig, sorbet-runtime
|
|
177
|
+
checks the value there first: the caller sees a plain `TypeError`, and none of
|
|
178
|
+
the structured, translatable detail below ever reaches it.
|
|
176
179
|
|
|
177
180
|
The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
|
|
178
181
|
|
|
@@ -464,7 +467,7 @@ than leaving you sorbet's words:
|
|
|
464
467
|
| what came back | what the message adds |
|
|
465
468
|
|---|---|
|
|
466
469
|
| an `ID` the server sent unquoted | GraphQL serializes `ID` as a JSON string, so this is the server out of spec — plus how to take it anyway (`register_scalar("ID", "T.untyped")`) |
|
|
467
|
-
| an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum(fallback:)` to absorb them |
|
|
470
|
+
| an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum("Species", fallback: true)` to absorb them into `Other` |
|
|
468
471
|
| a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
|
|
469
472
|
|
|
470
473
|
Simulate one in tests with
|
data/docs/generated_modules.md
CHANGED
|
@@ -61,6 +61,18 @@ name your operations**: an anonymous document is named after the module in the
|
|
|
61
61
|
emitted `QUERY` *and* in `OPERATION_NAME` — both, since a server rejects an
|
|
62
62
|
`operationName` its document doesn't declare.
|
|
63
63
|
|
|
64
|
+
Code that takes *any* generated module — a persisted-query manifest, a
|
|
65
|
+
transport of your own — types it as `GraphWeaver::QueryModule` and reads the
|
|
66
|
+
same two through `query_string` and `operation_name`, which carry sigs. That is
|
|
67
|
+
the spelling rubocop-sorbet allows: `mod.const_get(:QUERY)` on a `Module` is
|
|
68
|
+
`Sorbet/ConstantsFromStrings`, and the `T.unsafe` that gets around it is
|
|
69
|
+
`Sorbet/ForbidTUnsafe`.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
sig { params(mod: GraphWeaver::QueryModule).returns(String) }
|
|
73
|
+
def manifest_line(mod) = "#{mod.operation_name}: #{mod.query_string.bytesize} bytes"
|
|
74
|
+
```
|
|
75
|
+
|
|
64
76
|
A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and `hash`,
|
|
65
77
|
so a result works as a hash key), `deconstruct_keys` for pattern matching,
|
|
66
78
|
`#to_h`, and `#to_json`/`#as_json`. All of them go the whole way down a nested
|
|
@@ -167,10 +179,11 @@ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
|
167
179
|
|
|
168
180
|
**Union and interface members** are the one name that doesn't come from a key:
|
|
169
181
|
they take the type condition that produces them (`... on Book` → `Book`) inside
|
|
170
|
-
the container named for the field, plus the catch-all `Other`; a
|
|
171
|
-
out of a shared fragment is named for
|
|
172
|
-
one collapsed union type take the first
|
|
173
|
-
position-determined, all of it
|
|
182
|
+
the container named for the field, plus the catch-all `Other`; a type
|
|
183
|
+
[hoisted](#a-shared-fragment-is-one-type) out of a shared fragment is named for
|
|
184
|
+
the fragment; and several fields sharing one collapsed union type take the first
|
|
185
|
+
of their keys alphabetically. Still position-determined, all of it — a fragment
|
|
186
|
+
name is a source fact like a result key.
|
|
174
187
|
|
|
175
188
|
Two collisions are handled rather than left to surprise you. A name that would
|
|
176
189
|
shadow the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
@@ -321,6 +334,12 @@ Both spellings then cast to `Status::LegacyMode`, only the target gets a
|
|
|
321
334
|
constant, and a variable sends the target — see
|
|
322
335
|
[scalars.md](scalars.md#two-spellings-one-value).
|
|
323
336
|
|
|
337
|
+
A value the *server* adds after you generate is drift, and casting it raises by
|
|
338
|
+
default. `register_enum("Species", fallback: true)` gives the generated enum one
|
|
339
|
+
extra member, `Other`, that every undeclared wire value casts to — the same
|
|
340
|
+
catch-all an abstract type's dispatch always carries. A variable can't send it;
|
|
341
|
+
see [scalars.md](scalars.md#values-the-server-hasnt-told-you-about-yet).
|
|
342
|
+
|
|
324
343
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
325
344
|
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
|
|
326
345
|
enums into the query module itself; there's no cross-query set to share against,
|
|
@@ -369,6 +388,46 @@ schema's spelling in both directions, so the query, the request, the response,
|
|
|
369
388
|
and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
|
|
370
389
|
trailing underscore.
|
|
371
390
|
|
|
391
|
+
### A shared fragment is one type
|
|
392
|
+
|
|
393
|
+
**A whole field selected as exactly one named shared fragment is one type in
|
|
394
|
+
`GraphQLTypes`, named for the fragment, which each query aliases.** Object or
|
|
395
|
+
abstract, same sentence:
|
|
396
|
+
|
|
397
|
+
```graphql
|
|
398
|
+
# app/graphql/fragments/pet_fields.graphql
|
|
399
|
+
fragment PetFields on Pet { name species birthday }
|
|
400
|
+
|
|
401
|
+
# two queries, one Ruby type
|
|
402
|
+
query Roster { pets { ...PetFields } }
|
|
403
|
+
query Shelter { shelter { pets { ...PetFields } } }
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
GraphQLTypes::PetFields # the struct, generated once
|
|
408
|
+
RosterQuery::PetFields # the alias each query opens with
|
|
409
|
+
sig { params(pet: GraphQLTypes::PetFields).returns(String) } # nameable by your app
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
That is what makes the fragment the unit of reuse on *both* sides of the wire: a
|
|
413
|
+
presenter, a serializer or a policy can name its argument's type, and a union
|
|
414
|
+
spread this way gives one exhaustive `case … T.absurd` everywhere instead of a
|
|
415
|
+
fresh dispatch module per query.
|
|
416
|
+
|
|
417
|
+
It is opt-in by how you write the query, and only the exact shape hoists —
|
|
418
|
+
`{ ...PetFields id }`, `{ ...PetFields ...Vaccinations }`, a spread carrying
|
|
419
|
+
`@skip`/`@include`, a fragment the query file defines itself, and a fragment
|
|
420
|
+
written on some other type than the field's all stay a position-named struct
|
|
421
|
+
where they are. A spread *inside* a hoisted fragment is inlined into it rather
|
|
422
|
+
than hoisted again. Like shared inputs, it's a `generate!`-directory concern;
|
|
423
|
+
dynamic `parse` inlines everything.
|
|
424
|
+
|
|
425
|
+
Registrations follow the type, not the position, so `extend_type("Pet", …)`
|
|
426
|
+
mixins and `alias:` accessors land on the hoisted struct exactly as they would
|
|
427
|
+
on a local one. An `alias:` path on the *parent* may end on the hoisted struct
|
|
428
|
+
(`alias: { first_pet: "pets.first" }`) but not read through it — the struct
|
|
429
|
+
belongs to another module — and says so at generation.
|
|
430
|
+
|
|
372
431
|
### Abstract types
|
|
373
432
|
|
|
374
433
|
An abstract field emits **one struct per type condition the selection names**,
|
|
@@ -402,11 +461,10 @@ off `__typename` when the selection carries one unaliased and unguarded, and off
|
|
|
402
461
|
narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
|
|
403
462
|
match would be indistinguishable from a miss.
|
|
404
463
|
|
|
405
|
-
|
|
406
|
-
(`{ ...FeedItemFields }`)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
464
|
+
A whole union field selected as one named *shared* fragment
|
|
465
|
+
(`{ ...FeedItemFields }`) is [hoisted](#a-shared-fragment-is-one-type) into
|
|
466
|
+
`GraphQLTypes` like any other, so the same union is one Ruby type family across
|
|
467
|
+
queries rather than a fresh dispatch module per query.
|
|
410
468
|
|
|
411
469
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
412
470
|
|
|
@@ -438,8 +496,9 @@ you handle it.** It is exhaustive over the members *this query asked about*, plu
|
|
|
438
496
|
cover: two *differently-selected* occurrences of the same union are distinct type
|
|
439
497
|
families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
|
|
440
498
|
written for one won't span the other. Select the union through a shared fragment
|
|
441
|
-
to hold it as one type across queries
|
|
442
|
-
is
|
|
499
|
+
to hold it as one type across queries
|
|
500
|
+
([above](#a-shared-fragment-is-one-type)); if all you have is the bare tag,
|
|
501
|
+
`__typename` is the common denominator, unchecked.
|
|
443
502
|
|
|
444
503
|
## Type helpers
|
|
445
504
|
|
|
@@ -503,6 +562,22 @@ the one thing you couldn't add by hand: a `const` has no sig to put it in.
|
|
|
503
562
|
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
504
563
|
beats `# typed: false` for a helper you want checked.
|
|
505
564
|
|
|
565
|
+
**An `abstract!` mixin has a precondition: every query must select what it
|
|
566
|
+
declares.** The mixin goes into *every* struct generated from the type, so a
|
|
567
|
+
query selecting a subset has nothing to satisfy the rest, and generation refuses
|
|
568
|
+
— naming the struct, the mixin, the members, and the two ways out: select them
|
|
569
|
+
in that query, or select the type through one
|
|
570
|
+
[shared fragment](#a-shared-fragment-is-one-type), which hoists a single struct
|
|
571
|
+
for every query to share. The second is usually the answer: a shape several
|
|
572
|
+
queries lean on is a fragment.
|
|
573
|
+
|
|
574
|
+
One sig a mixin cannot write is one over a *generated* type — `sig {
|
|
575
|
+
abstract.returns(GraphQLTypes::Species) }` raises `NameError` at generation,
|
|
576
|
+
because registrations load before the enum that generation is about to write.
|
|
577
|
+
The field's sig has to be `T.untyped` there. The struct's own `const` still
|
|
578
|
+
carries the real type, so a call site reading `pet.species` is checked; only the
|
|
579
|
+
mixin's view of it isn't.
|
|
580
|
+
|
|
506
581
|
### Flat accessors with `alias:`
|
|
507
582
|
|
|
508
583
|
The one derivation the generator can type for you is a plain projection — a
|
|
@@ -528,7 +603,8 @@ alias: { label: "name", tag: "meta.tag" }
|
|
|
528
603
|
```
|
|
529
604
|
|
|
530
605
|
The path is the **Ruby** accessor chain, so its segments are snake_case props
|
|
531
|
-
(`name_with_owner.tag`), not wire names. It
|
|
606
|
+
(`name_with_owner.tag`), not wire names. It may end on a
|
|
607
|
+
[hoisted](#a-shared-fragment-is-one-type) struct but not read through one. It's typed from the selection: any
|
|
532
608
|
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
533
609
|
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
534
610
|
an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
|
|
@@ -679,8 +755,8 @@ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
|
|
|
679
755
|
generating without one fails pointing at exactly that.
|
|
680
756
|
|
|
681
757
|
**A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
|
|
682
|
-
Input types, schema enums, and
|
|
683
|
-
kind of thing — a type that would otherwise be copied into every query that
|
|
758
|
+
Input types, schema enums, and the types hoisted from shared fragments are all
|
|
759
|
+
one kind of thing — a type that would otherwise be copied into every query that
|
|
684
760
|
touches it — so they live in one module, one file each, and a query module that
|
|
685
761
|
uses any of them opens with `require_relative "types"`. Rename the constant
|
|
686
762
|
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
@@ -716,6 +792,43 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
|
|
|
716
792
|
— are in [getting started](getting_started.md#5-verify-in-ci); a
|
|
717
793
|
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
718
794
|
|
|
795
|
+
### Make your tooling treat generated/ as generated
|
|
796
|
+
|
|
797
|
+
Three readers, three tellings — and `rails g graph_weaver:install` writes the
|
|
798
|
+
two that live in files:
|
|
799
|
+
|
|
800
|
+
- **You.** Every generated file opens with `# Generated by GraphWeaver
|
|
801
|
+
<version> — do not edit.`
|
|
802
|
+
- **Rubocop.** The output directory goes into `AllCops: Exclude:` — machine-written
|
|
803
|
+
code isn't yours to style, and plain `rubocop` otherwise fires
|
|
804
|
+
`Style/Documentation` on every struct and `Metrics/*` on every `from_h`.
|
|
805
|
+
- **GitHub.** `linguist-generated` collapses these files in a pull-request diff
|
|
806
|
+
(shown as "Load diff") and drops them from the repository's language
|
|
807
|
+
breakdown. It is display-only: they stay versioned, expandable, and `git diff`
|
|
808
|
+
here is unchanged.
|
|
809
|
+
|
|
810
|
+
An app that didn't run the installer adds the two by hand:
|
|
811
|
+
|
|
812
|
+
```yaml
|
|
813
|
+
# .rubocop.yml — inherit_mode keeps rubocop's own Exclude defaults
|
|
814
|
+
AllCops:
|
|
815
|
+
inherit_mode:
|
|
816
|
+
merge:
|
|
817
|
+
- Exclude
|
|
818
|
+
Exclude:
|
|
819
|
+
- "app/graphql/generated/**/*"
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
```text
|
|
823
|
+
# .gitattributes — gitignore-style, so `dir/**` is everything beneath it
|
|
824
|
+
app/graphql/generated/** linguist-generated
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
`git check-attr linguist-generated app/graphql/generated/types.rb` answers `set`
|
|
828
|
+
when the pattern is right. One line per graph in a multi-schema app, and
|
|
829
|
+
[linguist's overrides](https://github.com/github-linguist/linguist/blob/main/docs/overrides.md)
|
|
830
|
+
has the other attributes it reads.
|
|
831
|
+
|
|
719
832
|
### Loading what it wrote
|
|
720
833
|
|
|
721
834
|
In Rails, loading is automatic — the Railtie requires every generated file at
|
data/docs/getting_started.md
CHANGED
|
@@ -6,6 +6,8 @@ Follow it once when you add the gem to an app. Rails is assumed;
|
|
|
6
6
|
[not Rails?](#not-rails) covers the differences. Exploring an API from a console
|
|
7
7
|
instead? Start with [dynamic mode](real_world.md) — no build step. No Sorbet in
|
|
8
8
|
your app? None needed — [Sorbet, with or without](#sorbet-with-or-without).
|
|
9
|
+
Already have a GraphQL client? [Migrating](migrating.md) is the order to move
|
|
10
|
+
off it in.
|
|
9
11
|
|
|
10
12
|
## 1. Install
|
|
11
13
|
|
|
@@ -96,6 +98,11 @@ What it wrote:
|
|
|
96
98
|
marked "do not edit," so the output directory is added to `AllCops: Exclude:`.
|
|
97
99
|
An `AllCops:` you already have is left alone (a second one would replace it,
|
|
98
100
|
not merge); the generator prints the line to add.
|
|
101
|
+
- **`.gitattributes`** — `app/graphql/generated/** linguist-generated`, which is
|
|
102
|
+
how GitHub is told the same thing: it collapses those files in a pull-request
|
|
103
|
+
diff and leaves them out of the repository's language breakdown. Display only,
|
|
104
|
+
so they stay versioned and expandable
|
|
105
|
+
([the three tellings](generated_modules.md#make-your-tooling-treat-generated-as-generated)).
|
|
99
106
|
|
|
100
107
|
Rake needs no wiring: in Rails the `graph_weaver:*` tasks register themselves and
|
|
101
108
|
depend on `:environment`, so your initializer runs first. The generated modules
|
|
@@ -165,10 +172,11 @@ Fragment files hold only fragments (no operations), and names are unique across
|
|
|
165
172
|
them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
|
|
166
173
|
default `app/graphql/fragments`).
|
|
167
174
|
|
|
168
|
-
One payoff: when a shared fragment *is* the whole selection on a
|
|
169
|
-
|
|
170
|
-
gets the same Ruby type — so
|
|
171
|
-
|
|
175
|
+
One payoff: when a shared fragment *is* the whole selection on a field, its type
|
|
176
|
+
is hoisted once into `GraphQLTypes` under the fragment's name and every query
|
|
177
|
+
that spreads it gets the same Ruby type — so a presenter can take a
|
|
178
|
+
`GraphQLTypes::PersonFields`, and a union's `case … T.absurd` works everywhere.
|
|
179
|
+
See [hoisting](generated_modules.md#a-shared-fragment-is-one-type).
|
|
172
180
|
|
|
173
181
|
## 4. Test against fakes
|
|
174
182
|
|
|
@@ -216,9 +224,10 @@ Five questions, five tasks — the last only on a federated graph:
|
|
|
216
224
|
Every one of them exits non-zero on a finding, so the gate is a chain.
|
|
217
225
|
`verify` compares the committed generated files against what the current schema
|
|
218
226
|
+ queries + registrations would produce, so it belongs in every CI build.
|
|
219
|
-
`schema:diff` asks whatever the dump came from —
|
|
220
|
-
`GRAPHWEAVER_AUTH` for private APIs),
|
|
221
|
-
[serves the schema itself](#your-apps-own-schema-in-process)
|
|
227
|
+
`schema:diff` asks whatever the dump came from — the source url the dump
|
|
228
|
+
recorded (with `GRAPHWEAVER_AUTH` for private APIs), your own schema class when
|
|
229
|
+
the app [serves the schema itself](#your-apps-own-schema-in-process), else the
|
|
230
|
+
client this graph's modules already post to — and
|
|
222
231
|
`rake graph_weaver:schema:refresh` is the repair either way. On an app with more
|
|
223
232
|
than one schema, `rake graph_weaver:graphs` lists which graphs are configured,
|
|
224
233
|
where each generates, and what each registers.
|
|
@@ -283,8 +292,8 @@ omits it. Any drift exits non-zero — whether a change matters is yours to judg
|
|
|
283
292
|
(`#breaking`, `#compatible`, `#to_h`, `#empty?`).
|
|
284
293
|
|
|
285
294
|
`queries:check` answers the question that matters when the schema *has* moved:
|
|
286
|
-
**which of your queries no longer validate, and why.** It re-introspects the
|
|
287
|
-
|
|
295
|
+
**which of your queries no longer validate, and why.** It re-introspects the url
|
|
296
|
+
the dump records (without rewriting the dump) and validates every `.graphql` file
|
|
288
297
|
against the schema as it is right now, naming each error's line and column:
|
|
289
298
|
|
|
290
299
|
```
|
|
@@ -294,6 +303,12 @@ app/graphql/queries/person.graphql
|
|
|
294
303
|
1 invalid query
|
|
295
304
|
```
|
|
296
305
|
|
|
306
|
+
A dump that records no url has nothing to re-read, and so does a graph that
|
|
307
|
+
names its own schema: both are checked as they stand on disk. That is `verify`'s
|
|
308
|
+
question rather than this one's, so the passing verdict names the file instead of
|
|
309
|
+
claiming a check against the server ([a dump you already
|
|
310
|
+
have](#a-schema-dump-you-already-have)).
|
|
311
|
+
|
|
297
312
|
`GraphWeaver.check_queries` returns the same findings as data — a hash of file
|
|
298
313
|
to `message`/`line`/`column`, empty when everything validates — so you can wire
|
|
299
314
|
it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
|
|
@@ -331,9 +346,11 @@ app/graphql/queries/products.graphql: Products.sku — selected, never read (Cat
|
|
|
331
346
|
Each line names the query file, the selection to go and delete, and the
|
|
332
347
|
generated prop behind it. It reads the generated structs for the props a query
|
|
333
348
|
produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
|
|
334
|
-
and `.jbuilder`
|
|
335
|
-
`
|
|
336
|
-
|
|
349
|
+
and `.jbuilder` — plus Ruby carrying no extension, which is any name under
|
|
350
|
+
`bin/` or `exe/` and a ruby shebang anywhere else — for every name they could be
|
|
351
|
+
read by: `.sku`, `sku:`, `:sku`, `"sku"`. `PATHS=app,lib` narrows the sweep (a
|
|
352
|
+
`PATHS=` naming a directory that isn't there is refused rather than swept as
|
|
353
|
+
nothing); anything under a directory
|
|
337
354
|
named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
|
|
338
355
|
as is any file defining a graphql-ruby **type** — a `field :sku` there is your
|
|
339
356
|
*server* offering a field, not this app reading one back. A module handed whole
|
|
@@ -416,6 +433,27 @@ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
|
|
|
416
433
|
and introspects nothing. A dump has no resolvers, so it can't execute — set
|
|
417
434
|
`GraphWeaver.client` to whatever serves the API.
|
|
418
435
|
|
|
436
|
+
**A dump you brought from elsewhere has no provenance**: it doesn't record the
|
|
437
|
+
server it was introspected from, which is exactly what migrating off
|
|
438
|
+
graphql-client leaves you ([migrating](migrating.md)). `rake
|
|
439
|
+
graph_weaver:schema:refresh` adopts it — with no url on the file it introspects
|
|
440
|
+
the client the graph names, rewrites the dump, and records the source, so every
|
|
441
|
+
later refresh and `schema:diff` re-read the right server. `URL=` names the
|
|
442
|
+
endpoint instead, if you'd rather say it once than configure the client first.
|
|
443
|
+
|
|
444
|
+
Do it early. Until the dump records a source, `queries:check` has nothing to
|
|
445
|
+
re-introspect, so it validates against the committed file — a real check, but
|
|
446
|
+
`verify`'s question rather than this one's — and the verdict says which:
|
|
447
|
+
|
|
448
|
+
```
|
|
449
|
+
every query validates against db/schema.graphql as committed — not the server (rake graph_weaver:schema:diff asks whether the server moved)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
A dump with no recorded url and no client behind it *is* the schema — a
|
|
453
|
+
hand-maintained SDL nothing serves. `schema:refresh` leaves that one alone
|
|
454
|
+
(`records no source url and the graph names no client — left as checked in`) and
|
|
455
|
+
refreshes the rest, rather than taking the whole task down with it.
|
|
456
|
+
|
|
419
457
|
## More than one schema
|
|
420
458
|
|
|
421
459
|
The five steps above describe one graph — a schema, its queries, its output. An
|
|
@@ -484,8 +522,9 @@ An app that is a pure client of someone else's GraphQL owns no schema class, so
|
|
|
484
522
|
every graph's `schema` is a dump. Give each the file you want and a `client`
|
|
485
523
|
that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
|
|
486
524
|
client into its own dump, recording the url so every later `schema:refresh` and
|
|
487
|
-
`schema:diff` re-reads the right server.
|
|
488
|
-
|
|
525
|
+
`schema:diff` re-reads the right server. A graph with neither a recorded url nor
|
|
526
|
+
a client is left as checked in and the rest still refresh. (`URL=` is for the
|
|
527
|
+
app that has one dump and no graphs.)
|
|
489
528
|
|
|
490
529
|
In specs, `graph:` is how an example says which graph a helper stands in for —
|
|
491
530
|
`graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
|
|
@@ -527,6 +566,106 @@ per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
|
527
566
|
time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
|
|
528
567
|
true`/`false`.
|
|
529
568
|
|
|
569
|
+
### The types stop where your sigs do
|
|
570
|
+
|
|
571
|
+
That typo is caught where the struct comes back. Hand the struct to one method
|
|
572
|
+
without a sig and it is `T.untyped` from there on — the same typo, one layer in,
|
|
573
|
+
is silent — and a sig on the method that produced it is no help either:
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
# typed: true
|
|
577
|
+
class PersonDirectory
|
|
578
|
+
extend T::Sig
|
|
579
|
+
|
|
580
|
+
sig { returns(T.nilable(PersonQuery::Result::Person)) }
|
|
581
|
+
def person = PersonQuery.execute!(id: "1").person
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
class Profile
|
|
585
|
+
def initialize(person) # no sig, so @person is untyped
|
|
586
|
+
@person = person
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def title = @person.nmae # "No errors! Great job."
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
class Page
|
|
593
|
+
def initialize(directory) # nor is the sig above any help here
|
|
594
|
+
@directory = directory
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def title = @directory.person&.nmae # also clean
|
|
598
|
+
end
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Every hop has to be sig'd: the method that produces the struct, any wrapper it
|
|
602
|
+
passes through, and the constructor that stored the collaborator. Two shapes
|
|
603
|
+
cover most of an app.
|
|
604
|
+
|
|
605
|
+
A wrapper that only passes a block through keeps the block's type with
|
|
606
|
+
`type_parameters`:
|
|
607
|
+
|
|
608
|
+
```ruby
|
|
609
|
+
# a rescue wrapper that keeps the block's type
|
|
610
|
+
sig do
|
|
611
|
+
type_parameters(:T).params(blk: T.proc.returns(T.type_parameter(:T)))
|
|
612
|
+
.returns(T.type_parameter(:T))
|
|
613
|
+
end
|
|
614
|
+
def people(&blk) = yield
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
A service boundary spells the struct it hands out, and whoever holds that
|
|
618
|
+
service types the ivar:
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
class Page
|
|
622
|
+
extend T::Sig
|
|
623
|
+
|
|
624
|
+
sig { params(directory: PersonDirectory).void }
|
|
625
|
+
def initialize(directory)
|
|
626
|
+
@directory = directory # the sig is what types this ivar
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
sig { returns(T.nilable(String)) }
|
|
630
|
+
def title = @directory.person&.nmae # now srb tc has it
|
|
631
|
+
end
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
That is sigs on half a dozen service methods and a couple of constructors before
|
|
635
|
+
the first typo in a layered app is caught. Budget it as its own piece of work
|
|
636
|
+
rather than as part of generating the types — and if it isn't work you're going
|
|
637
|
+
to do, the runtime half still holds: a bad field is a `NoMethodError` the first
|
|
638
|
+
time the line runs.
|
|
639
|
+
|
|
640
|
+
### Give a struct a short name where you name one
|
|
641
|
+
|
|
642
|
+
A generated struct's constant path spells out the query that produced it, which
|
|
643
|
+
is what makes it stable — and long. Where your own code names one in a sig, alias
|
|
644
|
+
it once, in the class that hands it out:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
# before
|
|
648
|
+
sig { params(code: String).returns(T.nilable(Countries::CountryProfileQuery::Result::Country)) }
|
|
649
|
+
|
|
650
|
+
# after
|
|
651
|
+
class Directory
|
|
652
|
+
Country = Countries::CountryProfileQuery::Result::Country
|
|
653
|
+
|
|
654
|
+
sig { params(code: String).returns(T.nilable(Country)) }
|
|
655
|
+
end
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Use a plain constant, not `T.type_alias`: the constant works in a sig *and* as a
|
|
659
|
+
value (`Country.from_h`), and in a `# typed: strict` file it needs no `T.let`
|
|
660
|
+
around it. Reach for `T.type_alias` only where a constant can't express the type
|
|
661
|
+
— a union of two queries' structs.
|
|
662
|
+
|
|
663
|
+
That takes the worst sig in one migrated app from 116 characters to 72, and the
|
|
664
|
+
callers get the short name too: a presenter takes a `Directory::Country` and
|
|
665
|
+
stops knowing which `.graphql` file produced it. It is also what makes the sig
|
|
666
|
+
chain above affordable, since a sig usually goes unwritten because the line is
|
|
667
|
+
too long to want to write.
|
|
668
|
+
|
|
530
669
|
## Not Rails?
|
|
531
670
|
|
|
532
671
|
There's no generator, but what it writes is short — a few lines wherever your
|
|
@@ -541,6 +680,12 @@ GraphWeaver.client = GraphWeaver.new(
|
|
|
541
680
|
GraphWeaver.load_generated! # no Railtie to require the generated files
|
|
542
681
|
```
|
|
543
682
|
|
|
683
|
+
**`load_generated!` goes before your own requires** when anything your app loads
|
|
684
|
+
names a generated constant as it loads — a `STATUS_LABELS` table keyed on
|
|
685
|
+
`GraphQLTypes::ShipmentStatus` raises `uninitialized constant` otherwise, and
|
|
686
|
+
building it lazily to dodge that earns `Dynamic constant references are
|
|
687
|
+
unsupported` from `srb tc`. One line of ordering settles both.
|
|
688
|
+
|
|
544
689
|
```sh
|
|
545
690
|
mkdir -p app/graphql/queries app/graphql/generated
|
|
546
691
|
rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
|
data/docs/migrating.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Migrating from graphql-client (or a hand-rolled client)
|
|
2
|
+
|
|
3
|
+
**The wire doesn't change.** The same query text goes out and the same JSON comes
|
|
4
|
+
back, so this is a sequence of small commits rather than a cutover: queries move
|
|
5
|
+
across one at a time while the old client keeps serving, and most of the spec
|
|
6
|
+
suite you already have keeps passing untouched.
|
|
7
|
+
|
|
8
|
+
Budget 3–4 weeks of one engineer for an app with ~40 queries and 25k lines of
|
|
9
|
+
code reading them — [what it costs](#what-it-costs) breaks that down. If the app
|
|
10
|
+
doesn't run Sorbet and has fewer than ten queries, don't migrate: the wins that
|
|
11
|
+
survive without `srb tc` are real, but they don't pay for a checked-in code
|
|
12
|
+
generator ([alternatives](alternatives.md#graphlient)).
|
|
13
|
+
|
|
14
|
+
## From graphql-client, in Rails
|
|
15
|
+
|
|
16
|
+
**Both gems stay in the Gemfile until the last commit.** `rails g
|
|
17
|
+
graph_weaver:install` boots the app to read your configuration, so an initializer
|
|
18
|
+
that still requires graphql-client has to keep resolving — remove the gem first
|
|
19
|
+
and the generator dies on `cannot load such file -- graphql/client`. Running the
|
|
20
|
+
two side by side is the right shape anyway.
|
|
21
|
+
|
|
22
|
+
1. **Install beside graphql-client**, pointed at the dump you already have:
|
|
23
|
+
`rails g graph_weaver:install db/petstore_schema.json` ([a schema dump you
|
|
24
|
+
already have](getting_started.md#a-schema-dump-you-already-have)). Commit that
|
|
25
|
+
and nothing else.
|
|
26
|
+
2. **Adopt the dump.** It records no source url, so `schema:diff` and
|
|
27
|
+
`queries:check` have no server to ask. `rake graph_weaver:schema:refresh`
|
|
28
|
+
rewrites it from the client the app points at (or `URL=<your endpoint>` once)
|
|
29
|
+
and records the provenance. Do it now: until you do, `queries:check` is
|
|
30
|
+
re-reading the file it is meant to be checking against, and says so.
|
|
31
|
+
3. **Register scalars and enums, and regenerate**, before porting any code.
|
|
32
|
+
Registrations are baked into generated source, so one added later reaches
|
|
33
|
+
nothing until the next `rake graph_weaver:generate` ([scalars](scalars.md)).
|
|
34
|
+
4. **Port one query end to end** — write the `.graphql` file, generate, rewrite
|
|
35
|
+
its call site — and leave the specs alone. They pass untouched. That single
|
|
36
|
+
commit is the proof for every one after it.
|
|
37
|
+
5. **Port the rest, one commit per query.** Each `Client.parse` constant stays
|
|
38
|
+
where it is until its last caller is gone.
|
|
39
|
+
6. **Only now port the specs** to `graphql: :fake` ([testing](testing.md)). Keep
|
|
40
|
+
webmock until the last wire-level stub is gone, and keep one spec that really
|
|
41
|
+
serves HTTP.
|
|
42
|
+
7. **Delete graphql-client.** Last commit.
|
|
43
|
+
8. **Sigs through your own app are a separate project**, after all of the above —
|
|
44
|
+
[the types stop where your sigs do](getting_started.md#the-types-stop-where-your-sigs-do)
|
|
45
|
+
says what that work is.
|
|
46
|
+
|
|
47
|
+
Keep your own error classes. Translating `GraphWeaver::QueryError`,
|
|
48
|
+
`TransportError` and `ServerError` at one seam leaves a controller, its rescues
|
|
49
|
+
and their specs untouched ([errors](errors.md)). Keep your presenters too, and
|
|
50
|
+
your fixtures until step 6.
|
|
51
|
+
|
|
52
|
+
### Why app code and specs are separate commits
|
|
53
|
+
|
|
54
|
+
Because the wire is the same, a webmock suite doesn't notice which client sent
|
|
55
|
+
the request. Swap the client under an untouched suite and most of it stays green
|
|
56
|
+
— 18 of 22 stubbed examples in one migrated Rails app, same stubs, same JSON
|
|
57
|
+
fixtures; the four that failed were unit specs feeding a hand-rolled `Struct` to
|
|
58
|
+
a presenter. That is the difference between a reviewable migration and a big-bang
|
|
59
|
+
one.
|
|
60
|
+
|
|
61
|
+
## From a hand-rolled client
|
|
62
|
+
|
|
63
|
+
Same skeleton, three differences.
|
|
64
|
+
|
|
65
|
+
- **One branch, no seam.** There's no generator to keep bootable and a
|
|
66
|
+
hand-rolled client is sixty lines, so running two of them side by side costs
|
|
67
|
+
more than it saves.
|
|
68
|
+
- **Set the load order first**: `GraphWeaver.load_generated!` goes before your own
|
|
69
|
+
requires ([not Rails?](getting_started.md#not-rails)). Add `rake` to the Gemfile
|
|
70
|
+
while you're there.
|
|
71
|
+
- **Keep the old client's transport specs**, rewritten against a real
|
|
72
|
+
`GraphWeaver.new(url, retries: 2)` with webmock. Retries, timeouts and backoff
|
|
73
|
+
are the one thing fakes can't cover.
|
|
74
|
+
|
|
75
|
+
## What you delete
|
|
76
|
+
|
|
77
|
+
- **The retry loop.** A hand-rolled one usually retries everything, mutations
|
|
78
|
+
included. `retries: 2` excludes mutations by default, honours `Retry-After`,
|
|
79
|
+
and knows 408 and 429 ([retries](transports.md#retries)) — twenty lines gone and
|
|
80
|
+
a correctness bug gone with them.
|
|
81
|
+
- **Scalar parsers.** `register_scalar("Money", Money)` is one line, and it
|
|
82
|
+
deletes the `"USD 12.50".split` sitting in every place that parsed one. `Date`
|
|
83
|
+
and `DateTime` need no registration at all ([scalars](scalars.md)).
|
|
84
|
+
- **Fragment unwrapping.** graphql-client masks a spread fragment's fields on the
|
|
85
|
+
parent, so every call site reads `Petstore::PetFields.new(data.pet).name` and
|
|
86
|
+
carries the parent alongside for the fields the fragment didn't cover. Generated
|
|
87
|
+
structs inline the fragment, so it's `pet.name`; a fragment that is a whole
|
|
88
|
+
selection becomes one shared Ruby type
|
|
89
|
+
([hoisting](generated_modules.md#a-shared-fragment-is-one-type)).
|
|
90
|
+
- **JSON fixtures and stub helpers.** `graphql: :fake` fabricates a
|
|
91
|
+
schema-correct response and you pin the fields the example is about. Pins are
|
|
92
|
+
schema vocabulary (`"Pet.species" => "DOG"`), so they survive query refactors,
|
|
93
|
+
and they're spellchecked — a typo raises instead of leaving the example green
|
|
94
|
+
against random data ([testing](testing.md)).
|
|
95
|
+
|
|
96
|
+
## The one behaviour change to plan for
|
|
97
|
+
|
|
98
|
+
**Enum drift is fatal.** A hand-rolled client hands you the raw string, so a value
|
|
99
|
+
the server added after you shipped falls through to whatever your code does with
|
|
100
|
+
an unknown one — commonly a `humanize`. A generated enum refuses instead: casting
|
|
101
|
+
raises and the whole response is lost, naming the value, the enum and the values
|
|
102
|
+
it knows about.
|
|
103
|
+
|
|
104
|
+
That is the right default for an API you own and a real risk for one you don't,
|
|
105
|
+
because it breaks on a day nobody deployed. Before you port a query over an enum
|
|
106
|
+
someone else can extend, register a fallback for it and unknown values land there
|
|
107
|
+
instead — [enums](scalars.md#enums-map-onto-your-own-tenum).
|
|
108
|
+
|
|
109
|
+
## What it costs
|
|
110
|
+
|
|
111
|
+
| | how it scales | ~40 queries, 25k lines |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `.graphql` files and generating | sublinear — the second query costs a tenth of the first | 2–3 days |
|
|
114
|
+
| call sites | linear, and the bulk of it: every `data["x"]["y"]` becomes `x.y`, and every defensive `nil` guard either disappears or turns out to have been wrong | ~1 day per 3–4k lines of consuming code |
|
|
115
|
+
| specs | linear, and pleasant — the fixture-to-pin conversion is the same edit every time | ~2 days |
|
|
116
|
+
| sigs through your own app | the one that decides whether the migration paid off | weeks; a separate project |
|
|
117
|
+
|
|
118
|
+
Coming off graphql-client, run the new one behind the old for the first two
|
|
119
|
+
weeks. The wire compatibility makes that free.
|