graph_weaver 0.6.1 → 0.7.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -49,11 +49,21 @@ GraphWeaver.generate!(schema:) # write the modules
49
49
  GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
50
50
  ```
51
51
 
52
+ **`verify_generated!` costs what `generate!` costs**, minus the writes — it
53
+ recomputes the whole plan, every file's content, and diffs each against disk,
54
+ whether nothing is stale or everything is. It doesn't get cheaper because only
55
+ one query changed, so it belongs in *one* example per suite run, not in a
56
+ `before` or an assertion per example, where it reads like a cheap check and
57
+ isn't.
58
+
52
59
  `generate!` returns every file the plan produces, but rewrites only the ones
53
60
  whose bytes changed; `GraphWeaver.changed_files` is that subset. So
54
61
  `rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
55
62
  date` for the rest, and a watching dev server has one module to reload instead
56
- of all of them.
63
+ of all of them. The unregistered-scalar report is the rake task's `puts`, not
64
+ `generate!`'s, so off rake read `GraphWeaver.untyped_scalars` for the unioned
65
+ list — or set [`GraphWeaver.logger`](logging.md), which `generate!` names them
66
+ on at `info` as it goes.
57
67
 
58
68
  The schema dump is step 0 — codegen reads it, never a live endpoint.
59
69
  `cache: true` on a url client writes it on first introspection
@@ -89,6 +99,21 @@ explicit, factory_bot-style:
89
99
  GraphWeaver.load_generated! # require every file under generated_paths
90
100
  ```
91
101
 
102
+ **Outside Rails, four things have to agree**, and nothing wires them together
103
+ for you — a script that generates its own modules sets all four:
104
+
105
+ 1. `queries_paths` — where `generate!` reads `.graphql` files.
106
+ 2. `generated_paths` — where it writes, and where `load_generated!` reads.
107
+ Point them at the same directory or generation is invisible.
108
+ 3. the call above, before the first `execute` — nothing else requires the
109
+ files.
110
+ 4. `GraphWeaver.client =` — a module generated without a baked
111
+ [`client:`](#clients) has none of its own.
112
+
113
+ Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
114
+ gets `PersonQuery: client must respond to #execute(query, variables:), got
115
+ NilClass` from a module that otherwise looks fine.
116
+
92
117
  Every directory setting is a list — `queries_paths`, `generated_paths`,
93
118
  `fragments_paths` — and every entry is read (entries may be globs; the
94
119
  generated default includes `app/graphql/*/generated`, so per-schema layouts
@@ -168,6 +193,58 @@ end
168
193
  **result**, or a raised `GraphWeaver::QueryError`. See [errors](errors.md).
169
194
  - `from_response` / `from_response!` are the **network-free half** of the
170
195
  pair — same envelope, but from a response hash you already have (below).
196
+ - A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and
197
+ `hash`, so a result works as a hash key), `deconstruct_keys` for pattern
198
+ matching, `#to_h`, and `#to_json`/`#as_json`. All of them go the whole way
199
+ down a nested result.
200
+ Immutable as far as its props go, like `Struct` or `Data` — and no further:
201
+ the `String` or `Hash` a leaf holds is the one the response carried, so
202
+ `result.name << "!"` changes the result, and its `hash` with it.
203
+
204
+ **Cache a result with `Marshal`, not YAML.** A `T::Enum` member is a
205
+ singleton that sorbet compares by identity, and Psych allocates an object
206
+ before filling it in, so YAML has no way to hand back the canonical one:
207
+ after a round trip `pet.species == Species::Dog` is false and the result no
208
+ longer equals itself. `Marshal` restores it intact — as does JSON, since
209
+ `#to_json` writes the wire shape and `from_h` reads it back (below).
210
+
211
+ ```ruby
212
+ PersonQuery.from_response!(raw) == PersonQuery.from_response!(raw) # true — value, not identity
213
+
214
+ case PersonQuery.execute!(id: "1")
215
+ in { person: { name:, pets: [{ name: first_pet }, *] } } then "#{name} and #{first_pet}"
216
+ in { person: { name: } } then "#{name}, petless"
217
+ in { person: nil } then "nobody"
218
+ end
219
+
220
+ PersonQuery.execute!(id: "1").to_h
221
+ # => { person: { id: "1", name: "Daniel", birthday: #<Date 1984-05-06>,
222
+ # pets: [{ name: "Nibbler" }] } }
223
+ ```
224
+
225
+ `#to_h` is the **Ruby** shape, not the wire's: snake_case prop names as
226
+ Symbols, nils kept, enums as their `T::Enum` members, and a registered
227
+ scalar as whatever object its codec built. It is a view, for Ruby to read.
228
+
229
+ `#to_json` — and `#as_json`, which `render json:` goes through — is the
230
+ **wire** shape instead: the response keys, and every leaf back through its
231
+ scalar registration's `serialize:`. So a result's JSON is the inverse of
232
+ `from_h`:
233
+
234
+ ```ruby
235
+ PersonQuery::Result.from_h(JSON.parse(result.to_json)) == result # true
236
+ ```
237
+
238
+ which is what a cache entry, a log line or a JSON API response wants. That
239
+ split is deliberate: a Symbol-keyed Ruby hash can't be mistaken for a
240
+ server's response, and a JSON string can — so the JSON is the one that has
241
+ to be true. (An **input** struct's `to_h` is already the wire hash it
242
+ sends, so there its JSON and its `to_h` agree.) The trip is exactly as
243
+ faithful as each scalar's own `cast:`/`serialize:` pair: a `Time` goes back
244
+ out with [the microseconds its registration writes](scalars.md#going-out--what-a-variable-kwarg-accepts),
245
+ and a `register_scalar` with a `cast:` and no `serialize:` has no wire
246
+ spelling at all, so its value reaches the encoder as it is — the same
247
+ reason an input can't send one.
171
248
  - `OPERATION_NAME` rides along on every request as the spec's
172
249
  `operationName`, so Apollo Studio, Hasura and your APM key traces, rate
173
250
  limits and slow-query reports on the operation instead of lumping every
@@ -196,6 +273,12 @@ file holding two operations, since one file can't name two modules. Change a
196
273
  file's `query` to `mutation` and its constant changes with it; the next
197
274
  `generate!` prunes the old file, and `verify` fails until you regenerate.
198
275
 
276
+ **A graph's `namespace:` nests what it generates**, and is the answer when two
277
+ schemas in one app each have a `person.graphql`: `namespace: "Billing"` makes
278
+ that one `Billing::PersonQuery` in the same `person_query.rb`, and its shared
279
+ types module `Billing::GraphQLTypes`. Nothing else about the rule changes — the
280
+ file still names the module. See [getting started](getting_started.md#more-than-one-schema).
281
+
199
282
  Parsing a raw query *string* has no file to name it after, so it uses the
200
283
  operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
201
284
  `Query` for an anonymous one (its constants are container-scoped, so collisions
@@ -291,6 +374,14 @@ stands in its place for the arguments — stricter, and with a better message
291
374
  and the `Result` it returns is a `T::Struct`, so its props are still checked one
292
375
  by one.
293
376
 
377
+ `T::Configuration.default_checked_level = :never` buys nothing back here. That
378
+ knob governs `sig` dispatch, and the emitted sigs already opt out; the cost that
379
+ remains is `T::Struct`'s own prop validation, which sorbet-runtime declares
380
+ `.checked(:never)` in its own source and runs through a setter built at class
381
+ definition. `from_h` allocates and costs the same either way — measured
382
+ object-for-object identical — so reach for the scalar's cast, not this, when a
383
+ deserialization path is hot.
384
+
294
385
  One kwarg per declared variable, always — so adding a variable to a query
295
386
  adds a kwarg and leaves every existing call site alone. Two names are refused at
296
387
  generation, `$client` and `$variables`: the generated `execute` body already
@@ -339,6 +430,40 @@ a shared type keeps one identity across modules. A query module aliases only its
339
430
  diffs exactly the types it touched, and types the schema drops are pruned on
340
431
  regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
341
432
 
433
+ ### An input object generates its whole closure
434
+
435
+ A result type is generated per selection set, because a selection set *is* the
436
+ question. An input object has no selection set, so the only static answer to
437
+ "what can `$where` hold" is every input type it can transitively reach — and
438
+ codegen emits a file for each. On a hand-written schema that closure is usually
439
+ the one type and nothing else. On a generated one (Hasura, Gatsby), where every
440
+ `_bool_exp` references every other, one `$where` reaches a thousand of them.
441
+
442
+ The escape is to stop making the filter a variable. Write it as a literal in the
443
+ query with a variable per leaf, and codegen has ordinary scalars to generate
444
+ instead of the closure — on the query that emitted ~1,200 files, exactly one:
445
+
446
+ ```graphql
447
+ query($name: String!, $minHeight: Int!) {
448
+ pokemon(where: { name: { _ilike: $name }, height: { _gte: $minHeight } }) {
449
+ name
450
+ }
451
+ }
452
+ ```
453
+
454
+ `srb tc` gets *more* out of that, not less. `name: String`, `min_height:
455
+ Integer` are types it checks at every call site, where the variable form is
456
+ `T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped])` — and a hash built from
457
+ `params`, which is how a filter is really assembled, takes the untyped branch.
458
+ Refusals land on the leaf too, so `path` is the form field rather than the
459
+ comparison operator under it.
460
+
461
+ Two shapes can't be inlined, and codegen says which when a prop collision in an
462
+ input type forces the question. A key chosen at runtime — the sort column in
463
+ `order_by: { <column>: asc }` — has no spelling, because GraphQL has no dynamic
464
+ object keys. And a literal list can't stand in for a length only the runtime
465
+ knows.
466
+
342
467
  ## Enums: one GraphQL enum, one Ruby type
343
468
 
344
469
  Every schema enum a query touches — as a variable, in a result, or both —
@@ -363,6 +488,28 @@ of yours instead.
363
488
  the enums into the query module itself; there's no cross-query set to share
364
489
  against, but one enum is still one class within that module.
365
490
 
491
+ **The one misuse nothing catches** is comparing against the wire spelling:
492
+
493
+ ```ruby
494
+ pet.species == "CAT" # => false, always, and silently
495
+ pet.species == GraphQLTypes::Species::Cat
496
+ ```
497
+
498
+ A generated enum is a plain `T::Enum`, so `==` against a String is `false` —
499
+ `srb tc` allows it (`==` takes `BasicObject`) and nothing raises. sorbet-runtime
500
+ owns this question and ships the switch; turn it on in dev and test and route
501
+ the report wherever your other soft assertions go:
502
+
503
+ ```ruby
504
+ T::Configuration.enable_legacy_t_enum_migration_mode
505
+ T::Configuration.soft_assert_handler = ->(message, extra) { raise "#{message} #{extra}" }
506
+ ```
507
+
508
+ It covers your own `T::Enum`s too, which is why it belongs there rather than in
509
+ the generated classes. Careful reading it: in that mode the comparison answers
510
+ **true** (it serializes first), so the handler, not the return value, is the
511
+ signal.
512
+
366
513
  ## Selections
367
514
 
368
515
  - **Fragments** — inline fragments and named spreads flatten into the
@@ -380,6 +527,20 @@ mode) the struct raises a NoMethodError naming the prop that does exist —
380
527
  `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
381
528
  a near-miss typo in either casing.
382
529
 
530
+ A name that would shadow a method every struct answers — `class`, `hash`,
531
+ `display`, `to_json`, and `supplied` on an input — takes a trailing underscore
532
+ instead: `class` → `class_`, in results and input types alike. The generated
533
+ source says so on the line above the prop. Only the Ruby name moves: the wire
534
+ keeps the schema's spelling in both directions, so the query, the request and
535
+ the response are untouched, and `result.class` is still Ruby's `class`. The
536
+ prop is the one Ruby name for the field, so `.new`, `.coerce`, a result's
537
+ `#to_h` and pattern matching, and an `InputError`'s `#path` all use `class_`
538
+ (an input error's `#coordinate` still names the schema's `Tricky.class`). The
539
+ wire views are where the schema's spelling comes back: an **input** struct's
540
+ `#to_h` is the hash you would send — `{"class" => …}` — and a result's
541
+ `#as_json`/`#to_json` write `"class"` too, so `render json: result` never
542
+ leaks a trailing underscore. Input structs don't pattern-match at all.
543
+
383
544
  ### Abstract types
384
545
 
385
546
  An abstract field emits **one struct per type condition the selection names**,
@@ -387,7 +548,10 @@ plus a catch-all `Other`, wrapped in a module with
387
548
  `Type = T.type_alias { T.any(...) }` and a `from_h` that dispatches on
388
549
  `__typename`. Generation therefore *requires* `__typename` in such a selection,
389
550
  unaliased and unconditional — the wire response carries no type tag unless you
390
- ask, and `from_h` reads it on every response.
551
+ ask, and `from_h` reads it on every response. One `__typename` inside each
552
+ `... on Type` does **not** substitute, however many of them there are: the
553
+ dispatch runs before any member's selection applies, and a member the query
554
+ never named would carry none at all.
391
555
 
392
556
  Size follows the query, not the schema: two `... on` conditions against GitHub's
393
557
  `Node` — an interface with a few hundred implementations — emit three structs,
@@ -473,9 +637,12 @@ pet.name # => "Shelby" — the wire value stays honest
473
637
 
474
638
  The methods live on the struct, so they see its wire fields at runtime and
475
639
  fakes/cassettes get the behavior automatically; registrations are additive
476
- (repeated ones stack). Editing the *mixin* in development needs a restart,
477
- unlike a `.graphql` edit: a reload hands the constant a new module object, and
478
- the `include` that took the old one doesn't run again.
640
+ (repeated ones stack). The mixin is one of your own constants, so in Rails the
641
+ registration goes in a `to_prepare` block like `register_enum` does, and for the
642
+ same reason [getting started](getting_started.md#2-run-the-generator) has the
643
+ rule and the boot order behind it. Editing the *mixin* in development needs a
644
+ restart, unlike a `.graphql` edit: a reload hands the constant a new module
645
+ object, and the `include` that took the old one doesn't run again.
479
646
  For quick decoration, build the mixin inline — the block
480
647
  is `module_eval`'d into a fresh module auto-named under
481
648
  `GraphWeaver::TypeHelpers`:
@@ -486,12 +653,41 @@ GraphWeaver.extend_type("Pet") do
486
653
  end
487
654
  ```
488
655
 
489
- **Neither form is statically checked**, for the same reason: `srb tc` checks a
490
- mixin's method bodies in the module's own scope, not the including struct's, so
491
- a helper reading a wire field (`name`, `birthday`) fails with "method does not
492
- exist on the module" and the block form has no source on disk for `srb tc` to
493
- read at all. Write such a helper at `# typed: false`, or reach the field through
494
- `T.unsafe(self)`.
656
+ The name is where the block is written and what it extends:
657
+ `GraphWeaver::TypeHelpers::Pet` at the top level,
658
+ `GraphWeaver::TypeHelpers::Billing::Pet` inside `GraphWeaver.graph :billing`.
659
+ Generated code spells it, so it depends on your source and nothing else two
660
+ graphs can extend the same type name, and the name a `generate` bakes in is the
661
+ one a boot creates.
662
+
663
+ **Neither form is statically checked as written**, for the same reason: `srb tc`
664
+ checks a mixin's method bodies in the module's own scope, not the including
665
+ struct's, so a helper reading a wire field (`name`, `birthday`) fails with
666
+ "method does not exist on the module" — and the block form has no source on disk
667
+ for `srb tc` to read at all (it shows up as `Unable to resolve constant` on the
668
+ generated `include`, which is the cost of the convenience).
669
+
670
+ A *named* module can carry real sigs, though, by declaring the fields it leans
671
+ on — abstract sigs are how a mixin says "whatever includes me has these", and the
672
+ struct's `const`s satisfy them:
673
+
674
+ ```ruby
675
+ # typed: strict
676
+ module PetHelpers
677
+ extend T::Sig
678
+ extend T::Helpers
679
+ abstract!
680
+
681
+ sig { abstract.returns(String) }
682
+ def name; end
683
+
684
+ sig { returns(String) }
685
+ def display_name = "#{name} 🐶"
686
+ end
687
+ ```
688
+
689
+ `T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
690
+ beats `# typed: false` for a helper you want checked.
495
691
 
496
692
  ### Flat accessors with `alias:`
497
693
 
@@ -560,13 +756,23 @@ Resolution is per call (`client:`) → per module → baked constant →
560
756
  [transports](transports.md#client-resolution).
561
757
 
562
758
  Generate *without* a baked constant when you want modules to follow the
563
- app default (`GraphWeaver.client =` in an initializer) — that's also what
564
- lets [testing's `graphql:` tag](testing.md) swap in a client per example.
759
+ app default (`GraphWeaver.client =` in an initializer).
760
+
761
+ A baked constant is no longer a reason a module escapes
762
+ [testing's `graphql:` tag](testing.md): the tag is exactly the instruction
763
+ to replace the client generation chose, so it stands in for the baked one
764
+ too. What the *example* says still wins — a per-call `client:`, or
765
+ `MyQuery.client =` in a `before` block.
565
766
 
566
767
  `client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
567
768
  every generated module). A baked constant is emitted as a private
568
769
  `DEFAULT_CLIENT`, resolved on first use so a module can load before the
569
- initializer that builds its client.
770
+ initializer that builds its client. A module generated from a
771
+ [declared graph](getting_started.md#more-than-one-schema) also carries a private `GRAPH` naming
772
+ it — so with two graphs, `graphql: :fake` fabricates each module's own
773
+ schema instead of having to be told which one you meant, and it is the
774
+ `:graph` on every [instrumentation event](logging.md#the-payload) the
775
+ module's `execute` produces.
570
776
 
571
777
  ## Deserializing a response from another client
572
778
 
@@ -593,7 +799,7 @@ keys** (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
593
799
 
594
800
  Which is checked, since symbolizing is the likeliest thing to go wrong at this
595
801
  seam: a hash carrying neither `"data"` nor `"errors"` raises a
596
- `GraphWeaver::TypeError` naming the keys it *did* find, rather than handing back
802
+ `GraphWeaver::CastError` naming the keys it *did* find, rather than handing back
597
803
  an envelope that reports success with no data. `nil` and a bare String are
598
804
  refused the same way.
599
805
 
@@ -606,6 +812,16 @@ checking matters. `GraphWeaver.run(source, query, **variables)` — or
606
812
  `client.run` — is the one-shot form: parse and execute in one call, no module
607
813
  kept.
608
814
 
815
+ In an app with [more than one graph](getting_started.md#more-than-one-schema), a
816
+ parsed module belongs to one of them — that is what a `graphql:` tag runs it
817
+ against, the same thing generation bakes into a file. It is read off the schema
818
+ you parsed against when a graph runs that class in-process; say it outright
819
+ otherwise:
820
+
821
+ ```ruby
822
+ PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)
823
+ ```
824
+
609
825
  Generated source is eval'd, so inputs are validated: module names must be
610
826
  constant names, and query heredocs can't be terminated early. Still: queries
611
827
  are code — don't feed untrusted strings to parse.