graph_weaver 0.7.3 → 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/federation.md +3 -2
- data/docs/generated_modules.md +176 -22
- data/docs/getting_started.md +174 -14
- data/docs/i18n.md +4 -4
- data/docs/migrating.md +119 -0
- data/docs/scalars.md +161 -35
- data/docs/testing.md +24 -3
- data/docs/upgrading.md +51 -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/generators/graph_weaver/install_generator.rb +32 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/aliases.rb +23 -2
- data/lib/graph_weaver/codegen/emit.rb +35 -16
- data/lib/graph_weaver/codegen/enum_type.rb +149 -19
- data/lib/graph_weaver/codegen/nodes.rb +72 -37
- 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 +259 -106
- data/lib/graph_weaver/coerce.rb +25 -6
- data/lib/graph_weaver/federation.rb +1 -6
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +23 -5
- data/lib/graph_weaver/in_process.rb +1 -3
- data/lib/graph_weaver/input_struct.rb +31 -10
- 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 +12 -4
- data/lib/graph_weaver/internal.rb +84 -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 +101 -1
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +80 -74
- 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/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
|
@@ -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
|
|
@@ -308,6 +321,25 @@ camelizes to nothing — `_` and `__` are both legal GraphQL — is refused at
|
|
|
308
321
|
generation: there is no constant to name it. Map the enum onto one of yours
|
|
309
322
|
instead.
|
|
310
323
|
|
|
324
|
+
Two values that camelize to the *same* constant are refused for the same reason.
|
|
325
|
+
A schema mid-rename declares exactly that — `LEGACY_MODE` alongside
|
|
326
|
+
`legacy_mode`, so old clients keep working — and if they are one value, say
|
|
327
|
+
which spelling goes on the wire:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Both spellings then cast to `Status::LegacyMode`, only the target gets a
|
|
334
|
+
constant, and a variable sends the target — see
|
|
335
|
+
[scalars.md](scalars.md#two-spellings-one-value).
|
|
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
|
+
|
|
311
343
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
312
344
|
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
|
|
313
345
|
enums into the query module itself; there's no cross-query set to share against,
|
|
@@ -356,6 +388,46 @@ schema's spelling in both directions, so the query, the request, the response,
|
|
|
356
388
|
and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
|
|
357
389
|
trailing underscore.
|
|
358
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
|
+
|
|
359
431
|
### Abstract types
|
|
360
432
|
|
|
361
433
|
An abstract field emits **one struct per type condition the selection names**,
|
|
@@ -389,11 +461,10 @@ off `__typename` when the selection carries one unaliased and unguarded, and off
|
|
|
389
461
|
narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
|
|
390
462
|
match would be indistinguishable from a miss.
|
|
391
463
|
|
|
392
|
-
|
|
393
|
-
(`{ ...FeedItemFields }`)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
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.
|
|
397
468
|
|
|
398
469
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
399
470
|
|
|
@@ -425,8 +496,9 @@ you handle it.** It is exhaustive over the members *this query asked about*, plu
|
|
|
425
496
|
cover: two *differently-selected* occurrences of the same union are distinct type
|
|
426
497
|
families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
|
|
427
498
|
written for one won't span the other. Select the union through a shared fragment
|
|
428
|
-
to hold it as one type across queries
|
|
429
|
-
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.
|
|
430
502
|
|
|
431
503
|
## Type helpers
|
|
432
504
|
|
|
@@ -470,19 +542,42 @@ The name is where the block is written and what it extends:
|
|
|
470
542
|
`GraphWeaver::TypeHelpers::Billing::Pet` inside `GraphWeaver.graph :billing`.
|
|
471
543
|
Generated code spells it, so it depends on your source and nothing else — two
|
|
472
544
|
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
|
-
|
|
545
|
+
one a boot creates. The module is minted at registration, so no file declares
|
|
546
|
+
it; generation writes a `type_helpers.rbi` beside the modules that does, which
|
|
547
|
+
is what lets your `srb tc` resolve the `include`. Ruby never loads an `.rbi`, so
|
|
548
|
+
dropping the registration still fails loudly at require rather than quietly
|
|
549
|
+
handing the struct an empty module.
|
|
550
|
+
|
|
551
|
+
**Neither form has its method bodies statically checked**, for the same reason:
|
|
552
|
+
`srb tc` checks a mixin's method bodies in the module's own scope, not the
|
|
553
|
+
including struct's, so a helper reading a wire field (`name`, `birthday`) fails
|
|
554
|
+
with "method does not exist on the module" — and the block form has no source on
|
|
555
|
+
disk for `srb tc` to read at all. A *named* module can carry real sigs, though, by
|
|
480
556
|
declaring the fields it leans on: `abstract!` plus a
|
|
481
557
|
`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
|
|
558
|
+
includes me has these", and the struct's `const`s satisfy them — generation
|
|
559
|
+
declares the override Sorbet demands there (`const :name, String, override:
|
|
560
|
+
true`, and `sig { override.returns(...) }` on an `alias:` accessor), which is
|
|
561
|
+
the one thing you couldn't add by hand: a `const` has no sig to put it in.
|
|
483
562
|
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
484
563
|
beats `# typed: false` for a helper you want checked.
|
|
485
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
|
+
|
|
486
581
|
### Flat accessors with `alias:`
|
|
487
582
|
|
|
488
583
|
The one derivation the generator can type for you is a plain projection — a
|
|
@@ -508,7 +603,8 @@ alias: { label: "name", tag: "meta.tag" }
|
|
|
508
603
|
```
|
|
509
604
|
|
|
510
605
|
The path is the **Ruby** accessor chain, so its segments are snake_case props
|
|
511
|
-
(`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
|
|
512
608
|
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
513
609
|
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
514
610
|
an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
|
|
@@ -534,6 +630,26 @@ excuses a field the query didn't select, not a segment the schema doesn't have:
|
|
|
534
630
|
typo or a wire-cased name (`findPets` for `find_pets`) still raises, since no
|
|
535
631
|
selection could ever satisfy it.
|
|
536
632
|
|
|
633
|
+
**One call does both halves.** `alias:` and a block are independent parts of the
|
|
634
|
+
same registration — the accessor is emitted into the struct body, the block
|
|
635
|
+
becomes a mixin the struct includes — so a block method can call an accessor the
|
|
636
|
+
same call declared. Inside the block the alias is spelled `alias_field`, which
|
|
637
|
+
is the same keyword said next to the methods that use it: same paths, same
|
|
638
|
+
errors, same accessor. Two positions, one thing.
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
GraphWeaver.extend_type("Widget") do
|
|
642
|
+
alias_field :tag, "meta.tag" # or alias_field "meta.tag" — accessor named `tag`
|
|
643
|
+
def shout = tag&.upcase
|
|
644
|
+
end
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
The verb is `alias_field` because the block can't spell `alias` itself: it is a
|
|
648
|
+
Ruby keyword, and the block is `module_eval`'d Ruby, so writing it there would
|
|
649
|
+
define a method alias rather than a projection. A block says one alias per line
|
|
650
|
+
and nothing else — the compact `{ }`/`[ ]` forms and `optional: true` stay on
|
|
651
|
+
the keyword, since leniency describes the registration rather than one accessor.
|
|
652
|
+
|
|
537
653
|
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
538
654
|
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
539
655
|
bodies. Every form above, and every error it raises, is a named example in
|
|
@@ -602,6 +718,7 @@ app/graphql/
|
|
|
602
718
|
generated/
|
|
603
719
|
types.rb # manifest: requires + forward declarations, in load order
|
|
604
720
|
types/ # one file per shared type
|
|
721
|
+
type_helpers.rbi # only with a block-form extend_type — declares its module
|
|
605
722
|
*_query.rb # one module per query — generated, checked in, never edited
|
|
606
723
|
*_mutation.rb # ...and per mutation
|
|
607
724
|
```
|
|
@@ -638,8 +755,8 @@ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
|
|
|
638
755
|
generating without one fails pointing at exactly that.
|
|
639
756
|
|
|
640
757
|
**A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
|
|
641
|
-
Input types, schema enums, and
|
|
642
|
-
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
|
|
643
760
|
touches it — so they live in one module, one file each, and a query module that
|
|
644
761
|
uses any of them opens with `require_relative "types"`. Rename the constant
|
|
645
762
|
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
@@ -675,6 +792,43 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
|
|
|
675
792
|
— are in [getting started](getting_started.md#5-verify-in-ci); a
|
|
676
793
|
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
677
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
|
+
|
|
678
832
|
### Loading what it wrote
|
|
679
833
|
|
|
680
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,12 +303,33 @@ 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
|
|
300
315
|
a path) and nothing touches the network, which is how you check your queries
|
|
301
316
|
against a proposed subgraph before it's live.
|
|
302
317
|
|
|
318
|
+
A query you have as a **string** rather than on disk asks the same client the
|
|
319
|
+
same question, and gets the same entries back:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
client.check_query('query($id: ID!) { person(id: $id) { nmae } }')
|
|
323
|
+
# => [{ "message" => "Field 'nmae' doesn't exist on type 'Person' (Did you mean `name`?)",
|
|
324
|
+
# "line" => 1, "column" => 37 }]
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Empty means it validates. It checks against that client's own schema — the one
|
|
328
|
+
`execute` would run against — so nothing re-introspects, and an unparseable
|
|
329
|
+
source comes back as an entry rather than an exception. Shared fragments are
|
|
330
|
+
inlined from `fragments:`, defaulting to `GraphWeaver.fragments_paths` the way
|
|
331
|
+
`parse` does.
|
|
332
|
+
|
|
303
333
|
### The selections nothing reads
|
|
304
334
|
|
|
305
335
|
`rake graph_weaver:unused` asks the one question the others can't: not "is the
|
|
@@ -316,9 +346,11 @@ app/graphql/queries/products.graphql: Products.sku — selected, never read (Cat
|
|
|
316
346
|
Each line names the query file, the selection to go and delete, and the
|
|
317
347
|
generated prop behind it. It reads the generated structs for the props a query
|
|
318
348
|
produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
|
|
319
|
-
and `.jbuilder`
|
|
320
|
-
`
|
|
321
|
-
|
|
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
|
|
322
354
|
named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
|
|
323
355
|
as is any file defining a graphql-ruby **type** — a `field :sku` there is your
|
|
324
356
|
*server* offering a field, not this app reading one back. A module handed whole
|
|
@@ -401,6 +433,27 @@ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
|
|
|
401
433
|
and introspects nothing. A dump has no resolvers, so it can't execute — set
|
|
402
434
|
`GraphWeaver.client` to whatever serves the API.
|
|
403
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
|
+
|
|
404
457
|
## More than one schema
|
|
405
458
|
|
|
406
459
|
The five steps above describe one graph — a schema, its queries, its output. An
|
|
@@ -469,8 +522,9 @@ An app that is a pure client of someone else's GraphQL owns no schema class, so
|
|
|
469
522
|
every graph's `schema` is a dump. Give each the file you want and a `client`
|
|
470
523
|
that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
|
|
471
524
|
client into its own dump, recording the url so every later `schema:refresh` and
|
|
472
|
-
`schema:diff` re-reads the right server.
|
|
473
|
-
|
|
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.)
|
|
474
528
|
|
|
475
529
|
In specs, `graph:` is how an example says which graph a helper stands in for —
|
|
476
530
|
`graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
|
|
@@ -512,6 +566,106 @@ per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
|
512
566
|
time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
|
|
513
567
|
true`/`false`.
|
|
514
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
|
+
|
|
515
669
|
## Not Rails?
|
|
516
670
|
|
|
517
671
|
There's no generator, but what it writes is short — a few lines wherever your
|
|
@@ -526,6 +680,12 @@ GraphWeaver.client = GraphWeaver.new(
|
|
|
526
680
|
GraphWeaver.load_generated! # no Railtie to require the generated files
|
|
527
681
|
```
|
|
528
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
|
+
|
|
529
689
|
```sh
|
|
530
690
|
mkdir -p app/graphql/queries app/graphql/generated
|
|
531
691
|
rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
|
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
|