graph_weaver 0.4.6 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/docs/generated_modules.md
CHANGED
|
@@ -8,25 +8,63 @@ This is the production path — checked in, reviewed, statically checked
|
|
|
8
8
|
(assembled step by step in the [getting started](getting_started.md), including
|
|
9
9
|
[what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without)).
|
|
10
10
|
For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
|
|
11
|
-
scripts, `client.
|
|
11
|
+
scripts, `client.run!` skips modules entirely.
|
|
12
12
|
|
|
13
13
|
## Generating
|
|
14
14
|
|
|
15
15
|
The workflow that keeps generated code honest: queries live as `.graphql`
|
|
16
16
|
files (the source of truth), generation writes the Ruby, and verification
|
|
17
17
|
fails when the two drift. The conventional layout (configurable via
|
|
18
|
-
`GraphWeaver.
|
|
18
|
+
`GraphWeaver.queries_paths` / `generated_paths` / `schema_path`):
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
21
|
app/graphql/
|
|
22
22
|
schema.json # introspection dump (or schema.graphql SDL)
|
|
23
|
-
queries/ # *.graphql — hand-written, reviewed
|
|
23
|
+
queries/ # *.graphql / *.gql, nested — hand-written, reviewed
|
|
24
|
+
fragments/ # shared fragments, spread by name from any query
|
|
24
25
|
generated/
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
types.rb # manifest: requires + forward declarations, in load order
|
|
27
|
+
types/ # one file per shared type
|
|
27
28
|
*_query.rb # one module per query — generated, checked in, never edited
|
|
29
|
+
*_mutation.rb # ...and per mutation
|
|
28
30
|
```
|
|
29
31
|
|
|
32
|
+
**A type shared across query modules lives in `GraphQLTypes` and is aliased
|
|
33
|
+
in.** Input types, schema enums, and unions hoisted from shared fragments are
|
|
34
|
+
all one kind of thing — a type that would otherwise be copied into every query
|
|
35
|
+
that touches it — so they live in one module, one file each, and a query module
|
|
36
|
+
that uses any of them opens with `require_relative "types"`. Rename the constant
|
|
37
|
+
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
38
|
+
generates against two schemas, in the same initializer that already gives each
|
|
39
|
+
its own paths.
|
|
40
|
+
|
|
41
|
+
One module is one namespace, so a shared fragment whose name is already a schema
|
|
42
|
+
type in that module is refused at generation, naming both.
|
|
43
|
+
|
|
44
|
+
**Naming.** A module is named after its **file**, suffixed with the operation
|
|
45
|
+
the file defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
|
|
46
|
+
`save_list_entry.graphql` → `SaveListEntryMutation` in
|
|
47
|
+
`save_list_entry_mutation.rb`. The operation name written *inside* the file
|
|
48
|
+
never names the module (it goes on the wire as `operationName`); leave it off
|
|
49
|
+
and the module's name is written into the document instead. The same rule
|
|
50
|
+
runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
|
|
51
|
+
`client.load_queries!`.
|
|
52
|
+
|
|
53
|
+
Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
|
|
54
|
+
found, but the module name still comes from the file name alone, so it is
|
|
55
|
+
`PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
|
|
56
|
+
generation, naming both, rather than one silently overwriting the other; so is a
|
|
57
|
+
file holding two operations, since one file can't name two modules.
|
|
58
|
+
|
|
59
|
+
Change a file's `query` to `mutation` and its constant changes with it; the
|
|
60
|
+
next `generate!` prunes the old file, and `verify` fails until you regenerate.
|
|
61
|
+
|
|
62
|
+
Parsing a raw query *string* has no file to name it after, so it uses the
|
|
63
|
+
operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
|
|
64
|
+
`Query` for an anonymous one (its constants are container-scoped, so collisions
|
|
65
|
+
are impossible) while `Codegen.generate` insists on a deliberate name. Pass
|
|
66
|
+
`module_name:`/`name:` to override any of this.
|
|
67
|
+
|
|
30
68
|
The schema dump is step 0 — codegen reads it, never a live endpoint.
|
|
31
69
|
`cache: true` on a url client writes it on first introspection
|
|
32
70
|
(`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
|
|
@@ -36,14 +74,13 @@ Rake tasks (self-registering in Rails; elsewhere add
|
|
|
36
74
|
`require "graph_weaver/tasks"` to your Rakefile):
|
|
37
75
|
|
|
38
76
|
```sh
|
|
39
|
-
rake graph_weaver:generate #
|
|
77
|
+
rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
40
78
|
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
41
79
|
```
|
|
42
80
|
|
|
43
|
-
Scalar/enum/type registrations are baked into generated source, so they
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
file that does your registrations from the Rakefile yourself.
|
|
81
|
+
Scalar/enum/type registrations are baked into generated source, so they must run
|
|
82
|
+
first. In Rails they do — the tasks depend on `:environment`. Outside Rails,
|
|
83
|
+
require the file that does your registrations from the Rakefile yourself.
|
|
47
84
|
|
|
48
85
|
Or call the same APIs directly:
|
|
49
86
|
|
|
@@ -53,57 +90,71 @@ GraphWeaver.generate!(schema:) # write the modules
|
|
|
53
90
|
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
54
91
|
```
|
|
55
92
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
**Generation prunes.** Rename or delete a `.graphql` and the module it used
|
|
94
|
+
to produce is deleted on the next `generate!`; `verify` flags it as stale
|
|
95
|
+
until you regenerate. Only files carrying GraphWeaver's `# Generated by
|
|
96
|
+
GraphWeaver — do not edit.` header are ever deleted, so hand-written files
|
|
97
|
+
in the output directory are safe.
|
|
98
|
+
|
|
99
|
+
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
100
|
+
boot, after your initializers (so registrations run first). Elsewhere it's
|
|
101
|
+
explicit, factory_bot-style:
|
|
59
102
|
|
|
60
103
|
```ruby
|
|
61
|
-
GraphWeaver.load_generated! # require every file under
|
|
104
|
+
GraphWeaver.load_generated! # require every file under generated_paths
|
|
62
105
|
```
|
|
63
106
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
108
|
+
`fragments_paths` — and every entry is read (entries may be globs; the
|
|
109
|
+
generated default includes `app/graphql/*/generated`, so per-schema layouts
|
|
110
|
+
load too). Append a test-only schema or an engine's queries and every reader
|
|
111
|
+
walks them all:
|
|
68
112
|
|
|
69
113
|
```ruby
|
|
70
114
|
# e.g. in spec/support/graph_weaver.rb
|
|
71
|
-
GraphWeaver.generated_paths << "spec/
|
|
72
|
-
GraphWeaver.queries_paths << "spec/support/graphql/queries"
|
|
115
|
+
GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
73
116
|
```
|
|
74
117
|
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
Assigning a String wraps it, so pointing at one directory stays a one-liner.
|
|
119
|
+
`generate!` writes into the first `generated_paths` entry — one run, one output
|
|
120
|
+
directory. `schema_path` is the one singular setting: a run reads one schema,
|
|
121
|
+
so a list would name a dump nothing ever opens.
|
|
77
122
|
|
|
78
123
|
(Plain requires, not Zeitwerk: Zeitwerk would expect
|
|
79
124
|
`Generated::PersonQuery` from `generated/person_query.rb`, and generated
|
|
80
125
|
code only changes on regeneration — restart, like a schema migration.)
|
|
81
126
|
|
|
82
|
-
Regenerate when: a query changes, the schema changes
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
Introspected dumps record their source url, so drift is checkable ahead
|
|
89
|
-
of the late signal: `rake graph_weaver:schema:verify` re-introspects the
|
|
90
|
-
recorded url and fails when the server has moved;
|
|
91
|
-
`rake graph_weaver:schema:refresh` rewrites the dump
|
|
92
|
-
(`GRAPHWEAVER_AUTH` supplies a token for private APIs). Don't confuse
|
|
93
|
-
the two verifies: `graph_weaver:verify` asks "is the generated code
|
|
94
|
-
fresh?" — local, every CI run; `graph_weaver:schema:verify` asks "has the
|
|
95
|
-
*server* drifted from the dump?" — network, needs the recorded url, run
|
|
96
|
-
on a schedule.
|
|
127
|
+
Regenerate when: a query changes, the schema changes, a scalar registration
|
|
128
|
+
changes, or GraphWeaver itself upgrades (emission may differ across versions;
|
|
129
|
+
`verify_generated!` catches it). The rake tasks that spot a schema change for
|
|
130
|
+
you — `schema:diff`, `schema:refresh`, `queries:check` — are in
|
|
131
|
+
[getting started](getting_started.md#5-verify-in-ci); a
|
|
132
|
+
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
97
133
|
|
|
98
134
|
In development, skip the build entirely — `client.load_queries!` parses
|
|
99
135
|
every query file into modules with the same names generation would use
|
|
100
136
|
(see [dynamic mode](#dynamic-mode)).
|
|
101
137
|
|
|
138
|
+
### Generation is deterministic
|
|
139
|
+
|
|
140
|
+
The same schema and the same queries produce **byte-identical files** — on any
|
|
141
|
+
machine, in any order, however many times you run it. Everything with a
|
|
142
|
+
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
143
|
+
sorted, and a spec asserts it both across calls and against the checked-in
|
|
144
|
+
fixtures.
|
|
145
|
+
|
|
146
|
+
Lean on it: regenerating a file you didn't change produces no diff, so a
|
|
147
|
+
`graph_weaver:generate` in a PR shows exactly what moved, `verify_generated!`
|
|
148
|
+
never fails spuriously, and a generated file is worth reviewing line by line.
|
|
149
|
+
(A GraphWeaver upgrade may legitimately change emission — that's a version
|
|
150
|
+
bump, and the changelog says when to regenerate.)
|
|
151
|
+
|
|
102
152
|
## Anatomy
|
|
103
153
|
|
|
104
154
|
```ruby
|
|
105
155
|
module PersonQuery
|
|
106
156
|
QUERY = "..." # the operation, verbatim
|
|
157
|
+
OPERATION_NAME = "PersonQuery" # its name — the module's, when the file's is anonymous
|
|
107
158
|
|
|
108
159
|
class Result < T::Struct # the response shape, exactly as selected
|
|
109
160
|
class Person < T::Struct
|
|
@@ -116,9 +167,9 @@ module PersonQuery
|
|
|
116
167
|
const :person, T.nilable(Person)
|
|
117
168
|
end
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
def self.execute(client
|
|
121
|
-
def self.execute!(client
|
|
170
|
+
extend GraphWeaver::QueryModule # client / client= (see below)
|
|
171
|
+
def self.execute(id:, client: nil) # -> GraphWeaver::Response[Result]
|
|
172
|
+
def self.execute!(id:, client: nil) # -> Result, or raises QueryError
|
|
122
173
|
|
|
123
174
|
def self.from_response(response) # deserialize a raw hash -> Response[Result]
|
|
124
175
|
def self.from_response!(response) # -> Result, or raises QueryError
|
|
@@ -132,6 +183,14 @@ end
|
|
|
132
183
|
`GraphWeaver::QueryError`.
|
|
133
184
|
- `from_response` / `from_response!` are the **network-free half** of the
|
|
134
185
|
pair — same envelope, but from a response hash you already have (see below).
|
|
186
|
+
- `OPERATION_NAME` rides along on every request as the spec's
|
|
187
|
+
`operationName`, so Apollo Studio, Hasura and your APM key traces, rate
|
|
188
|
+
limits and slow-query reports on the operation instead of lumping every
|
|
189
|
+
request together. **You don't have to name your operations**: an anonymous
|
|
190
|
+
document is named after the module in the emitted `QUERY` *and* in
|
|
191
|
+
`OPERATION_NAME` — both, since a server rejects an `operationName` its
|
|
192
|
+
document doesn't declare. A document that names its own operation is left
|
|
193
|
+
exactly as written.
|
|
135
194
|
|
|
136
195
|
## Deserializing a response from another client
|
|
137
196
|
|
|
@@ -151,9 +210,8 @@ person = response.data!.person # typed, no network
|
|
|
151
210
|
person = PersonQuery.from_response!(raw).person
|
|
152
211
|
```
|
|
153
212
|
|
|
154
|
-
`
|
|
155
|
-
|
|
156
|
-
Errors and extensions are preserved; `#data!` / `from_response!` raise
|
|
213
|
+
`execute` *is* `from_response(client.execute(...))`, so the envelope is
|
|
214
|
+
identical: errors and extensions preserved, `#data!` / `from_response!` raising
|
|
157
215
|
`QueryError` on top-level errors.
|
|
158
216
|
|
|
159
217
|
The one requirement: pass the response **verbatim** — a hash (or anything with
|
|
@@ -170,67 +228,43 @@ mutation($name: String!, $species: Species!, $note: String) { ... }
|
|
|
170
228
|
```
|
|
171
229
|
|
|
172
230
|
```ruby
|
|
173
|
-
|
|
231
|
+
AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
|
|
174
232
|
```
|
|
175
233
|
|
|
176
234
|
- required vs optional falls out of nullability and defaults: nullable or
|
|
177
235
|
defaulted variables become optional kwargs (nil is omitted from the wire,
|
|
178
236
|
so server-side defaults apply)
|
|
179
|
-
- enum variables
|
|
180
|
-
|
|
237
|
+
- enum variables accept the enum or its wire value (`species: Species::Dog`
|
|
238
|
+
or `species: "DOG"`)
|
|
181
239
|
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
182
240
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`
|
|
241
|
+
One kwarg per declared variable, always — so adding a variable to a query
|
|
242
|
+
adds a kwarg and leaves every existing call site alone. Two names are refused at
|
|
243
|
+
generation, `$client` and `$variables`: the generated `execute` body already
|
|
244
|
+
owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
|
|
245
|
+
Rename the variable in the query.
|
|
246
|
+
|
|
247
|
+
**Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
|
|
248
|
+
normalizes underscored Symbol/String keys, enums accept wire values, nested
|
|
249
|
+
inputs accept hashes, and an unknown key raises with a spellchecked hint rather
|
|
250
|
+
than silently dropping:
|
|
186
251
|
|
|
187
252
|
```graphql
|
|
188
253
|
mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
|
|
189
254
|
```
|
|
190
255
|
|
|
191
256
|
```ruby
|
|
192
|
-
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
The wrapping level is rebuilt on the wire, and each field type-checks
|
|
196
|
-
exactly like a variable would. Operations with more than one variable (or
|
|
197
|
-
a nullable input) keep the variable-per-kwarg surface — there the input
|
|
198
|
-
kwarg accepts the generated `T::Struct` or a plain hash (`.coerce`
|
|
199
|
-
normalizes underscored Symbol/String keys; enums accept wire values;
|
|
200
|
-
nested inputs accept hashes; unknown keys raise with a spellchecked
|
|
201
|
-
hint rather than silently dropping):
|
|
257
|
+
AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
|
|
202
258
|
|
|
203
|
-
|
|
204
|
-
|
|
259
|
+
# the struct form is the one srb tc checks field by field
|
|
260
|
+
AdoptMutation.execute!(input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog))
|
|
205
261
|
```
|
|
206
262
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
after its directory (`app/graphql/github/generated` → `GithubInputs`).
|
|
213
|
-
Override the module name globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
-
with `generate!(inputs_module:)`. Per-type files keep schema drift
|
|
215
|
-
reviewable: a migration diffs exactly the types it touched, and types
|
|
216
|
-
the schema drops are pruned on regeneration (`verify` flags strays).
|
|
217
|
-
Query modules alias what they touch,
|
|
218
|
-
so `AdoptQuery::AdoptionInput` still works and shared types keep one
|
|
219
|
-
identity across modules — three filtered Hasura queries cost one ~11k-line
|
|
220
|
-
inputs file plus ~90 lines each, instead of ~35k lines of duplicates.
|
|
221
|
-
Deeply nested types live unaliased in the shared module
|
|
222
|
-
(`GraphQLInputs::PetFilter`). Dynamic `parse` stays self-contained.
|
|
223
|
-
|
|
224
|
-
The structs themselves are module-level (`AdoptQuery::AdoptionInput`):
|
|
225
|
-
typed consts plus a compact per-field `FIELDS` table that the
|
|
226
|
-
`GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`)
|
|
227
|
-
produces the wire hash with nil optionals omitted, `coerce` builds from
|
|
228
|
-
plain hashes. The conversions ship in the generated file as data
|
|
229
|
-
(lambdas in the table), so a Hasura `bool_exp` pulling hundreds of input
|
|
230
|
-
types stays ~2 lines per field instead of unrolled methods. Nested inputs work (dependencies emit
|
|
231
|
-
first), including recursive ones — Hasura's self-referential `bool_exp`
|
|
232
|
-
filters generate cleanly (`_and:`/`_not:` fields typed as the struct
|
|
233
|
-
itself), so variable-driven filtering works:
|
|
263
|
+
A struct is typed consts plus a compact per-field `FIELDS` table the
|
|
264
|
+
`GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`) builds
|
|
265
|
+
the wire hash with nil optionals omitted, `coerce` builds from a plain hash.
|
|
266
|
+
Nested inputs work, including recursive ones — a self-referential filter
|
|
267
|
+
generates cleanly, with `_and:`/`_not:` typed as the struct itself:
|
|
234
268
|
|
|
235
269
|
```ruby
|
|
236
270
|
where = mod::PokemonBoolExp.coerce(
|
|
@@ -239,25 +273,42 @@ where = mod::PokemonBoolExp.coerce(
|
|
|
239
273
|
mod.execute!(where:)
|
|
240
274
|
```
|
|
241
275
|
|
|
276
|
+
In the `generate!` workflow input types are emitted **once per schema**, one
|
|
277
|
+
file per type under `generated/types/` with `types.rb` as the manifest. Query
|
|
278
|
+
modules alias what they touch, so `AdoptMutation::AdoptionInput` still works and
|
|
279
|
+
a shared type keeps one identity across modules. A query module aliases only its
|
|
280
|
+
*variable root* types, so a deeply nested one is reached as
|
|
281
|
+
`GraphQLTypes::<Type>`. Per-type files keep drift reviewable: a schema migration
|
|
282
|
+
diffs exactly the types it touched, and types the schema drops are pruned on
|
|
283
|
+
regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
|
|
284
|
+
|
|
285
|
+
## Enums: one GraphQL enum, one Ruby type
|
|
286
|
+
|
|
287
|
+
Every schema enum a query touches — as a variable, in a result, or both —
|
|
288
|
+
becomes exactly one Ruby type in the shared module, named for the enum
|
|
289
|
+
(`GraphQLTypes::Species`), and every query module aliases it:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
species = SearchQuery.execute!(term: "Shelby").search.first.species
|
|
293
|
+
AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
So a value read out of one query hands straight back into another's variable,
|
|
297
|
+
`case`/`T.absurd` is exhaustive across your app, and the class a field gets
|
|
298
|
+
doesn't depend on what else the query happened to reference.
|
|
299
|
+
|
|
300
|
+
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
301
|
+
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
|
|
302
|
+
the enums into the query module itself; there's no cross-query set to share
|
|
303
|
+
against, but one enum is still one class within that module.
|
|
304
|
+
|
|
242
305
|
## Selections
|
|
243
306
|
|
|
244
307
|
- **Fragments** — inline fragments and named spreads flatten into the
|
|
245
308
|
selection; type conditions match exact names or interfaces/unions the type
|
|
246
309
|
belongs to.
|
|
247
|
-
- **Unions and interfaces** —
|
|
248
|
-
|
|
249
|
-
possible type, `Type = T.type_alias { T.any(...) }`, and a `from_h`
|
|
250
|
-
dispatching on `__typename` — which generation therefore *requires* in
|
|
251
|
-
the selection (the wire response carries no type tag unless you ask).
|
|
252
|
-
Two narrower shapes skip the dispatch (and the `__typename`) entirely:
|
|
253
|
-
interface-level fields only → one shared struct; a single `... on X`
|
|
254
|
-
condition and nothing else → `X`'s struct, always nilable — a
|
|
255
|
-
non-matching runtime type comes back as `nil`, so narrowing doubles as
|
|
256
|
-
filtering. When a whole union field is selected as one named *shared*
|
|
257
|
-
fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
|
|
258
|
-
`GraphQLUnions` module and each query aliases it — so the same union is one
|
|
259
|
-
Ruby type family across queries, not a fresh dispatch module per query. Like
|
|
260
|
-
shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
310
|
+
- **Unions and interfaces** — one struct per type condition the selection
|
|
311
|
+
names, plus a catch-all `Other`. Detail [below](#abstract-types).
|
|
261
312
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
262
313
|
the response regardless of schema nullability, so its generated type is
|
|
263
314
|
always nilable.
|
|
@@ -270,6 +321,38 @@ mode) the struct raises a NoMethodError naming the prop that does exist —
|
|
|
270
321
|
`use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
|
|
271
322
|
a near-miss typo in either casing.
|
|
272
323
|
|
|
324
|
+
### Abstract types
|
|
325
|
+
|
|
326
|
+
An abstract field emits **one struct per type condition the selection names**,
|
|
327
|
+
plus a catch-all `Other`, wrapped in a module with
|
|
328
|
+
`Type = T.type_alias { T.any(...) }` and a `from_h` that dispatches on
|
|
329
|
+
`__typename`. Generation therefore *requires* `__typename` in such a selection,
|
|
330
|
+
unaliased and unconditional — the wire response carries no type tag unless you
|
|
331
|
+
ask, and `from_h` reads it on every response.
|
|
332
|
+
|
|
333
|
+
Size follows the query, not the schema: two `... on` conditions against GitHub's
|
|
334
|
+
`Node` — an interface with a few hundred implementations — emit three structs,
|
|
335
|
+
not a few hundred. Anything the query didn't name — a member you have no
|
|
336
|
+
fragment on, or one the schema grew *after* you generated — deserializes into
|
|
337
|
+
`Other`, carrying what the abstract type itself guarantees (an interface's
|
|
338
|
+
selected interface-level fields; for a union, `__typename`). Adding a union
|
|
339
|
+
member upstream is a non-breaking change, and it stays one here.
|
|
340
|
+
|
|
341
|
+
Two selections have nothing to dispatch between, so they skip the module and
|
|
342
|
+
become the struct directly: **no conditions at all** (interface-level fields
|
|
343
|
+
only) → one shared struct; **exactly one condition** → that type's struct,
|
|
344
|
+
always nilable, since a non-matching runtime type comes back as `nil` — so
|
|
345
|
+
narrowing doubles as filtering. Narrowing reads the match off `__typename` when
|
|
346
|
+
the selection carries it and off "the object came back empty" when it doesn't,
|
|
347
|
+
which is why an all-`@skip`/`@include` narrowed fragment without a `__typename`
|
|
348
|
+
is refused: a match would be indistinguishable from a miss.
|
|
349
|
+
|
|
350
|
+
When a whole union field is selected as one named *shared* fragment
|
|
351
|
+
(`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
|
|
352
|
+
for the fragment — and each query aliases it, so the same union is one Ruby type
|
|
353
|
+
family across queries, not a fresh dispatch module per query. Like shared
|
|
354
|
+
inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
355
|
+
|
|
273
356
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
274
357
|
|
|
275
358
|
`from_h` already reads `__typename` off the wire and builds the right member
|
|
@@ -277,62 +360,194 @@ struct, so what you hold is a real `Book` or `Disc`, not a tag. Branch on the
|
|
|
277
360
|
class and let Sorbet do the rest:
|
|
278
361
|
|
|
279
362
|
```ruby
|
|
280
|
-
items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc)
|
|
363
|
+
items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc, Result::Item::Other)
|
|
281
364
|
case item
|
|
282
365
|
when Result::Item::Book then item.title # narrowed to Book — .title is available
|
|
283
366
|
when Result::Item::Disc then item.runtime # narrowed to Disc — .runtime is available
|
|
367
|
+
when Result::Item::Other then item.__typename # something this query names no fields on
|
|
284
368
|
else T.absurd(item)
|
|
285
369
|
end
|
|
286
370
|
end
|
|
287
371
|
```
|
|
288
372
|
|
|
289
|
-
Two things a `case
|
|
373
|
+
Two things a `case` on the `__typename` string can't give you. `when Book`
|
|
290
374
|
*narrows*: inside the branch `item` is statically a `Book`, so its fields
|
|
291
|
-
typecheck
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
the
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
`
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
375
|
+
typecheck and a `Disc` field is a compile error. And after every branch the
|
|
376
|
+
`T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
|
|
377
|
+
**write a fragment for another member, regenerate, and the `T.absurd` stops
|
|
378
|
+
compiling until you handle it.**
|
|
379
|
+
|
|
380
|
+
Exhaustive over the members *this query asked about*, plus `Other` —
|
|
381
|
+
deliberately not "every type in the schema", which is what keeps a `case` you
|
|
382
|
+
wrote today compiling when upstream adds a member. To make the compiler force
|
|
383
|
+
your hand on a new one, name it in the query.
|
|
384
|
+
|
|
385
|
+
`__typename` is still there as a plain `String`, with one use the class can't
|
|
386
|
+
cover: two *differently-selected* occurrences of the same union are distinct
|
|
387
|
+
type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
|
|
388
|
+
`case` written for one won't span the other. Select the union through a shared
|
|
389
|
+
fragment to hold it as one type across queries ([above](#abstract-types)); if
|
|
390
|
+
all you have is the bare tag, `__typename` is the common denominator, unchecked.
|
|
391
|
+
|
|
392
|
+
## Naming nested types
|
|
393
|
+
|
|
394
|
+
Module names come from the file ([above](#generating)). **Every nested type is
|
|
395
|
+
named for the response key that selects it**, camelized
|
|
396
|
+
(`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
|
|
397
|
+
`Entities`). Structs nest the way the selection does, so the constant path
|
|
398
|
+
reads like the query:
|
|
399
|
+
|
|
400
|
+
```graphql
|
|
401
|
+
query { repository { stargazers { edges { node { login } } } } }
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
StargazersQuery::Result::Repository::Stargazers::Edges::Node
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The name is a function of that field's own position and nothing else, which is
|
|
409
|
+
the property that matters when generated code is checked in and referenced from
|
|
410
|
+
app code: **adding, removing, or reordering an unrelated selection can never
|
|
411
|
+
rename a struct you already use.**
|
|
412
|
+
[`spec/naming_spec.rb`](../spec/naming_spec.rb) asserts each of those three
|
|
413
|
+
edits leaves the name alone.
|
|
414
|
+
|
|
415
|
+
The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
416
|
+
generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
|
|
417
|
+
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
418
|
+
|
|
419
|
+
Two kinds of name don't come from a key, both equally position-determined:
|
|
420
|
+
|
|
421
|
+
- **Union and interface members** are named for the type condition that
|
|
422
|
+
produces them (`... on Book` → `Book`) inside the container named for the
|
|
423
|
+
field, plus the catch-all `Other`. A union hoisted out of a shared fragment
|
|
424
|
+
is named for the fragment.
|
|
425
|
+
- Where several fields share one collapsed union type (identical selections),
|
|
426
|
+
it takes the first of their keys alphabetically; and a name that would shadow
|
|
427
|
+
the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
428
|
+
(`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
|
|
429
|
+
|
|
430
|
+
## Type helpers
|
|
431
|
+
|
|
432
|
+
Derived values (display names, emoji, predicates) belong next to the data but
|
|
433
|
+
not *in* it — rewriting wire values on the way in destroys the raw truth.
|
|
434
|
+
Register a plain module and every struct generated from that GraphQL type
|
|
435
|
+
includes it, whatever query it appears in:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
module PetHelpers
|
|
439
|
+
def adult? = birthday && birthday < Date.today << 24
|
|
440
|
+
def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
GraphWeaver.extend_type("Pet", PetHelpers)
|
|
444
|
+
|
|
445
|
+
pet.display_name # => "Shelby 🦴"
|
|
446
|
+
pet.name # => "Shelby" — the wire value stays honest
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The methods live on the struct, so they see its wire fields at runtime and
|
|
450
|
+
fakes/cassettes get the behavior automatically; registrations are additive
|
|
451
|
+
(repeated ones stack). For quick decoration, build the mixin inline — the block
|
|
452
|
+
is `module_eval`'d into a fresh module auto-named under
|
|
453
|
+
`GraphWeaver::TypeHelpers`:
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
GraphWeaver.extend_type("Pet") do
|
|
457
|
+
def display_name = "#{name} 🐶"
|
|
458
|
+
end
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Neither form is statically checked**, for the same reason: `srb tc` checks a
|
|
462
|
+
mixin's method bodies in the module's own scope, not the including struct's, so
|
|
463
|
+
a helper reading a wire field (`name`, `birthday`) fails with "method does not
|
|
464
|
+
exist on the module" — and the block form has no source on disk for `srb tc` to
|
|
465
|
+
read at all. Write such a helper at `# typed: false`, or reach the field through
|
|
466
|
+
`T.unsafe(self)`.
|
|
467
|
+
|
|
468
|
+
### Flat accessors with `alias:`
|
|
469
|
+
|
|
470
|
+
The one derivation the generator can type for you is a plain projection — a
|
|
471
|
+
selected field, possibly nested, exposed under a flat accessor. `alias:` emits a
|
|
472
|
+
sig'd delegator *into the struct body*, where the field is in scope, so it's
|
|
473
|
+
fully checked (the thing a mixin can't be):
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
|
|
477
|
+
|
|
478
|
+
# generated on the Widget struct:
|
|
479
|
+
# sig { returns(T.nilable(String)) }
|
|
480
|
+
# def tag = meta&.tag
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Forms:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
alias: { tag: "meta.tag" } # explicit accessor name
|
|
487
|
+
alias: "meta.tag" # accessor named after the last segment (`tag`)
|
|
488
|
+
alias: ["meta.tag", "meta.color"] # several at once
|
|
489
|
+
alias: { label: "name", tag: "meta.tag" }
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
The path is the **Ruby** accessor chain, so its segments are snake_case props
|
|
493
|
+
(`name_with_owner.tag`), not wire names. It's typed from the selection: any
|
|
494
|
+
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
495
|
+
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
496
|
+
an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
|
|
497
|
+
non-list, or a name that collides with a real field all fail with a pointed
|
|
498
|
+
error. Registrations stack, like the mixin forms.
|
|
499
|
+
|
|
500
|
+
A segment can also be `first` or `last` to pick one element out of a list hop —
|
|
501
|
+
always nilable, since the list may be empty. This is what turns an
|
|
502
|
+
`_entities`-style "array that logically holds one thing" into a clean accessor:
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
|
|
506
|
+
|
|
507
|
+
# sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
|
|
508
|
+
# def entity = _entities&.first # (a multi-fragment selection types it as the union)
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
`optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
|
|
512
|
+
the path just omits the accessor instead of failing generation. Reach for it when
|
|
513
|
+
the alias lives on a universal type like `Query` — where a strict alias would
|
|
514
|
+
force *every* query to select the path — or when it only fits some selections.
|
|
515
|
+
It excuses a field the query didn't select, not a segment the schema doesn't
|
|
516
|
+
have: a typo or a wire-cased name (`findPets` for `find_pets`) still raises,
|
|
517
|
+
since no selection could ever satisfy it.
|
|
518
|
+
|
|
519
|
+
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
520
|
+
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
521
|
+
bodies.
|
|
522
|
+
|
|
523
|
+
Every form above, and every error it raises, is a named example in
|
|
524
|
+
[`spec/aliases_spec.rb`](../spec/aliases_spec.rb).
|
|
317
525
|
|
|
318
526
|
## Clients
|
|
319
527
|
|
|
320
|
-
A client is anything with `execute(query, variables:)` whose result `to_h`s
|
|
321
|
-
into `{"data" => ..., "errors" => ...}
|
|
322
|
-
|
|
323
|
-
canonical list lives in
|
|
528
|
+
A client is anything with `execute(query, variables:, operation_name:)` whose result `to_h`s
|
|
529
|
+
into `{"data" => ..., "errors" => ...}` — a `GraphWeaver::Client`, a transport,
|
|
530
|
+
a `Retry`, a live schema class, a fake. Resolution: per call (`client:`) → per
|
|
531
|
+
module → baked constant → `GraphWeaver.client` — the canonical list lives in
|
|
532
|
+
[transports](transports.md#client-resolution).
|
|
324
533
|
|
|
325
534
|
Generate *without* a baked constant when you want modules to follow the
|
|
326
535
|
app default (`GraphWeaver.client =` in an initializer) — that's also what
|
|
327
|
-
lets [testing's
|
|
536
|
+
lets [testing's `graphql:` tag](testing.md) swap in a client per example.
|
|
537
|
+
|
|
538
|
+
`client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
|
|
539
|
+
every generated module). A baked constant is emitted as `DEFAULT_CLIENT`,
|
|
540
|
+
resolved on first use so a module can load before the initializer that builds
|
|
541
|
+
its client.
|
|
328
542
|
|
|
329
543
|
## Dynamic mode
|
|
330
544
|
|
|
331
545
|
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
|
|
332
546
|
into an anonymous container — no global constants leak). Same runtime
|
|
333
547
|
semantics; invisible to `srb tc`, so prefer the build step where static
|
|
334
|
-
checking matters. `GraphWeaver.
|
|
335
|
-
the one-shot form
|
|
548
|
+
checking matters. `GraphWeaver.run(source, query, **variables)` — or
|
|
549
|
+
`client.run` — is the one-shot form: parse and execute in one call, no module
|
|
550
|
+
kept.
|
|
336
551
|
|
|
337
552
|
Generated source is eval'd, so inputs are validated: module names must be
|
|
338
553
|
constant names, and query heredocs can't be terminated early. Still: queries
|