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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +1 -0
  4. data/docs/errors.md +5 -2
  5. data/docs/federation.md +3 -2
  6. data/docs/generated_modules.md +176 -22
  7. data/docs/getting_started.md +174 -14
  8. data/docs/i18n.md +4 -4
  9. data/docs/migrating.md +119 -0
  10. data/docs/scalars.md +161 -35
  11. data/docs/testing.md +24 -3
  12. data/docs/upgrading.md +51 -5
  13. data/examples/github/generated/star_mutation.rb +24 -2
  14. data/examples/github/generated/stargazers_query.rb +61 -5
  15. data/examples/github/generated/starred_query.rb +33 -3
  16. data/lib/generators/graph_weaver/install_generator.rb +32 -3
  17. data/lib/graph_weaver/client.rb +23 -0
  18. data/lib/graph_weaver/codegen/aliases.rb +23 -2
  19. data/lib/graph_weaver/codegen/emit.rb +35 -16
  20. data/lib/graph_weaver/codegen/enum_type.rb +149 -19
  21. data/lib/graph_weaver/codegen/nodes.rb +72 -37
  22. data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
  23. data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
  24. data/lib/graph_weaver/codegen.rb +259 -106
  25. data/lib/graph_weaver/coerce.rb +25 -6
  26. data/lib/graph_weaver/federation.rb +1 -6
  27. data/lib/graph_weaver/graph.rb +4 -1
  28. data/lib/graph_weaver/hints.rb +23 -5
  29. data/lib/graph_weaver/in_process.rb +1 -3
  30. data/lib/graph_weaver/input_struct.rb +31 -10
  31. data/lib/graph_weaver/internal/subgraphs.rb +1 -10
  32. data/lib/graph_weaver/internal/unused.rb +32 -7
  33. data/lib/graph_weaver/internal/values.rb +12 -4
  34. data/lib/graph_weaver/internal.rb +84 -0
  35. data/lib/graph_weaver/logging.rb +26 -29
  36. data/lib/graph_weaver/query_module.rb +20 -5
  37. data/lib/graph_weaver/railtie.rb +7 -2
  38. data/lib/graph_weaver/rspec.rb +0 -1
  39. data/lib/graph_weaver/schema_loader.rb +7 -8
  40. data/lib/graph_weaver/tasks.rb +60 -5
  41. data/lib/graph_weaver/testing/fake_client.rb +4 -10
  42. data/lib/graph_weaver/testing/router.rb +26 -25
  43. data/lib/graph_weaver/testing.rb +101 -1
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +80 -74
  46. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 653ecbcc2215bf5358e34bc83d701ded874a6ee1910ceeca18f0ed205a44798f
4
- data.tar.gz: 2d1e49f247e84347c7bd86123b54b1b9d31d8978bc2a6a8f8d9538342c3ab67d
3
+ metadata.gz: 53034719b6dcd597d098091606e08b6f4a33a15a89826374cf6b14df841aa0fc
4
+ data.tar.gz: cb64c3e12070f522a44353066aaac80ea7faf9df7d8a564a1abfcd053584049b
5
5
  SHA512:
6
- metadata.gz: 220c5d9ab87c671a602b226b2789fee65307e3b8a85407fa5c979eb272cde0e27d551e0a6d5ef32ad2714a6eec211fcf0657d1a2b7eb7a53c33d063756d8e82c
7
- data.tar.gz: 650c62d1ca107aaaccfa03ddd4686475c15ad68c0cf421be67379a7b88212af8366681da61208dee39ee2a68a468ca792f8cc9fee54c8982087924f927c2a2d1
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.3)
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.3)
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` carries the same
694
- list as a `"subgraphs"` key:
693
+ with the subgraphs behind the type it names, and `check_queries` or
694
+ `client.check_query(source)`, for a client built from that dump — carries the
695
+ same list as a `"subgraphs"` key:
695
696
 
696
697
  ```
697
698
  app/graphql/queries/product.graphql
@@ -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
@@ -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
- When a whole union field is selected as one named *shared* fragment
393
- (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
394
- for the fragment — and each query aliases it, so the same union is one Ruby type
395
- family across queries, not a fresh dispatch module per query. Like shared inputs,
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 ([above](#abstract-types)); if all you have
429
- is the bare tag, `__typename` is the common denominator, unchecked.
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
- **Neither form is statically checked as written**, for the same reason: `srb tc`
476
- checks a mixin's method bodies in the module's own scope, not the including
477
- struct's, so a helper reading a wire field (`name`, `birthday`) fails with
478
- "method does not exist on the module" — and the block form has no source on disk
479
- for `srb tc` to read at all. A *named* module can carry real sigs, though, by
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's typed from the selection: any
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 unions hoisted from shared fragments are all one
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
@@ -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,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
- recorded url (without rewriting the dump) and validates every `.graphql` file
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` for every name they could be read by `.sku`, `sku:`, `:sku`,
320
- `"sku"`. `PATHS=app,lib` narrows the sweep (a `PATHS=` naming a directory that
321
- isn't there is refused rather than swept as nothing); anything under a directory
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. (`URL=` is for the app that has one
473
- dump and no graphs.)
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** name for the type — `Int`, `Money` — so a
125
- sentence built from it still says "Int" in the middle of the French. Translate
126
- the name yourself under a key of your own and pass it *after* the splat, which
127
- wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
124
+ `details[:type]` is the **GraphQL** spelling of the type — a name (`Int`,
125
+ `Money`) or a whole signature (`[JSON!]`, `[Float!]!`) so a sentence built
126
+ from it still says "Int" in the middle of the French. Translate the spelling
127
+ yourself under a key of your own and pass it *after* the splat, which wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
128
128
 
129
129
  `@oneOf` violations are `:refused`, except exactly one field explicitly null,
130
130
  which is `:missing` on that field — a form gets the one slot to highlight. The