graph_weaver 0.5.0 → 0.6.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
data/CLAUDE.md DELETED
@@ -1,161 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## What this is
6
-
7
- A typed GraphQL client for Ruby: generates `# typed: strict` Ruby (nested
8
- `T::Struct`s + a typed `execute`) from your queries, checked against the schema
9
- at generation time. Sorbet is core to the product.
10
-
11
- ## Design principle — correct and simple, in that order
12
-
13
- Adoption follows delight, and delight follows from a tool that solves the real
14
- problem without making you think. So the bar for any change is: is it correct,
15
- and is it the *simplest* thing that is correct? Complexity is not neutral — it
16
- is confusion, bugs, and frustration, paid for by every future reader and user.
17
-
18
- What this means when choosing between designs:
19
-
20
- - **One rule beats a rule with exceptions.** A behavior you can state in a
21
- sentence, and predict without reading the source, is worth more than one that
22
- is marginally more capable. Generated class names come from the response key —
23
- full stop — rather than from the type name with disambiguation-on-collision,
24
- because the second rule can't be stated without describing its own edge cases.
25
- - **Prefer removing a decision to adding a knob.** The transport default got
26
- *better* by deleting auto-detection: one less thing to know, one less way to be
27
- surprised, and the fast path became the default. Reach for a config option only
28
- after the simple default has actually failed someone.
29
- - **A convention can beat a capability.** One file holds one operation. That's a
30
- convention, and it makes module naming derivable from the filename; supporting
31
- multiple operations per file would be more capable and worse.
32
- - **No spooky action at a distance.** Behavior should follow from the code in
33
- front of you, not from what else is in the Gemfile, what ran first, or which
34
- selection the walk happened to reach earlier.
35
- - **Match the ecosystem's conventions** where one exists — a familiar shape costs
36
- the user zero learning, which is the cheapest simplicity available.
37
- - **Errors are part of the interface.** A good message names what went wrong,
38
- where, and what to do about it. `optional: true` in the message beats the same
39
- advice buried in docs. The best bug fix often makes an error impossible; the
40
- next best makes it self-explanatory.
41
- - **Refuse rather than guess.** When intent is ambiguous, fail loudly at
42
- generation time. A silent wrong answer is the most expensive outcome this
43
- library can produce, because the generated code looks authoritative.
44
-
45
- When simplicity and capability genuinely conflict, say so out loud and pick
46
- deliberately — but the default is simple.
47
-
48
- **Docs are a complexity detector.** A section that belabors a topic is usually
49
- not a writing problem — it is the library pushing complexity onto the reader.
50
- Before expanding an explanation, ask whether the thing being explained should
51
- exist. If two paragraphs are needed to say which of two ways applies, delete one
52
- of the ways; the docs then shrink on their own, which is the signal the cut was
53
- right.
54
-
55
- ## Sorbet typing policy — type by value, not for coverage
56
-
57
- Sorbet being core does **not** mean every file should be `# typed: strict`. Type
58
- where it pays off in developer experience; leave the rest at `# typed: true`.
59
-
60
- - **Strict (full sigs) — developer-facing contracts.** The types users touch:
61
- `response.rb` (the envelope every `execute` returns), the error hierarchy
62
- (`errors.rb`), and the **generated code** (emitted `# typed: strict`). Concrete
63
- types here give downstream apps real call-site checking + autocomplete — that's
64
- the product.
65
- - **`# typed: true` (loose) — dynamic / boundary internals.** The codegen
66
- (`codegen.rb`, `codegen/nodes.rb`, `codegen/emit.rb`, `codegen/scalar_type.rb`,
67
- `codegen/enum_type.rb`, `codegen/type_helpers.rb`) walks graphql-ruby's
68
- approximately-typed AST and builds modules/strings dynamically; `client.rb`
69
- wraps a graphql-ruby schema and a duck-typed transport. Strict here is ~all
70
- `T.untyped` — paperwork that documents shape without catching anything.
71
- **Don't promote these to strict.**
72
- - Rule of thumb: if a sig would be mostly `T.untyped`, it isn't worth writing.
73
- Concrete types = value; `T.untyped` sigs = paperwork.
74
- - `railtie.rb` / `tasks.rb` are `# typed: ignore` (Rails/Rake DSL).
75
-
76
- ## Design invariants (don't "fix" these)
77
-
78
- - **The client slot is duck-typed.** A transport, `Retry`, a live graphql-ruby
79
- schema class, or a test fake all satisfy one contract —
80
- `execute(query, variables:) => {"data" => ..., "errors" => ...}` — with no
81
- shared base class. **Don't formalize it as a strict Sorbet interface**: a
82
- graphql-ruby `Schema` class fits the slot without inheriting anything, and a
83
- strict interface would exclude it. This is why the transport/client seams stay
84
- loosely typed.
85
- - **Codegen is query-driven.** Structs are generated per selection set, only for
86
- the types a query actually touches — not the whole schema (so extra schema
87
- types, e.g. federation `join__*`, generate no code).
88
- - **Leaf codecs vs composite decoration.** `register_scalar` / `register_enum`
89
- *define/replace* how a leaf deserializes (its Ruby shape is fixed);
90
- `extend_type` only *decorates* a generated composite struct with mixins — it
91
- can't replace one, because a composite's shape varies per query. Don't add a
92
- "replace a composite's deserializer" path.
93
-
94
- ## Green before commit
95
-
96
- `bundle exec` needs the rvm ruby — the default PATH ruby is 2.6 and can't even
97
- load bundler:
98
-
99
- ```sh
100
- source ~/.rvm/scripts/rvm && rvm use 3.4.9
101
- ```
102
-
103
- Then:
104
-
105
- ```sh
106
- bundle exec rspec # full suite
107
- bundle exec srb tc # Sorbet typecheck (CI gates on this too)
108
- bundle exec ruby bin/generate # regenerate fixtures — must leave the tree clean
109
- ```
110
-
111
- All three must pass. Sorbet sigs are runtime-checked by sorbet-runtime, so a
112
- wrong sig surfaces as an rspec failure, not only a `srb tc` error — a green suite
113
- validates the sigs against real usage.
114
-
115
- Two more when the change could reach them:
116
-
117
- ```sh
118
- bundle exec rspec --order rand:1 # and a couple of other seeds
119
- bundle exec ruby bin/federation-diff # the fixture supergraph still composes
120
- ```
121
-
122
- Order-independence is worth checking rather than assuming — four order-dependent
123
- failures have hidden behind the default `:defined` order, and a *burst* of them
124
- usually means one shared resource cascading rather than many bugs.
125
-
126
- ## Drive it from a throwaway app when the host seam changes
127
-
128
- **The suite cannot test the gem's relationship with its host.** It is not a Rails
129
- app, so anything that depends on Rails' boot order, Zeitwerk, or rake's task
130
- graph is structurally invisible to it — and both bugs found that way were silent
131
- in development and only appeared in production boot or when registrations
132
- mattered:
133
-
134
- - `rake graph_weaver:generate` never ran `:environment`, because the task asked
135
- `Rake::Task.task_defined?("environment")` at *load* time and Rails defines it
136
- *after* railties' `rake_tasks` blocks. Every `register_scalar`/`extend_type` in
137
- an initializer was silently dropped at generation, and `verify` reported the
138
- result up to date.
139
- - `app/graphql/generated` sits under a Zeitwerk root while its files define
140
- top-level constants, so eager loading raised `NameError`. Lazy dev boot was
141
- fine; production was not.
142
-
143
- So when the railtie, the rake tasks, the generator, or the documented install
144
- path changes, spin up a scratch Rails app outside the repo, point its Gemfile at
145
- your checkout, and actually use it. Worth exercising: **both** a remote endpoint
146
- and the app's own graphql-ruby schema in-process; `RAILS_ENV=production` boot and
147
- `rails zeitwerk:check`; a registration in `config/initializers` that must reach
148
- generated output; the rake tasks end to end; and the testing harness from inside
149
- the app's own specs. The app is disposable — rebuilding it is cheaper than the
150
- bugs it catches.
151
-
152
- ## Version bumps
153
-
154
- Bump `lib/graph_weaver/version.rb` and, in the **same commit**:
155
-
156
- - update `Gemfile.lock` (the gem pins its own version there; CI runs a frozen
157
- `bundle install`, which fails at the *setup* step with exit code 16 — before
158
- tests — if the lock is stale), and
159
- - add a `CHANGELOG.md` entry.
160
-
161
- `gem push` (the actual RubyGems release) is a separate, manual step.
data/DECISIONS.md DELETED
@@ -1,309 +0,0 @@
1
- # Decisions
2
-
3
- Roads not taken, and why. The code shows what was chosen; it is silent about
4
- what was considered and rejected — and those are the ones that get re-litigated,
5
- usually by someone with the same good instinct that was already followed to its
6
- end once.
7
-
8
- Only entries where the rejected path is *tempting* belong here. An obvious call
9
- needs no record. What changed and when lives in `CHANGELOG.md`; the invariants
10
- and principles live in `CLAUDE.md`; stated non-goals (subscriptions, `@defer`,
11
- uploads, normalized caching, fragment masking, batching) live in `REVIEW.md` §7.
12
-
13
- ---
14
-
15
- ## The client slot stays duck-typed
16
-
17
- **Considered:** formalizing `execute(query, variables:, operation_name:)` as a
18
- Sorbet interface or a base class, so the thing every client implements is
19
- declared rather than implied.
20
-
21
- **Rejected because** a live graphql-ruby `Schema` class satisfies the slot and
22
- cannot inherit from us. An interface would exclude the one implementation we
23
- neither wrote nor control — and that implementation is the whole in-process
24
- story. The openness is the feature.
25
-
26
- Downstream of this: `Parsing` requires `#schema` of its includers and can't
27
- declare it, hence one `T.unsafe(self)` in that mixin.
28
-
29
- ## `InProcess` lives outside `Transport::`
30
-
31
- **Considered:** moving it under `Transport::`, or restructuring so
32
- `Transport::HTTP` parents a native and a Faraday implementation and `InProcess`
33
- slots in beside them.
34
-
35
- **Rejected because** `Transport` is a base class, not a namespace: it owns the
36
- GraphQL-over-HTTP flow (encode the body, classify network failures, `ServerError`
37
- on non-2xx, parse). `InProcess` implements none of that. And the restructure
38
- wouldn't achieve its goal anyway — a bare `Schema` class is in the client slot
39
- too, so `Transport::` still wouldn't mean "things you can pass as `transport:`".
40
- The taxonomy can't be clean because the slot is deliberately open.
41
-
42
- Renaming the abstract base (`Transport::Base`) to stop it sharing a name with its
43
- namespace is the smaller, honest version if this ever itches again.
44
-
45
- ## `Transport::HTTP` is the default; Faraday is opt-in
46
-
47
- **Considered:** keeping the previous `defined?(::Faraday)` auto-detection.
48
-
49
- **Rejected because** Faraday arrives transitively through stripe, octokit and
50
- friends, so adding an unrelated gem silently changed your transport, your
51
- timeouts, and your connection reuse (Faraday's default adapter reconnects per
52
- request; measured 10 connections for 10 requests versus 1). Behaviour that
53
- depends on what else is in the Gemfile is unreasonable-about-able. Same code,
54
- same transport.
55
-
56
- ## Cassettes live in `spec/cassettes`, not `spec/fixtures`
57
-
58
- **Considered:** `spec/fixtures/`, or a namespaced `spec/fixtures/graph_weaver/`,
59
- as the more conventional home for test data.
60
-
61
- **Rejected because** Rails globs the fixture path for `{**,*}/*.yml`
62
- (`active_record/test_fixtures.rb`) — recursively, so a subdirectory doesn't save
63
- you. `fixtures :all` would try to load cassettes as ActiveRecord fixtures. The
64
- conventional-looking choice is the broken one.
65
-
66
- ## Directories organize queries; they don't namespace modules
67
-
68
- **Considered:** deriving module names from nested query directories, so
69
- `admin/pets.graphql` becomes `AdminPetsQuery` instead of colliding with
70
- `pets.graphql`.
71
-
72
- **Rejected because** it *moves* collisions rather than removing them —
73
- `admin/pets.graphql` and `admin_pets.graphql` would then collide — so the
74
- refusal has to exist either way, and the naming rule stops being statable
75
- without describing which path segments count. A duplicate base name refuses,
76
- naming both files.
77
-
78
- ## `#parse` requires a schema, so `Retry` doesn't have it
79
-
80
- **Considered:** delegating `#parse` through `Retry` to whatever it wraps, so
81
- `Retry.new(client).parse(q)` reads naturally.
82
-
83
- **Rejected because** `respond_to?(:parse)` would then be true half the time and
84
- false the other half — a `Retry` over a bare HTTP transport has no schema to
85
- reach through to. The rule is "anything holding a schema can parse against it",
86
- which is a domain, not an exception. `Retry` doesn't meet the precondition.
87
-
88
- ## Mutations generate `…Mutation` modules
89
-
90
- **Considered, and initially rejected:** keeping the uniform `…Query` suffix,
91
- because renaming breaks call sites *and* because the generated filename changes,
92
- leaving a stale `_query.rb` that `load_generated!` keeps requiring.
93
-
94
- **Reversed once generated-file pruning landed** — the stale-file half of the
95
- objection dissolved, and the remaining "one rule beats a conditional one"
96
- argument lost to `SaveListEntryMutation.execute!` being what a user types every
97
- day. The generated filename mirrors the constant, so `ls generated/` still
98
- answers "what's the constant".
99
-
100
- ## One shared types module, not three
101
-
102
- **Considered:** keeping `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`
103
- separate on the grounds that their file shapes genuinely differ (a manifest plus
104
- per-type files versus a single file).
105
-
106
- **Rejected because** file layout is an implementation detail and the constant a
107
- user types is not. Merging also *deleted* a mechanism: the artifacts used to
108
- alias each other's constants across files, which lexical scope now handles for
109
- free. Per-type files were extended to all three rather than dropped — adding an
110
- enum value diffs one file.
111
-
112
- ## Subgraph mapping is derived, then verified — never guessed
113
-
114
- **Considered:** requiring an explicit `subgraphs:` map, on the grounds that
115
- auto-detection is guessing and a wrong guess silently points a test suite at the
116
- wrong resolvers.
117
-
118
- **Rejected because** matching on *what a schema defines* against the routing
119
- table is a derivation with evidence, not a guess — and the ambiguous cases
120
- (two candidates, or none) refuse rather than pick. Detection and validation are
121
- the same check run in two directions, so there is no second code path to
122
- disagree.
123
-
124
- ## Only some `ArgumentError`s were branded
125
-
126
- **Considered:** two uniform answers. Brand all ~50 `raise ArgumentError` sites
127
- under `GraphWeaver::Error`, so "everything descends from `Error`" is literally
128
- true; or leave subgraph detection's refusals as `ArgumentError` and qualify the
129
- sentence in `docs/errors.md`.
130
-
131
- **Rejected because** the first throws away the one thing `ArgumentError`
132
- communicates — you passed something wrong at this call site, like any Ruby
133
- method — and `pool_size: must be >= 1` is exactly that. The second leaves the
134
- refusals a `Testing::Router` user actually meets outside the umbrella the docs
135
- point them at, which is where a spec helper rescues.
136
-
137
- What survives is a line that can be stated: **what the library concludes,
138
- having read your schema, is a `GraphWeaver::Error`; an argument wrong on its
139
- face is an `ArgumentError`.** Subgraph mapping (`ConfigurationError`) and a
140
- query file whose name can't spell a constant are verdicts; `cast:` not being a
141
- Symbol is not. A rule with a stated boundary beats a uniform one that lies
142
- about half its cases.
143
-
144
- ## An abstract type is bucketed on `__typename`, not planned away
145
-
146
- **Considered:** keeping the `abstract_boundary` refusal, on the reasoning that a
147
- representation needs one concrete `__typename` and the planner — which takes no
148
- variables and runs before any fetch — cannot know it.
149
-
150
- **Rejected because** the planner doesn't have to know it. It only has to plan
151
- *every* possibility: the supergraph says which concrete types a subgraph can
152
- answer a union or interface with, so the plan carries a branch per type and
153
- execution picks the one the data came back as. Deciding at execution is the
154
- existing precedent — `@skip`/`@include` already filter deferrals against the
155
- variables in hand for exactly the same reason.
156
-
157
- The corollary is smaller and sharper than the rule it replaced. A fragment
158
- whose condition can't hold at a position — `... on Note` where the answering
159
- subgraph's union holds no Note — is **dropped**, not refused, even though
160
- "refuse rather than guess" pulls the other way. It isn't a guess: the fragment
161
- can never match, so `{}` is the only answer, and a real `@apollo/gateway`
162
- returns exactly that. What still refuses is the case where the supergraph
163
- genuinely doesn't say — no `@join__unionMember`/`@join__implements`, and the
164
- type in more than one subgraph — because then the branch list itself would be
165
- invented.
166
-
167
- ## A nested field set crosses whole, or not at all
168
-
169
- **Considered:** assembling a nested `@key`/`@requires` object from more than
170
- one fetch — `store { id }` from the subgraph in hand and `store { region
171
- { code } }` from a prefetch, deep-merged into one representation. It is what
172
- `@apollo/gateway` does internally (`deepMerge(entity, dataReceivedFromService)`),
173
- and it would close the last nested case rather than refusing it.
174
-
175
- **Rejected because** the only shapes that produce the split are ones where the
176
- gateway is no longer an oracle. A field set reaching *through* a key field is
177
- the common one, and there the gateway doesn't split at all: composition drops
178
- `@external` from key fields — an entity's key is answerable by any subgraph
179
- declaring it — so the extending subgraph looks able to resolve the whole path,
180
- the gateway satisfies the `@requires` locally, and gets back whatever that
181
- subgraph happens to hold. Merging would mean answering *better* than the
182
- gateway, which under `0 wrong` is the same failure as answering worse. So a
183
- root fed by two fetches refuses, naming both halves and where each comes from.
184
-
185
- That trap is also why the fixture graph's nested `@requires` walks a plain
186
- external field (`dimensions`) and not the nested `@key`'s object (`store`):
187
- the first is diffable against a real gateway, the second isn't.
188
-
189
- ## The in-process router refuses rather than approximates
190
-
191
- **Considered:** planning every query shape, falling back to a best-effort answer
192
- where the semantics are uncertain.
193
-
194
- **Rejected because** a test double that answers 5% of queries differently from
195
- production is worse than one that answers 80% and declines the rest loudly. The
196
- refusal boundary *is* the product. Non-null propagation is the concrete reason:
197
- before that pass existed, three queries returned silently wrong data where the
198
- real router returned `data: null`.
199
-
200
- A corollary: a `--strict` mode for the drift differ was built and then deleted,
201
- because once a partly-local supergraph became a supported setup, failing on any
202
- skipped subgraph was wrong for every graph except a fully-local one — and that
203
- one's report already says "checked 3 of 3".
204
-
205
- ## `federation:diff` fails when it checked *nothing*
206
-
207
- **Considered:** leaving zero-checked as a pass, on the `--strict` reasoning
208
- directly above — absence is supported, and the headline already says "checked 0
209
- of 4".
210
-
211
- **Rejected because** zero is not a small number of subgraphs, it is a different
212
- kind of answer: the gate would pass whatever the subgraphs said, so a green run
213
- carries no information at all. "Checked 3 of 4" did real work. And the failure
214
- that produced it was silent — Rails leaves `rake_eager_load` false, so a stock
215
- app's CI gated on nothing while printing honest prose. There is no setup where
216
- you'd deliberately run this task against a supergraph none of whose subgraphs
217
- are here; the abort says to drop it from CI if that's really you.
218
-
219
- The rule stays statable in one sentence: it fails when it found drift, and when
220
- it had nothing to look at.
221
-
222
- ## Output structs allow Ruby-keyword prop names
223
-
224
- **Considered:** narrowing the ban to types with a registered `alias:` whose path
225
- starts at the prop.
226
-
227
- **Rejected because** the ban turned out to be unnecessary, not merely too broad.
228
- All 33 producible keywords construct, deserialize and typecheck as props; the
229
- only bare read is an `alias:` delegator's first hop, which now spells
230
- `self.next`. The proposed narrowing would also have made generation depend on
231
- unrelated global registry state.
232
-
233
- ## Queries directories are a list again
234
-
235
- **Considered:** leaving `queries_path` singular, as 0.4.x made it — one
236
- `generate!` run reads one directory against one schema, and a second entry
237
- would produce modules at runtime that `rake graph_weaver:generate` never
238
- generated and `verify` never checked.
239
-
240
- **Rejected because** that failure was the *divergence*, not the plurality:
241
- back then `load_queries!` walked the list and `generate!` read only its first
242
- entry. Every reader now goes through `GraphWeaver.query_files`, so a second
243
- directory is generated, verified and loaded alike — and a duplicate module
244
- name across two directories refuses, as it already did within one. What
245
- survives is the honest half of the argument: one run reads one *schema*, so
246
- `schema_path` stays singular.
247
-
248
- ## Deferred, deliberately
249
-
250
- - **`write_timeout` on `Transport::HTTP`** — a real gap (nothing bounds sending),
251
- but not yet worth a kwarg. Note `open_timeout` *is* the connect timeout and
252
- covers the TLS handshake too (`net-http`'s `ssl_socket_connect(s, @open_timeout)`);
253
- `ssl_timeout` is the OpenSSL session timeout and not a handshake deadline.
254
- - **A `net_http:` passthrough hash** — the answer if the timeout/TLS kwarg list
255
- keeps growing. Deliberately a hash and not a block: the pool creates
256
- connections lazily and on failure, so a block would run an unpredictable number
257
- of times. Configuration survives that; behaviour doesn't.
258
-
259
- ## Coercion says *whether*, never *how*
260
-
261
- **Considered:** keeping `coerce: <Symbol>` (`register_scalar("ID", String, coerce: :to_s)`),
262
- which let a registration name the conversion as well as opt into it.
263
-
264
- **Rejected because** it asked the user to answer a question the library already
265
- answers — the conversion for every scalar that has one is derived from the
266
- scalar itself, and a custom scalar's conversion is its `cast:`/`serialize:`
267
- pair. Its documented showcase existed only to re-enable something deliberately
268
- removed from the auto path: the feature arguing for its own removal.
269
-
270
- **Also considered:** dropping the `Int`/`Float` conversion entirely, leaving
271
- parse as the single coercion mechanism. **Rejected because** `first: params[:page_size]`
272
- arriving as a String is the most common real coercion in a Rails app, and
273
- without it `auto_coerce` would loosen nothing among the built-ins but `Date` —
274
- capability loss wearing simplicity's clothes.
275
-
276
- `coerce:` and `auto_coerce` both survive because they are one question at two
277
- scopes — a global default with a local override, the standard shape.
278
-
279
- ## `config.schema` refuses a subgraph rather than splitting in two
280
-
281
- **Considered:** splitting the setting, since it serves two masters — the schema
282
- fakes are fabricated against, and the live class `:in_process` runs. In a
283
- federated app you can't have both, and setting one to make `:in_process` work
284
- silently repointed `:fake` at a fraction of the graph.
285
-
286
- **Rejected because** the split is a second knob plus a rule about which one
287
- applies, and it buys a capability nothing lost: `:router` runs a subgraph's
288
- real resolvers too, stitched. The two masters only want different objects in a
289
- federated app, so refusing a subgraph class is the smaller change that makes
290
- `config.schema` mean one thing again — and the refusal `:in_process` already
291
- raises ("a federated graph has no one schema class — tag those examples
292
- `graphql: :router`") becomes the whole story instead of half of it.
293
-
294
- ## The `graphql:` tag names a mode; a client is built in the example
295
-
296
- **Considered:** letting the tag carry a client — `graphql: Failure.throttled`,
297
- or `graphql: FakeClient.new(overrides: …)` — which reads well and would make
298
- the tag's value a description rather than the cleanup marker it had become.
299
-
300
- **Rejected because** metadata is evaluated when the file loads: one client
301
- object would be shared by every example in the group, built before
302
- `Testing.configure` had run. Spooky at a distance, and stateful — `#requests`
303
- would accumulate across examples.
304
-
305
- What the reading was right about was the leak underneath, and that is fixed
306
- elsewhere: `GraphWeaver.client` is snapshotted and restored around *every*
307
- example, so the tag no longer earns its keep as a cleanup marker, and building
308
- a client is a plain assignment in a `before` block. `graphql_fake(**options)`
309
- exists only because a fake needs the schema derivation the tag was doing.
data/Makefile DELETED
@@ -1,23 +0,0 @@
1
- .PHONY: check generate test tc integration docs
2
-
3
- # full verify loop: regenerate, test, typecheck
4
- check: generate test tc
5
-
6
- # manual/one-off checks against real GraphQL APIs (network; GitHub needs
7
- # `gh auth login` or GITHUB_TOKEN; federation boots an Apollo gateway — node)
8
- integration:
9
- INTEGRATION=1 bundle exec rspec spec/integration
10
-
11
- generate:
12
- bundle exec ruby bin/generate
13
-
14
- test:
15
- bundle exec rspec
16
-
17
- tc:
18
- bundle exec srb tc
19
-
20
- # preview what rubydoc.info will render
21
- docs:
22
- bundle exec yard doc
23
- open doc/index.html
data/NOTES.md DELETED
@@ -1,182 +0,0 @@
1
- Research notes — graphql-client exploration → GraphWeaver
2
- ======
3
-
4
- **What this is now:** a working prototype of a standalone, Sorbet-typed
5
- GraphQL client for Ruby — "graphql-codegen for Ruby". `.graphql` queries +
6
- a schema (live class, introspection JSON, or SDL) generate `# typed: strict`
7
- Ruby: nested `T::Struct`s, casting code, and a typed `execute`, so `srb tc`
8
- sees the exact shape of every query result. It is **not** a graphql-client
9
- extension: generated code depends only on `graphql` (generation time) and
10
- `sorbet-runtime` (runtime); transport is a pluggable `executor:` (in-process
11
- schema or the bundled `HttpExecutor`).
12
-
13
- Start with `PLAN.md` for current state and next steps. Key files:
14
- `lib/struct_codegen.rb` (the generator), `queries/` → `bin/generate` →
15
- `lib/generated/` (the build loop), `StructCodegen.load` (build-free dynamic
16
- mode for development).
17
-
18
- **How it got here:** the repo began as an exploration of
19
- [graphql-client](https://github.com/github-community-projects/graphql-client)
20
- internals — could its class-generation layer be swapped to emit custom
21
- classes? (Yes: the `StructTypes` spike below.) The per-query codegen
22
- approach then outgrew graphql-client entirely, and everything below the
23
- next heading is preserved as the lab notebook: findings in chronological
24
- order, each backed by a spec.
25
-
26
- The specs are the documentation — each one asserts an observed behavior:
27
-
28
- ```sh
29
- bundle exec rspec
30
- ```
31
-
32
- ## Findings, in exploration order
33
-
34
- - The client runs fine against an in-process schema: `GraphQL::Client.new(schema: Schema, execute: Schema)` — no HTTP involved.
35
- - Each query selection gets its own dynamically generated wrapper class (subclass of `GraphQL::Client::Schema::ObjectClass`); fields are snake_case readers, and unselected fields raise instead of returning nil.
36
- - **Custom scalars are deserialized automatically** when the client is built from a live schema class: the reader casts wire values through the scalar's `coerce_input` (e.g. `"1990-06-15"` → `Date`). This is the built-in hook for producing rich Ruby values.
37
- - Caveat: this only works with `schema:` as a live schema class. A schema loaded from an introspection JSON dump has no coercion logic, so scalars would stay raw.
38
- - `to_h` returns the raw wire values (strings), not the casted ones — hydration code should read via the typed readers, not `to_h`.
39
- - Hydrating into `T::Struct`s is straightforward manually; the interesting next step is generating the structs (or a generic hydrator) from the parsed query definition, since the client already knows each selection's shape and types.
40
-
41
- ## Swapping the class-generation layer (answered: yes)
42
-
43
- `lib/struct_types.rb` + `spec/struct_types_spec.rb` prove the generation layer
44
- can be replaced wholesale — the client deserializes straight into generated
45
- `T::Struct`s, no `ObjectClass` involved.
46
-
47
- How the pipeline hangs together (graphql-client 0.26.0):
48
-
49
- - `Client#initialize` builds the types module: `@types = Schema.generate(schema)`
50
- (`attr_reader :types`, no setter — swap via `instance_variable_set` or a subclass).
51
- - `Client#parse` → `Definition#initialize` calls
52
- `client.types.define_class(definition, ast_nodes, type)` and stores the result
53
- as `definition.schema_class`. This is the ONLY thing the client asks of the
54
- types module.
55
- - `Client#query` → `definition.new(data, errors)` → `schema_class.new(data, errors)`.
56
- - Everything below that is the `cast(value, errors)` protocol, composed
57
- recursively per the query selection (NonNull/List wrappers, scalars, objects).
58
-
59
- So the replacement contract is just:
60
- - `define_class(definition, ast_nodes, type)` returning casters
61
- - casters respond to `cast(value, errors)`
62
- - the top-level caster must satisfy `Definition#new`'s case dispatch, which
63
- tests `===` against the `GraphQL::Client::Schema::ObjectType` module —
64
- including that module in your caster class is enough, plus a
65
- `new(data, errors)` method
66
-
67
- Gotchas found:
68
- - the client injects `__typename` into every selection (`QueryTypename`), so a
69
- custom generator must skip/handle `__`-prefixed fields
70
- - scalar casting reuses the schema type's `coerce_isolated_input` — same hook
71
- the stock `ScalarType` uses
72
- - prop nullability comes for free from the type walk: everything is
73
- `T.nilable` unless wrapped in NON_NULL
74
-
75
- ## Sorbet
76
-
77
- - `sorbet` + `tapioca` are set up (`bundle exec srb tc` is green); rbis in `sorbet/rbi/gems`
78
- - `struct_types.rb` typechecks at `# typed: true`
79
- - generated structs are real `T::Struct`s: schema-derived prop types
80
- (`T.nilable(Date)`, `T::Array[StructTypes::Pet]`) and runtime type
81
- enforcement on bad wire data
82
-
83
- ## Codegen: srb tc sees query result types (answered: yes)
84
-
85
- `lib/struct_codegen.rb` goes one step further than the runtime swap: it
86
- emits plain `# typed: strict` Ruby source from a query + schema — nested
87
- `T::Struct` classes, fully generated `from_h` casting code (no runtime
88
- reflection), and a sig'd `execute`.
89
-
90
- - source of truth: `queries/*.graphql`; regenerate with `bin/generate`
91
- into `lib/generated/`; a spec asserts the checked-in output is current
92
- - queries are validated against the schema at generation time
93
- - `srb tc` statically checks result access end to end:
94
- `result.person&.nmae` → `Method nmae does not exist on
95
- PersonQuery::Result::Person`
96
- - custom scalar deserialization is inlined by the generator
97
- (`Date.iso8601(...)`) via a scalar registry; nullability and list
98
- casting come from the NON_NULL/LIST walk
99
- - note: generated `execute` runs against the schema directly, replacing
100
- graphql-client at runtime entirely — the client's remaining value here
101
- would be its HTTP adapter, which the generated code could target instead
102
-
103
- ## Fragments & unions (answered for codegen)
104
-
105
- `queries/search.graphql` + `lib/generated/search_query.rb` exercise the
106
- design:
107
-
108
- - inline fragments and named fragment spreads are flattened into their
109
- matching member's selection (exact type-name condition match; interface
110
- conditions still open)
111
- - unions emit a module per selection site: one `T::Struct` per possible
112
- type, a `Type = T.type_alias { T.any(...) }`, and a `from_h` that
113
- dispatches on `__typename` — codegen refuses union selections that
114
- don't select `__typename`
115
- - every possible type gets a member struct even without a fragment (it
116
- still carries `__typename`), so dispatch is total
117
-
118
- ## Introspection / __type metadata
119
-
120
- - `__type` / `__schema` queries work against the demo schema as expected
121
- (see `spec/introspection_spec.rb` for the shapes)
122
- - the key result: `GraphQL::Schema.from_introspection(Demo::Schema.as_json)`
123
- produces a schema that codegen runs against **byte-identically** — so
124
- generation works for remote APIs known only via an introspection dump.
125
- Custom scalar handling survives because the codegen scalar registry is
126
- keyed by type *name*, unlike runtime `coerce_input` which needs the live
127
- schema class (the caveat that broke graphql-client's scalar casting)
128
-
129
- ## Federation / supergraph
130
-
131
- - join__/link-annotated supergraph SDL parses via
132
- `GraphQL::Schema.from_definition`, and codegen runs against it
133
- unchanged — the directives are transparent to result typing
134
- (`spec/federation_spec.rb` generates from a mini supergraph and casts a
135
- response with no live subgraphs)
136
- - gotcha: graphql-ruby's SDL builder does not apply directive-argument
137
- defaults, so real Apollo `join v0.3` SDL (non-null defaulted args like
138
- `extension: Boolean! = false`) fails to load unless those args are
139
- provided or the directive defs are trimmed — a compatibility issue a
140
- real tool would need to patch around
141
- - client-side, federation needs nothing more: you query the router like
142
- any schema. The *server-side* angle (emitting `@key`/`@external` via
143
- apollo-federation) is a separate exploration — potentially relevant to
144
- autographql
145
-
146
- ## Round 2: enums, interface conditions, loaders, dynamic mode, HTTP
147
-
148
- - **enums** generate `T::Enum` classes (`Species::Dog`), deserialized via
149
- `Species.deserialize(...)` in `from_h`; values sorted so output is
150
- deterministic across schema sources
151
- - **interface fragment conditions** (`... on Named { name }`) apply via
152
- `schema.possible_types`, not just exact type-name match. Interface-typed
153
- *fields* (a field returning `Named`) are still open — they'd emit like
154
- unions with `__typename` dispatch
155
- - **SchemaLoader** accepts both formats a remote service can hand you:
156
- introspection dump (`.json`) or SDL (`.graphql`/`.gql`); both generate
157
- byte-identically to the live schema class
158
- - **dynamic mode**: `StructCodegen.load(...)` generates + evals in one
159
- step — no build artifact, same runtime semantics, right for development
160
- or one-off scripts. Tradeoff: the module is invisible to `srb tc`, so
161
- static checking of result access needs the build step
162
- - **HTTP transport**: generated `execute` takes `executor:` — anything
163
- with `execute(query, variables:)` returning `{"data" => ...}`.
164
- `HttpExecutor` (Net::HTTP POST) runs the same generated structs against
165
- a live server (`spec/http_spec.rb` proves it against a local WEBrick
166
- serving Demo::Schema)
167
- - **directive defaults gap**: root cause found —
168
- `BuildFromDefinition#prepare_directives` passes only usage-site args
169
- while `Directive#initialize` validates all defined args without
170
- applying `default_value`. `lib/directive_defaults_patch.rb` prepends
171
- the fix; the federation spec now loads the *real* join v0.3 SDL.
172
- Present in graphql 2.6.3 (latest) — worth an upstream issue/PR
173
-
174
- ## Open questions
175
-
176
- - interface-typed fields (vs fragment conditions, which work)
177
- - ~~name collisions~~ ANSWERED: path-based won. A generated type is named
178
- for the response key that selects it, so the name is a function of the
179
- field's own position — no walk order, no first-come-first-served, and an
180
- unrelated selection can't move it. GraphQL aliases double as the explicit
181
- naming escape hatch (`pet: pets` names the struct `Pet`)
182
- - mutations/subscriptions (only query operations generate)