graph_weaver 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +12 -5
  5. data/docs/generated_modules.md +134 -17
  6. data/docs/getting_started.md +182 -20
  7. data/docs/logging.md +79 -35
  8. data/docs/migrating.md +126 -0
  9. data/docs/scalars.md +50 -6
  10. data/docs/testing.md +78 -14
  11. data/docs/upgrading.md +44 -2
  12. data/examples/README.md +4 -2
  13. data/examples/github/generate.rb +22 -8
  14. data/examples/github/generated/star_mutation.rb +2 -2
  15. data/examples/github/generated/stargazers_query.rb +2 -2
  16. data/examples/github/generated/starred_query.rb +2 -2
  17. data/examples/github/run.rb +1 -0
  18. data/examples/github/setup.rb +16 -8
  19. data/graph_weaver.gemspec +15 -6
  20. data/lib/generators/graph_weaver/install_generator.rb +49 -2
  21. data/lib/graph_weaver/client.rb +0 -23
  22. data/lib/graph_weaver/codegen/aliases.rb +36 -3
  23. data/lib/graph_weaver/codegen/emit.rb +20 -15
  24. data/lib/graph_weaver/codegen/enum_type.rb +52 -11
  25. data/lib/graph_weaver/codegen/nodes.rb +75 -32
  26. data/lib/graph_weaver/codegen.rb +260 -100
  27. data/lib/graph_weaver/coerce.rb +1 -1
  28. data/lib/graph_weaver/federation.rb +1 -6
  29. data/lib/graph_weaver/graph.rb +55 -5
  30. data/lib/graph_weaver/hints.rb +20 -5
  31. data/lib/graph_weaver/in_process.rb +2 -4
  32. data/lib/graph_weaver/input_struct.rb +50 -10
  33. data/lib/graph_weaver/internal/overrides.rb +126 -14
  34. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  35. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  36. data/lib/graph_weaver/internal/unused.rb +62 -18
  37. data/lib/graph_weaver/internal/values.rb +24 -7
  38. data/lib/graph_weaver/internal.rb +23 -6
  39. data/lib/graph_weaver/log_subscriber.rb +27 -17
  40. data/lib/graph_weaver/logging.rb +115 -82
  41. data/lib/graph_weaver/parsing.rb +32 -3
  42. data/lib/graph_weaver/query_module.rb +67 -12
  43. data/lib/graph_weaver/railtie.rb +7 -2
  44. data/lib/graph_weaver/rspec.rb +41 -18
  45. data/lib/graph_weaver/schema_diff.rb +24 -5
  46. data/lib/graph_weaver/schema_loader.rb +29 -17
  47. data/lib/graph_weaver/tasks.rb +98 -16
  48. data/lib/graph_weaver/testing/fake_client.rb +28 -31
  49. data/lib/graph_weaver/testing/router.rb +26 -25
  50. data/lib/graph_weaver/testing.rb +27 -8
  51. data/lib/graph_weaver/transport.rb +1 -1
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +72 -42
  54. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f631eac2f0c9bbd78b50cfe1b7a0178911a8939cc697d57328bc63b3b60cd0d7
4
- data.tar.gz: 771837c86ab744d47de6b1f58e990575ee7618d9cb362e871614f6aeebcded18
3
+ metadata.gz: 3a68eaeb8cd0f3139cc9e50b88d98a50e04acd0c2c94c7825233f04405d30c42
4
+ data.tar.gz: '08df5051075500cb81c710cd65705f387dec16bc29a626e2b8069bbf5c61b3a1'
5
5
  SHA512:
6
- metadata.gz: 8f24e3485d8ff839de3a9fd626b2c0038c0617c322b6249742aa90eae3552073d3cbf3335de430364072bfa990304689a7e9ad3db652ae527d46ac711573a276
7
- data.tar.gz: a484029fbbdd141af8be0586d8a830e2439f13fdbbc1b9f4826840f289af3e000a81457749657e5573cd67c4ae2017aa2929bfb34a300e9777669b9265c22eb5
6
+ metadata.gz: ee7b2971697409f7180e31d40569fd44108404c0f1d811b259bec6e000f7cf82142e7640de164510d1dbd253916392c6cc866619ee2d37e35e610b1ed0be9741
7
+ data.tar.gz: 9d219008a1db9bc353ef3b033b9c39f64657e58d1d2548fb074fc1a60973052e4abd0c1950d93622ad1cfc0414e7ca1219d9c770e32e445cedba287636f58a73
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.7.4)
4
+ graph_weaver (0.7.6)
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.4)
302
+ graph_weaver (0.7.6)
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
@@ -106,6 +106,7 @@ subclass says where it failed:
106
106
  | `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#kind`, `#path`, `#coordinate`, `#value`, `#details`, `#field`, `#struct` |
107
107
  | `QueryValidationError` | build time: the query didn't validate against the schema |
108
108
  | `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
109
+ | `Codegen::Aliases::HoistedSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path reads *into* a struct [hoisted](generated_modules.md#a-shared-fragment-is-one-type) from a shared fragment — `optional: true` skips it, but only at the cost of the accessor for every query that spreads the fragment |
109
110
  | `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
110
111
  | `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
111
112
  | `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
@@ -172,7 +173,10 @@ A nested filter reports the innermost input type, so the error points at the inp
172
173
  that actually held the bad field; passing something that is neither — a bare
173
174
  `String` where the input goes — reports the same way. A call site that *spells* the
174
175
  wrong type is caught earlier and better, by `srb tc`
175
- ([why](generated_modules.md#variables-become-typed-kwargs)).
176
+ ([why](generated_modules.md#variables-become-typed-kwargs)). And once a boundary
177
+ of your own between that call site and `execute` carries a sig, sorbet-runtime
178
+ checks the value there first: the caller sees a plain `TypeError`, and none of
179
+ the structured, translatable detail below ever reaches it.
176
180
 
177
181
  The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
178
182
 
@@ -216,9 +220,12 @@ a form keyed on `e.field` finds the same slot for a refusal raised before the
216
220
  request left and for one the server sent back.
217
221
 
218
222
  The **prop** (`issued_on`) is what you type in Ruby — `.new`, `.coerce`, the kwargs
219
- of `execute` — and it is `#message`, the developer's line, that names it. In a
220
- Rails form the field names are the props, so underscore on the way in — and give
221
- the nil case a home, because **`#field` is `nil` whenever nothing named a slot**:
223
+ of `execute` — and it is `#message`, the developer's line, that names it, with the
224
+ schema's spelling beside it where the two differ (`issued_on (issuedOn): expected
225
+ a String`): the prop is what you fix, and the wire name is what you grep the
226
+ `.graphql` for. In a Rails form the field names are the props, so underscore on
227
+ the way in — and give the nil case a home, because **`#field` is `nil` whenever
228
+ nothing named a slot**:
222
229
 
223
230
  ```ruby
224
231
  form.errors.add(e.field&.underscore || :base, render_input_error(e))
@@ -464,7 +471,7 @@ than leaving you sorbet's words:
464
471
  | what came back | what the message adds |
465
472
  |---|---|
466
473
  | 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 |
474
+ | 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
475
  | a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
469
476
 
470
477
  Simulate one in tests with
@@ -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 union hoisted
171
- out of a shared fragment is named for the fragment; and several fields sharing
172
- one collapsed union type take the first of their keys alphabetically. Still
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,12 @@ 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
- When a whole union field is selected as one named *shared* fragment
406
- (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
407
- for the fragment and each query aliases it, so the same union is one Ruby type
408
- family across queries, not a fresh dispatch module per query. Like shared inputs,
409
- it's a `generate!`-directory concern; dynamic `parse` inlines.
464
+ A whole abstract field selected as one named *shared* fragment
465
+ (`{ ...FeedItemFields }`) is [hoisted](#a-shared-fragment-is-one-type) into
466
+ `GraphQLTypes` like any other, in whichever of the three shapes above the
467
+ fragment has a dispatch module, one struct, or a nilable narrowed struct. So
468
+ which shape it is never decides whether consuming queries share a type, and
469
+ adding a second `... on` to a shared fragment doesn't move their constant.
410
470
 
411
471
  ### Consuming a union — dispatch on the class, not `__typename`
412
472
 
@@ -438,8 +498,9 @@ you handle it.** It is exhaustive over the members *this query asked about*, plu
438
498
  cover: two *differently-selected* occurrences of the same union are distinct type
439
499
  families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
440
500
  written for one won't span the other. Select the union through a shared fragment
441
- to hold it as one type across queries ([above](#abstract-types)); if all you have
442
- is the bare tag, `__typename` is the common denominator, unchecked.
501
+ to hold it as one type across queries
502
+ ([above](#a-shared-fragment-is-one-type)); if all you have is the bare tag,
503
+ `__typename` is the common denominator, unchecked.
443
504
 
444
505
  ## Type helpers
445
506
 
@@ -503,6 +564,22 @@ the one thing you couldn't add by hand: a `const` has no sig to put it in.
503
564
  `T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
504
565
  beats `# typed: false` for a helper you want checked.
505
566
 
567
+ **An `abstract!` mixin has a precondition: every query must select what it
568
+ declares.** The mixin goes into *every* struct generated from the type, so a
569
+ query selecting a subset has nothing to satisfy the rest, and generation refuses
570
+ — naming the struct, the mixin, the members, and the two ways out: select them
571
+ in that query, or select the type through one
572
+ [shared fragment](#a-shared-fragment-is-one-type), which hoists a single struct
573
+ for every query to share. The second is usually the answer: a shape several
574
+ queries lean on is a fragment.
575
+
576
+ One sig a mixin cannot write is one over a *generated* type — `sig {
577
+ abstract.returns(GraphQLTypes::Species) }` raises `NameError` at generation,
578
+ because registrations load before the enum that generation is about to write.
579
+ The field's sig has to be `T.untyped` there. The struct's own `const` still
580
+ carries the real type, so a call site reading `pet.species` is checked; only the
581
+ mixin's view of it isn't.
582
+
506
583
  ### Flat accessors with `alias:`
507
584
 
508
585
  The one derivation the generator can type for you is a plain projection — a
@@ -528,7 +605,8 @@ alias: { label: "name", tag: "meta.tag" }
528
605
  ```
529
606
 
530
607
  The path is the **Ruby** accessor chain, so its segments are snake_case props
531
- (`name_with_owner.tag`), not wire names. It's typed from the selection: any
608
+ (`name_with_owner.tag`), not wire names. It may end on a
609
+ [hoisted](#a-shared-fragment-is-one-type) struct but not read through one. It's typed from the selection: any
532
610
  nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
533
611
  scalar, enum, or nested struct. It's validated against each query at generation —
534
612
  an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
@@ -597,7 +675,7 @@ to replace it; what the *example* says still wins.
597
675
  generated module). A generated file says nothing about transport — only a private
598
676
  `GRAPH` naming its graph, which is also how `graphql: :fake` fabricates each
599
677
  module's own schema with two graphs in play, and the
600
- `:graph` on every [instrumentation event](logging.md#the-payload) the module's
678
+ `:graph` on every [instrumentation event](logging.md#instrumentation) the module's
601
679
  `execute` produces.
602
680
 
603
681
  ## Deserializing a response from another client
@@ -679,8 +757,8 @@ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
679
757
  generating without one fails pointing at exactly that.
680
758
 
681
759
  **A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
682
- Input types, schema enums, and unions hoisted from shared fragments are all one
683
- kind of thing — a type that would otherwise be copied into every query that
760
+ Input types, schema enums, and the types hoisted from shared fragments are all
761
+ one kind of thing — a type that would otherwise be copied into every query that
684
762
  touches it — so they live in one module, one file each, and a query module that
685
763
  uses any of them opens with `require_relative "types"`. Rename the constant
686
764
  (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
@@ -716,6 +794,43 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
716
794
  — are in [getting started](getting_started.md#5-verify-in-ci); a
717
795
  [`schema_stale?`](errors.md) error in production is the late signal.
718
796
 
797
+ ### Make your tooling treat generated/ as generated
798
+
799
+ Three readers, three tellings — and `rails g graph_weaver:install` writes the
800
+ two that live in files:
801
+
802
+ - **You.** Every generated file opens with `# Generated by GraphWeaver
803
+ <version> — do not edit.`
804
+ - **Rubocop.** The output directory goes into `AllCops: Exclude:` — machine-written
805
+ code isn't yours to style, and plain `rubocop` otherwise fires
806
+ `Style/Documentation` on every struct and `Metrics/*` on every `from_h`.
807
+ - **GitHub.** `linguist-generated` collapses these files in a pull-request diff
808
+ (shown as "Load diff") and drops them from the repository's language
809
+ breakdown. It is display-only: they stay versioned, expandable, and `git diff`
810
+ here is unchanged.
811
+
812
+ An app that didn't run the installer adds the two by hand:
813
+
814
+ ```yaml
815
+ # .rubocop.yml — inherit_mode keeps rubocop's own Exclude defaults
816
+ AllCops:
817
+ inherit_mode:
818
+ merge:
819
+ - Exclude
820
+ Exclude:
821
+ - "app/graphql/generated/**/*"
822
+ ```
823
+
824
+ ```text
825
+ # .gitattributes — gitignore-style, so `dir/**` is everything beneath it
826
+ app/graphql/generated/** linguist-generated
827
+ ```
828
+
829
+ `git check-attr linguist-generated app/graphql/generated/types.rb` answers `set`
830
+ when the pattern is right. One line per graph in a multi-schema app, and
831
+ [linguist's overrides](https://github.com/github-linguist/linguist/blob/main/docs/overrides.md)
832
+ has the other attributes it reads.
833
+
719
834
  ### Loading what it wrote
720
835
 
721
836
  In Rails, loading is automatic — the Railtie requires every generated file at
@@ -735,8 +850,10 @@ you — a script that generates its own modules sets all four:
735
850
  other [client](#clients) to reach for.
736
851
 
737
852
  Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
738
- gets `PersonQuery: client must respond to #execute(query, variables:), got
739
- NilClass` from a module that otherwise looks fine.
853
+ gets `no client configured set GraphWeaver.client= or pass a client` from a
854
+ module that otherwise looks fine. Set the slot to something that can't execute
855
+ and the module names the contract instead: `PersonQuery: client must respond to
856
+ #execute(query, variables:, operation_name:), got Hash`.
740
857
 
741
858
  Every directory setting is a list — `queries_paths`, `generated_paths`,
742
859
  `fragments_paths` — and every entry is read (entries may be globs; the generated
@@ -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 union field,
169
- its type is hoisted once into `GraphQLTypes` and every query that spreads it
170
- gets the same Ruby type — so one exhaustive `case T.absurd` works everywhere.
171
- See [abstract types](generated_modules.md#abstract-types).
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 — a recorded source url (with
220
- `GRAPHWEAVER_AUTH` for private APIs), or your own schema class when the app
221
- [serves the schema itself](#your-apps-own-schema-in-process) and
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,9 +292,11 @@ 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
- recorded url (without rewriting the dump) and validates every `.graphql` file
288
- against the schema as it is right now, naming each error's line and column:
295
+ **which of your queries no longer validate, and why.** It re-introspects
296
+ whatever is behind each graph's dump (without rewriting the dump) the same
297
+ source `schema:refresh` rewrites from and `schema:diff` compares against and
298
+ validates every `.graphql` file against the schema as it is right now, naming
299
+ each error's line and column:
289
300
 
290
301
  ```
291
302
  app/graphql/queries/person.graphql
@@ -294,6 +305,12 @@ app/graphql/queries/person.graphql
294
305
  1 invalid query
295
306
  ```
296
307
 
308
+ A dump that records no url has nothing to re-read, and so does a graph that
309
+ names its own schema: both are checked as they stand on disk. That is `verify`'s
310
+ question rather than this one's, so the passing verdict names the file instead of
311
+ claiming a check against the server ([a dump you already
312
+ have](#a-schema-dump-you-already-have)).
313
+
297
314
  `GraphWeaver.check_queries` returns the same findings as data — a hash of file
298
315
  to `message`/`line`/`column`, empty when everything validates — so you can wire
299
316
  it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
@@ -331,15 +348,20 @@ app/graphql/queries/products.graphql: Products.sku — selected, never read (Cat
331
348
  Each line names the query file, the selection to go and delete, and the
332
349
  generated prop behind it. It reads the generated structs for the props a query
333
350
  produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
334
- and `.jbuilder` for every name they could be read by `.sku`, `sku:`, `:sku`,
335
- `"sku"`. `PATHS=app,lib` narrows the sweep (a `PATHS=` naming a directory that
336
- isn't there is refused rather than swept as nothing); anything under a directory
351
+ and `.jbuilder` plus Ruby carrying no extension, which is any name under
352
+ `bin/` or `exe/` and a ruby shebang anywhere else for every name they could be
353
+ read by: `.sku`, `sku:`, `:sku`, `"sku"`. `PATHS=app,lib` narrows the sweep (a
354
+ `PATHS=` naming a directory that isn't there is refused rather than swept as
355
+ nothing); anything under a directory
337
356
  named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
338
357
  as is any file defining a graphql-ruby **type** — a `field :sku` there is your
339
358
  *server* offering a field, not this app reading one back. A module handed whole
340
359
  to a serializer (`render json:`, `to_h`, `as_json`, a local and all) counts every
341
- prop as read, and the report quotes the line it followed. Nothing is edited and
342
- the exit is 0; `STRICT=1` exits 1 when anything is unread.
360
+ prop as read, and the report quotes the line it followed a plain local stands
361
+ for the module only inside the method it was assigned in, so a same-named block
362
+ param in the next method credits nothing, while an `@ivar` crosses that boundary
363
+ the way a `before_action` does. Nothing is edited and the exit is 0;
364
+ `STRICT=1` exits 1 when anything is unread.
343
365
 
344
366
  **It is a lint, not a proof**, and the task's own footer says so. It matches
345
367
  names as text, so a prop called `name` counts as read the moment anything says
@@ -416,6 +438,34 @@ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
416
438
  and introspects nothing. A dump has no resolvers, so it can't execute — set
417
439
  `GraphWeaver.client` to whatever serves the API.
418
440
 
441
+ **A dump you brought from elsewhere has no provenance**: it doesn't record the
442
+ server it was introspected from, which is exactly what migrating off
443
+ graphql-client leaves you ([migrating](migrating.md)). `rake
444
+ graph_weaver:schema:refresh` adopts it — with no url on the file it introspects
445
+ the client the graph names, rewrites the dump, and records the source, so every
446
+ later refresh and `schema:diff` re-read the right server. `URL=` names the
447
+ endpoint instead, if you'd rather say it once than configure the client first.
448
+
449
+ Until there is something behind the dump to re-read — the url it records, the
450
+ server the graph's client posts to, or the graphql-ruby class that client runs
451
+ in-process — `queries:check` validates against the committed file: a real check,
452
+ but `verify`'s question rather than this one's, and the verdict says which:
453
+
454
+ ```
455
+ every query validates against db/schema.graphql as committed — not the server (rake graph_weaver:schema:diff asks whether the server moved)
456
+ ```
457
+
458
+ A dump with none of those behind it *is* the schema — a hand-maintained SDL
459
+ nothing serves. All three tasks say so in one sentence:
460
+
461
+ ```
462
+ db/schema.graphql records no source url and the graph names no server behind it — no client posting to one, and no graphql-ruby schema class in this process — so the file is the schema and nothing here can re-read it
463
+ ```
464
+
465
+ `schema:refresh` steps over that graph and refreshes the rest; `schema:diff`
466
+ still exits 1, because a gate that passes on having compared nothing is worse
467
+ than one that says so.
468
+
419
469
  ## More than one schema
420
470
 
421
471
  The five steps above describe one graph — a schema, its queries, its output. An
@@ -468,9 +518,10 @@ they belong to and nothing about transport, so they read it when they execute.
468
518
  A graph with no `client` falls back to `GraphWeaver.client`, the app default.
469
519
  Name the object (`client GraphWeaver.new(url, auth: …)`) or, when the constant
470
520
  holding it is defined later than the graph block, its name (`client "GITHUB"`),
471
- which is resolved on first use. `schema "x"` sets and a bare `schema` reads
472
- back; there is no `schema = "x"` form, since the block is `instance_eval`'d and
473
- that would be a local variable that silently does nothing.
521
+ which is resolved on first use and not a lambda, which `schema` needs and this
522
+ doesn't, since the name is resolved at call time already. `schema "x"` sets and
523
+ a bare `schema` reads back; there is no `schema = "x"` form, since the block is
524
+ `instance_eval`'d and that would be a local variable that silently does nothing.
474
525
 
475
526
  **`namespace` nests everything that graph generates** — `person.graphql` becomes
476
527
  `Billing::PersonQuery` ([naming](generated_modules.md#naming)). Constants are
@@ -484,8 +535,10 @@ An app that is a pure client of someone else's GraphQL owns no schema class, so
484
535
  every graph's `schema` is a dump. Give each the file you want and a `client`
485
536
  that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
486
537
  client into its own dump, recording the url so every later `schema:refresh` and
487
- `schema:diff` re-reads the right server. (`URL=` is for the app that has one
488
- dump and no graphs.)
538
+ `schema:diff` re-reads the right server. Every graph is its own job one with
539
+ no server behind its dump is left as checked in, one whose server is down is
540
+ reported under its own heading, and the rest still refresh. (`URL=` is for the
541
+ app that has one dump and no graphs.)
489
542
 
490
543
  In specs, `graph:` is how an example says which graph a helper stands in for —
491
544
  `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
@@ -527,6 +580,106 @@ per-struct `extend T::Sig` in generated files is redundant — rubocop's
527
580
  time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
528
581
  true`/`false`.
529
582
 
583
+ ### The types stop where your sigs do
584
+
585
+ That typo is caught where the struct comes back. Hand the struct to one method
586
+ without a sig and it is `T.untyped` from there on — the same typo, one layer in,
587
+ is silent — and a sig on the method that produced it is no help either:
588
+
589
+ ```ruby
590
+ # typed: true
591
+ class PersonDirectory
592
+ extend T::Sig
593
+
594
+ sig { returns(T.nilable(PersonQuery::Result::Person)) }
595
+ def person = PersonQuery.execute!(id: "1").person
596
+ end
597
+
598
+ class Profile
599
+ def initialize(person) # no sig, so @person is untyped
600
+ @person = person
601
+ end
602
+
603
+ def title = @person.nmae # "No errors! Great job."
604
+ end
605
+
606
+ class Page
607
+ def initialize(directory) # nor is the sig above any help here
608
+ @directory = directory
609
+ end
610
+
611
+ def title = @directory.person&.nmae # also clean
612
+ end
613
+ ```
614
+
615
+ Every hop has to be sig'd: the method that produces the struct, any wrapper it
616
+ passes through, and the constructor that stored the collaborator. Two shapes
617
+ cover most of an app.
618
+
619
+ A wrapper that only passes a block through keeps the block's type with
620
+ `type_parameters`:
621
+
622
+ ```ruby
623
+ # a rescue wrapper that keeps the block's type
624
+ sig do
625
+ type_parameters(:T).params(blk: T.proc.returns(T.type_parameter(:T)))
626
+ .returns(T.type_parameter(:T))
627
+ end
628
+ def people(&blk) = yield
629
+ ```
630
+
631
+ A service boundary spells the struct it hands out, and whoever holds that
632
+ service types the ivar:
633
+
634
+ ```ruby
635
+ class Page
636
+ extend T::Sig
637
+
638
+ sig { params(directory: PersonDirectory).void }
639
+ def initialize(directory)
640
+ @directory = directory # the sig is what types this ivar
641
+ end
642
+
643
+ sig { returns(T.nilable(String)) }
644
+ def title = @directory.person&.nmae # now srb tc has it
645
+ end
646
+ ```
647
+
648
+ That is sigs on half a dozen service methods and a couple of constructors before
649
+ the first typo in a layered app is caught. Budget it as its own piece of work
650
+ rather than as part of generating the types — and if it isn't work you're going
651
+ to do, the runtime half still holds: a bad field is a `NoMethodError` the first
652
+ time the line runs.
653
+
654
+ ### Give a struct a short name where you name one
655
+
656
+ A generated struct's constant path spells out the query that produced it, which
657
+ is what makes it stable — and long. Where your own code names one in a sig, alias
658
+ it once, in the class that hands it out:
659
+
660
+ ```ruby
661
+ # before
662
+ sig { params(code: String).returns(T.nilable(Countries::CountryProfileQuery::Result::Country)) }
663
+
664
+ # after
665
+ class Directory
666
+ Country = Countries::CountryProfileQuery::Result::Country
667
+
668
+ sig { params(code: String).returns(T.nilable(Country)) }
669
+ end
670
+ ```
671
+
672
+ Use a plain constant, not `T.type_alias`: the constant works in a sig *and* as a
673
+ value (`Country.from_h`), and in a `# typed: strict` file it needs no `T.let`
674
+ around it. Reach for `T.type_alias` only where a constant can't express the type
675
+ — a union of two queries' structs.
676
+
677
+ That takes the worst sig in one migrated app from 116 characters to 72, and the
678
+ callers get the short name too: a presenter takes a `Directory::Country` and
679
+ stops knowing which `.graphql` file produced it. It is also what makes the sig
680
+ chain above affordable, since a sig usually goes unwritten because the line is
681
+ too long to want to write.
682
+
530
683
  ## Not Rails?
531
684
 
532
685
  There's no generator, but what it writes is short — a few lines wherever your
@@ -541,6 +694,15 @@ GraphWeaver.client = GraphWeaver.new(
541
694
  GraphWeaver.load_generated! # no Railtie to require the generated files
542
695
  ```
543
696
 
697
+ **A public API needs no `auth:`** — leave the line out. It is in the example
698
+ because most endpoints want a token, not because the constructor asks for one.
699
+
700
+ **`load_generated!` goes before your own requires** when anything your app loads
701
+ names a generated constant as it loads — a `STATUS_LABELS` table keyed on
702
+ `GraphQLTypes::ShipmentStatus` raises `uninitialized constant` otherwise, and
703
+ building it lazily to dodge that earns `Dynamic constant references are
704
+ unsupported` from `srb tc`. One line of ordering settles both.
705
+
544
706
  ```sh
545
707
  mkdir -p app/graphql/queries app/graphql/generated
546
708
  rake graph_weaver:schema:refresh URL=https://api.example.com/graphql