graph_weaver 0.7.0 → 0.7.2
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/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +380 -463
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +343 -486
- data/docs/transports.md +203 -268
- data/docs/upgrading.md +211 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +30 -1
- data/lib/graph_weaver/codegen/emit.rb +5 -11
- data/lib/graph_weaver/codegen.rb +23 -55
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/graph.rb +39 -29
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal/test_clients.rb +7 -11
- data/lib/graph_weaver/internal.rb +81 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +44 -23
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/rspec.rb +13 -24
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/tasks.rb +10 -2
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/testing.rb +12 -4
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +32 -10
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/docs/generated_modules.md
CHANGED
|
@@ -1,164 +1,24 @@
|
|
|
1
1
|
# Generated modules
|
|
2
2
|
|
|
3
|
-
What `rake graph_weaver:generate` writes, and the rules it follows: how modules
|
|
4
|
-
and nested types get their names, how variables become kwargs, and how unions and
|
|
5
|
-
interfaces come out. Read it when you want to predict the output — or when a
|
|
6
|
-
generated name isn't the one you expected.
|
|
7
|
-
|
|
8
3
|
`GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
|
|
9
4
|
Ruby module. Everything `srb tc` knows about your query results comes from that
|
|
10
|
-
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
11
|
-
around it is assembled step by step in
|
|
12
|
-
[getting started](getting_started.md), including
|
|
13
|
-
[what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without).
|
|
14
|
-
For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
|
|
15
|
-
scripts, `client.run!` skips modules entirely.
|
|
16
|
-
|
|
17
|
-
## Generating
|
|
18
|
-
|
|
19
|
-
Queries live as `.graphql` files (the source of truth), generation writes the
|
|
20
|
-
Ruby, and verification fails when the two drift. The conventional layout
|
|
21
|
-
(configurable via `GraphWeaver.queries_paths` / `generated_paths` /
|
|
22
|
-
`schema_path`):
|
|
23
|
-
|
|
24
|
-
```text
|
|
25
|
-
app/graphql/
|
|
26
|
-
schema.json # introspection dump (or schema.graphql SDL)
|
|
27
|
-
queries/ # *.graphql / *.gql, nested — hand-written, reviewed
|
|
28
|
-
fragments/ # shared fragments, spread by name from any query
|
|
29
|
-
generated/
|
|
30
|
-
types.rb # manifest: requires + forward declarations, in load order
|
|
31
|
-
types/ # one file per shared type
|
|
32
|
-
*_query.rb # one module per query — generated, checked in, never edited
|
|
33
|
-
*_mutation.rb # ...and per mutation
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
```sh
|
|
37
|
-
rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
38
|
-
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"`
|
|
42
|
-
to your Rakefile. Scalar/enum/type registrations are baked into generated
|
|
43
|
-
source, so they must run first — in Rails they do, since the tasks depend on
|
|
44
|
-
`:environment`. Or call the same APIs directly:
|
|
45
|
-
|
|
46
|
-
```ruby
|
|
47
|
-
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
48
|
-
GraphWeaver.generate!(schema:) # write the modules
|
|
49
|
-
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
50
|
-
```
|
|
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
|
-
|
|
59
|
-
`generate!` returns every file the plan produces, but rewrites only the ones
|
|
60
|
-
whose bytes changed; `GraphWeaver.changed_files` is that subset. So
|
|
61
|
-
`rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
|
|
62
|
-
date` for the rest, and a watching dev server has one module to reload instead
|
|
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.
|
|
67
|
-
|
|
68
|
-
The schema dump is step 0 — codegen reads it, never a live endpoint.
|
|
69
|
-
`cache: true` on a url client writes it on first introspection
|
|
70
|
-
(`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
|
|
71
|
-
generating without one fails pointing at exactly that.
|
|
72
|
-
|
|
73
|
-
**A type shared across query modules lives in `GraphQLTypes` and is aliased
|
|
74
|
-
in.** Input types, schema enums, and unions hoisted from shared fragments are
|
|
75
|
-
all one kind of thing — a type that would otherwise be copied into every query
|
|
76
|
-
that touches it — so they live in one module, one file each, and a query module
|
|
77
|
-
that uses any of them opens with `require_relative "types"`. Rename the constant
|
|
78
|
-
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
79
|
-
generates against two schemas. One module is one namespace, so a shared fragment
|
|
80
|
-
whose name is already a schema type in that module is refused at generation,
|
|
81
|
-
naming both.
|
|
82
|
-
|
|
83
|
-
**Generation prunes.** Rename or delete a `.graphql` and the module it used
|
|
84
|
-
to produce is deleted on the next `generate!` — which says so, since a
|
|
85
|
-
deletion you didn't expect is the one worth reading. `verify` flags it as stale
|
|
86
|
-
until you regenerate. Only files carrying GraphWeaver's header —
|
|
87
|
-
`# Generated by GraphWeaver <version> — do not edit.`, where the version is the
|
|
88
|
-
release that wrote the file — are ever deleted, so hand-written files in the
|
|
89
|
-
output directory are safe. A run that finds **no** queries says where it looked
|
|
90
|
-
rather than exiting 0 in silence, and `verify_generated!` fails outright: a
|
|
91
|
-
mistyped `queries_paths` used to leave a CI gate green forever.
|
|
5
|
+
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
92
6
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
registrations of your own in one (so a helper a file names is already there). Elsewhere it's
|
|
96
|
-
explicit, factory_bot-style:
|
|
97
|
-
|
|
98
|
-
```ruby
|
|
99
|
-
GraphWeaver.load_generated! # require every file under generated_paths
|
|
100
|
-
```
|
|
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
|
-
|
|
117
|
-
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
118
|
-
`fragments_paths` — and every entry is read (entries may be globs; the
|
|
119
|
-
generated default includes `app/graphql/*/generated`, so per-schema layouts
|
|
120
|
-
load too). Append a test-only schema or an engine's queries and every reader
|
|
121
|
-
walks them all:
|
|
7
|
+
Read this when you want to predict the output, or when a generated name isn't
|
|
8
|
+
the one you expected:
|
|
122
9
|
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
10
|
+
- **[Anatomy](#anatomy)** — what a module holds, and what a `Result` can do
|
|
11
|
+
- **[Naming](#naming)** — how the module and every nested struct get their names
|
|
12
|
+
- **[Variables](#variables-become-typed-kwargs)** — kwargs, input objects, coercion
|
|
13
|
+
- **[Enums](#enums-one-graphql-enum-one-ruby-type)** and **[selections](#selections)** — fragments, aliases, unions and interfaces
|
|
14
|
+
- **[Type helpers](#type-helpers)** — your own methods on a generated struct
|
|
15
|
+
- **[Clients](#clients)**, **[`from_response`](#deserializing-a-response-from-another-client)**, and the **[build](#generating)** itself
|
|
127
16
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
otherwise — so where you started the process doesn't change which files it
|
|
134
|
-
reads, and every path it reports back is relative to that same root.
|
|
135
|
-
|
|
136
|
-
(Plain requires, not Zeitwerk: Zeitwerk would expect
|
|
137
|
-
`Generated::PersonQuery` from `generated/person_query.rb`. In development a
|
|
138
|
-
query edit regenerates and reloads before the next request; everywhere else
|
|
139
|
-
generated code changes only on regeneration — restart, like a schema
|
|
140
|
-
migration. `GraphWeaver.reload_generated!` does the reload by hand, after
|
|
141
|
-
regenerating in another terminal.)
|
|
142
|
-
|
|
143
|
-
Regenerate when: a query changes, the schema changes, a registration changes,
|
|
144
|
-
or GraphWeaver itself upgrades — **any release can change what codegen emits**,
|
|
145
|
-
patch releases included, and `verify_generated!` is what catches it. The rake
|
|
146
|
-
tasks that spot a *schema* change for you — `schema:diff`, `schema:refresh`,
|
|
147
|
-
`queries:check` — are in
|
|
148
|
-
[getting started](getting_started.md#5-verify-in-ci); a
|
|
149
|
-
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
150
|
-
|
|
151
|
-
In development, skip the build entirely — `client.load_queries!` parses
|
|
152
|
-
every query file into modules with the same names generation would use
|
|
153
|
-
(see [dynamic mode](#dynamic-mode)).
|
|
154
|
-
|
|
155
|
-
**Generation is deterministic.** The same schema and queries produce
|
|
156
|
-
byte-identical files, on any machine, in any order — everything with a
|
|
157
|
-
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
158
|
-
sorted, and a spec asserts it both across calls and against the checked-in
|
|
159
|
-
fixtures. So regenerating a file you didn't change produces no diff,
|
|
160
|
-
`verify_generated!` never fails spuriously, and a generated file is worth
|
|
161
|
-
reviewing line by line.
|
|
17
|
+
The setup around it is assembled step by step in
|
|
18
|
+
[getting started](getting_started.md), including
|
|
19
|
+
[what Sorbet does and doesn't require](getting_started.md#sorbet-with-or-without).
|
|
20
|
+
For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off scripts,
|
|
21
|
+
`client.run!` skips modules entirely.
|
|
162
22
|
|
|
163
23
|
## Anatomy
|
|
164
24
|
|
|
@@ -178,7 +38,7 @@ module PersonQuery
|
|
|
178
38
|
const :person, T.nilable(Person)
|
|
179
39
|
end
|
|
180
40
|
|
|
181
|
-
extend GraphWeaver::QueryModule # client
|
|
41
|
+
extend GraphWeaver::QueryModule # client (see below)
|
|
182
42
|
def self.execute(id:, client: nil) # -> GraphWeaver::Response[Result]
|
|
183
43
|
def self.execute!(id:, client: nil) # -> Result, or raises QueryError
|
|
184
44
|
|
|
@@ -187,113 +47,104 @@ module PersonQuery
|
|
|
187
47
|
end
|
|
188
48
|
```
|
|
189
49
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
end
|
|
50
|
+
`execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
|
|
51
|
+
`#data`, `#data!`, `#errors`, `#extensions` — so partial data and cost/throttle
|
|
52
|
+
metadata survive. `execute!` is the shortcut: the typed **result**, or a raised
|
|
53
|
+
`GraphWeaver::QueryError`. See [errors](errors.md).
|
|
54
|
+
[`from_response`](#deserializing-a-response-from-another-client) is the
|
|
55
|
+
network-free half of the pair.
|
|
56
|
+
|
|
57
|
+
`OPERATION_NAME` rides along on every request as the spec's `operationName`, so
|
|
58
|
+
Apollo Studio, Hasura and your APM key traces, rate limits and slow-query reports
|
|
59
|
+
on the operation instead of lumping every request together. **You don't have to
|
|
60
|
+
name your operations**: an anonymous document is named after the module in the
|
|
61
|
+
emitted `QUERY` *and* in `OPERATION_NAME` — both, since a server rejects an
|
|
62
|
+
`operationName` its document doesn't declare.
|
|
63
|
+
|
|
64
|
+
A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and `hash`,
|
|
65
|
+
so a result works as a hash key), `deconstruct_keys` for pattern matching,
|
|
66
|
+
`#to_h`, and `#to_json`/`#as_json`. All of them go the whole way down a nested
|
|
67
|
+
result. It is immutable as far as its props go, like `Struct` or `Data` — and no
|
|
68
|
+
further: the `String` or `Hash` a leaf holds is the one the response carried, so
|
|
69
|
+
`result.name << "!"` changes the result, and its `hash` with it.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
case PersonQuery.execute!(id: "1")
|
|
73
|
+
in { person: { name:, pets: [{ name: first_pet }, *] } } then "#{name} and #{first_pet}"
|
|
74
|
+
in { person: { name: } } then "#{name}, petless"
|
|
75
|
+
in { person: nil } then "nobody"
|
|
76
|
+
end
|
|
77
|
+
```
|
|
219
78
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
248
|
-
- `OPERATION_NAME` rides along on every request as the spec's
|
|
249
|
-
`operationName`, so Apollo Studio, Hasura and your APM key traces, rate
|
|
250
|
-
limits and slow-query reports on the operation instead of lumping every
|
|
251
|
-
request together. **You don't have to name your operations**: an anonymous
|
|
252
|
-
document is named after the module in the emitted `QUERY` *and* in
|
|
253
|
-
`OPERATION_NAME` — both, since a server rejects an `operationName` its
|
|
254
|
-
document doesn't declare. A document that names its own operation is left
|
|
255
|
-
exactly as written.
|
|
79
|
+
**`#to_h` is the Ruby shape; `#to_json` is the wire shape.** `to_h` gives
|
|
80
|
+
snake_case prop names as Symbols, nils kept, enums as their `T::Enum` members,
|
|
81
|
+
and a registered scalar as whatever object its codec built — a view, for Ruby to
|
|
82
|
+
read. `#to_json` — and `#as_json`, which `render json:` goes through — writes
|
|
83
|
+
the response keys instead, every leaf back through its scalar registration's
|
|
84
|
+
`serialize:`, so a result's JSON is the inverse of `from_h`
|
|
85
|
+
(`Result.from_h(JSON.parse(result.to_json)) == result`), which is what a cache
|
|
86
|
+
entry, a log line or a JSON API response wants. The split is deliberate: a
|
|
87
|
+
Symbol-keyed Ruby hash can't be mistaken for a server's response, and a JSON
|
|
88
|
+
string can, so the JSON is the one that has to be true. (An **input** struct's
|
|
89
|
+
`to_h` is already the wire hash it sends, so there its JSON and its `to_h`
|
|
90
|
+
agree.) The trip is exactly as faithful as each scalar's own `cast:`/`serialize:`
|
|
91
|
+
pair: a `Time` goes back out with
|
|
92
|
+
[the microseconds its registration writes](scalars.md#going-out--what-a-variable-kwarg-accepts),
|
|
93
|
+
and a `register_scalar` with a `cast:` and no `serialize:` has no wire spelling
|
|
94
|
+
at all, so its value reaches the encoder as it is.
|
|
95
|
+
|
|
96
|
+
**Cache a result with `Marshal` or JSON, not YAML.** A `T::Enum` member is a
|
|
97
|
+
singleton that sorbet compares by identity, and Psych allocates an object before
|
|
98
|
+
filling it in, so YAML has no way to hand back the canonical one: after a round
|
|
99
|
+
trip `pet.species == Species::Dog` is false and the result no longer equals
|
|
100
|
+
itself.
|
|
256
101
|
|
|
257
102
|
## Naming
|
|
258
103
|
|
|
259
104
|
**A module is named after its file**, suffixed with the operation the file
|
|
260
105
|
defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
|
|
261
106
|
`save_list_entry.graphql` → `SaveListEntryMutation` in
|
|
262
|
-
`save_list_entry_mutation.rb`. The operation name written *inside* the file
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
107
|
+
`save_list_entry_mutation.rb`. The operation name written *inside* the file never
|
|
108
|
+
names the module (it goes on the wire as `operationName`); leave it off and the
|
|
109
|
+
module's name is written into the document instead. The same rule runs at all
|
|
110
|
+
three doors: `generate!`, `GraphWeaver.parse(path)`, and `client.load_queries!`.
|
|
111
|
+
|
|
112
|
+
**Every run of non-alphanumerics in the file name is a word boundary**, after a
|
|
113
|
+
trailing `.query`/`.mutation`/`.subscription` extension naming the document's own
|
|
114
|
+
operation is dropped — so `get-hello.graphql` is `GetHelloQuery` in
|
|
115
|
+
`get_hello_query.rb`, and `hello.query.graphql` is `HelloQuery`, not
|
|
116
|
+
`HelloQueryQuery`. Only that extension is dropped: `user.profile.graphql` is
|
|
117
|
+
`UserProfileQuery`, keeping the `profile`. A file whose extension names a kind it
|
|
118
|
+
doesn't hold (`hello.query.graphql` defining a mutation) is refused, naming both
|
|
119
|
+
halves. What is left still has to spell a constant — `01_home.graphql` is
|
|
120
|
+
refused, since `01HomeQuery` isn't one.
|
|
267
121
|
|
|
268
122
|
Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
|
|
269
123
|
found, but the module name still comes from the file name alone, so it is
|
|
270
|
-
`PetsQuery
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
124
|
+
`PetsQuery`. Two files that name the same module are refused at generation,
|
|
125
|
+
naming both, rather than one silently overwriting the other; so is a file holding
|
|
126
|
+
two operations, since one file can't name two modules. Change a file's `query` to
|
|
127
|
+
`mutation` and its constant changes with it; the next `generate!` prunes the old
|
|
128
|
+
file, and `verify` fails until you regenerate.
|
|
275
129
|
|
|
276
130
|
**A graph's `namespace:` nests what it generates**, and is the answer when two
|
|
277
131
|
schemas in one app each have a `person.graphql`: `namespace: "Billing"` makes
|
|
278
132
|
that one `Billing::PersonQuery` in the same `person_query.rb`, and its shared
|
|
279
|
-
types module `Billing::GraphQLTypes`. Nothing else about the rule changes
|
|
280
|
-
|
|
133
|
+
types module `Billing::GraphQLTypes`. Nothing else about the rule changes. See
|
|
134
|
+
[getting started](getting_started.md#more-than-one-schema).
|
|
281
135
|
|
|
282
136
|
Parsing a raw query *string* has no file to name it after, so it uses the
|
|
283
137
|
operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
|
|
284
138
|
`Query` for an anonymous one (its constants are container-scoped, so collisions
|
|
285
139
|
are impossible) while `Codegen.generate` insists on a deliberate name. Override
|
|
286
|
-
with `name:` on either.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
reads `GraphWeaver.parse::PersonQuery::Result::Person`, not a hex object
|
|
290
|
-
address. Assign the module to a constant and every nested struct upgrades to
|
|
291
|
-
that real path.
|
|
140
|
+
with `name:` on either. Assign a parsed module to a constant and every nested
|
|
141
|
+
struct upgrades to that real path, so a cast failure names it rather than a hex
|
|
142
|
+
object address.
|
|
292
143
|
|
|
293
144
|
**Every nested type is named for the response key that selects it**, camelized
|
|
294
145
|
(`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
|
|
295
|
-
`Entities`). Structs nest the way the selection does, so the constant path
|
|
296
|
-
|
|
146
|
+
`Entities`). Structs nest the way the selection does, so the constant path reads
|
|
147
|
+
like the query:
|
|
297
148
|
|
|
298
149
|
```graphql
|
|
299
150
|
query { repository { stargazers { edges { node { login } } } } }
|
|
@@ -314,21 +165,19 @@ The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
|
314
165
|
generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
|
|
315
166
|
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
316
167
|
|
|
317
|
-
|
|
168
|
+
**Union and interface members** are the one name that doesn't come from a key:
|
|
169
|
+
they take the type condition that produces them (`... on Book` → `Book`) inside
|
|
170
|
+
the container named for the field, plus the catch-all `Other`; a union hoisted
|
|
171
|
+
out of a shared fragment is named for the fragment; and several fields sharing
|
|
172
|
+
one collapsed union type take the first of their keys alphabetically. Still
|
|
173
|
+
position-determined, all of it.
|
|
318
174
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
326
|
-
(`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
|
|
327
|
-
|
|
328
|
-
A generated name that would shadow a constant the file *uses* is refused
|
|
329
|
-
instead — a key `date` beside a `Date` scalar prop turns `Date.iso8601` into a
|
|
330
|
-
`NoMethodError` in a file that typechecks. The message names both; alias either
|
|
331
|
-
one in the query.
|
|
175
|
+
Two collisions are handled rather than left to surprise you. A name that would
|
|
176
|
+
shadow the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
177
|
+
(`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child. And a
|
|
178
|
+
name that would shadow a constant the file *uses* is refused — a key `date`
|
|
179
|
+
beside a `Date` scalar prop turns `Date.iso8601` into a `NoMethodError` in a file
|
|
180
|
+
that typechecks. The message names both; alias either one in the query.
|
|
332
181
|
|
|
333
182
|
## Variables become typed kwargs
|
|
334
183
|
|
|
@@ -342,15 +191,19 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
|
|
|
342
191
|
|
|
343
192
|
- required vs optional falls out of nullability and defaults: nullable or
|
|
344
193
|
defaulted variables become optional kwargs
|
|
345
|
-
- **absent and `null` are different things, and the kwarg says which.**
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
- enum variables accept the enum or its wire value (`species: Species::Dog`
|
|
352
|
-
|
|
194
|
+
- **absent and `null` are different things, and the kwarg says which.** Leaving a
|
|
195
|
+
keyword out omits the variable, so the server's default applies; passing `nil`
|
|
196
|
+
sends `null`, which is how a mutation clears a field. `bio: params[:bio]`
|
|
197
|
+
therefore sends `null` when the param is missing — pass the keyword only when
|
|
198
|
+
you mean to. A non-null variable can't carry `null`, so `nil` there still means
|
|
199
|
+
omit.
|
|
200
|
+
- enum variables accept the enum or its wire value (`species: Species::Dog` or
|
|
201
|
+
`species: "DOG"`)
|
|
353
202
|
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
203
|
+
- one kwarg per declared variable, always — so adding a variable to a query adds
|
|
204
|
+
a kwarg and leaves every existing call site alone. Two names are refused at
|
|
205
|
+
generation, `$client` and `$variables`: the generated `execute` body already
|
|
206
|
+
owns them. Rename the variable in the query.
|
|
354
207
|
|
|
355
208
|
**The kwarg is typed exactly as the schema types it, and the value is coerced
|
|
356
209
|
anyway.** Those aren't in tension, because they answer different questions:
|
|
@@ -369,34 +222,18 @@ A value that won't convert raises `GraphWeaver::InputError` naming the variable,
|
|
|
369
222
|
the operation and the value.
|
|
370
223
|
|
|
371
224
|
That is why the emitted sig carries `.checked(:never)`: sorbet-runtime would
|
|
372
|
-
otherwise reject the String before the body could read it. Coercion
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
385
|
-
One kwarg per declared variable, always — so adding a variable to a query
|
|
386
|
-
adds a kwarg and leaves every existing call site alone. Two names are refused at
|
|
387
|
-
generation, `$client` and `$variables`: the generated `execute` body already
|
|
388
|
-
owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
|
|
389
|
-
Rename the variable in the query.
|
|
225
|
+
otherwise reject the String before the body could read it. Coercion stands in its
|
|
226
|
+
place for the arguments — stricter, and with a better message — and the `Result`
|
|
227
|
+
it returns is a `T::Struct`, so its props are still checked one by one.
|
|
228
|
+
(`T::Configuration.default_checked_level = :never` buys nothing back: that knob
|
|
229
|
+
governs `sig` dispatch, which the emitted sigs already opt out of, and `from_h`
|
|
230
|
+
allocates and costs the same either way.)
|
|
390
231
|
|
|
391
232
|
**Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
|
|
392
233
|
normalizes underscored Symbol/String keys, enums accept wire values, nested
|
|
393
234
|
inputs accept hashes, and an unknown key raises with a spellchecked hint rather
|
|
394
235
|
than silently dropping:
|
|
395
236
|
|
|
396
|
-
```graphql
|
|
397
|
-
mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
|
|
398
|
-
```
|
|
399
|
-
|
|
400
237
|
```ruby
|
|
401
238
|
AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
|
|
402
239
|
|
|
@@ -410,25 +247,17 @@ the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
|
|
|
410
247
|
the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
|
|
411
248
|
struct built with `.new` can't tell the two apart — every unset prop is nil
|
|
412
249
|
either way — so `nil` there means omit; reach for `coerce` to send an explicit
|
|
413
|
-
null.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
In the `generate!` workflow input types are emitted **once per schema**, one
|
|
425
|
-
file per type under `generated/types/` with `types.rb` as the manifest. Query
|
|
426
|
-
modules alias what they touch, so `AdoptMutation::AdoptionInput` still works and
|
|
427
|
-
a shared type keeps one identity across modules. A query module aliases only its
|
|
428
|
-
*variable root* types, so a deeply nested one is reached as
|
|
429
|
-
`GraphQLTypes::<Type>`. Per-type files keep drift reviewable: a schema migration
|
|
430
|
-
diffs exactly the types it touched, and types the schema drops are pruned on
|
|
431
|
-
regeneration (`verify` flags strays). Dynamic `parse` stays self-contained.
|
|
250
|
+
null. Nested inputs work, including recursive ones: a self-referential filter
|
|
251
|
+
generates cleanly, with `_and:`/`_not:` typed as the struct itself.
|
|
252
|
+
|
|
253
|
+
In the `generate!` workflow input types are emitted **once per schema**, one file
|
|
254
|
+
per type under `generated/types/` with `types.rb` as the manifest. Query modules
|
|
255
|
+
alias what they touch, so `AdoptMutation::AdoptionInput` still works and a shared
|
|
256
|
+
type keeps one identity across modules; a query module aliases only its *variable
|
|
257
|
+
root* types, so a deeply nested one is reached as `GraphQLTypes::<Type>`. Per-type
|
|
258
|
+
files keep drift reviewable: a schema migration diffs exactly the types it
|
|
259
|
+
touched, and types the schema drops are pruned on regeneration (`verify` flags
|
|
260
|
+
strays). Dynamic `parse` stays self-contained.
|
|
432
261
|
|
|
433
262
|
### An input object generates its whole closure
|
|
434
263
|
|
|
@@ -451,18 +280,15 @@ query($name: String!, $minHeight: Int!) {
|
|
|
451
280
|
}
|
|
452
281
|
```
|
|
453
282
|
|
|
454
|
-
`srb tc` gets *more* out of that, not less. `name: String`, `min_height:
|
|
455
|
-
|
|
283
|
+
`srb tc` gets *more* out of that, not less. `name: String`, `min_height: Integer`
|
|
284
|
+
are types it checks at every call site, where the variable form is
|
|
456
285
|
`T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped])` — and a hash built from
|
|
457
286
|
`params`, which is how a filter is really assembled, takes the untyped branch.
|
|
458
287
|
Refusals land on the leaf too, so `path` is the form field rather than the
|
|
459
|
-
comparison operator under it.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
288
|
+
comparison operator under it. Two shapes can't be inlined, and codegen says which
|
|
289
|
+
when a prop collision forces the question: a key chosen at runtime (the sort
|
|
290
|
+
column in `order_by: { <column>: asc }`), since GraphQL has no dynamic object
|
|
291
|
+
keys, and a list whose length only the runtime knows.
|
|
466
292
|
|
|
467
293
|
## Enums: one GraphQL enum, one Ruby type
|
|
468
294
|
|
|
@@ -477,16 +303,15 @@ AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
|
|
|
477
303
|
|
|
478
304
|
So a value read out of one query hands straight back into another's variable,
|
|
479
305
|
`case`/`T.absurd` is exhaustive across your app, and the class a field gets
|
|
480
|
-
doesn't depend on what else the query happened to reference.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
of yours instead.
|
|
306
|
+
doesn't depend on what else the query happened to reference. An enum value that
|
|
307
|
+
camelizes to nothing — `_` and `__` are both legal GraphQL — is refused at
|
|
308
|
+
generation: there is no constant to name it. Map the enum onto one of yours
|
|
309
|
+
instead.
|
|
485
310
|
|
|
486
311
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
487
|
-
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
|
|
488
|
-
|
|
489
|
-
|
|
312
|
+
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
|
|
313
|
+
enums into the query module itself; there's no cross-query set to share against,
|
|
314
|
+
but one enum is still one class within that module.
|
|
490
315
|
|
|
491
316
|
**The one misuse nothing catches** is comparing against the wire spelling:
|
|
492
317
|
|
|
@@ -497,49 +322,39 @@ pet.species == GraphQLTypes::Species::Cat
|
|
|
497
322
|
|
|
498
323
|
A generated enum is a plain `T::Enum`, so `==` against a String is `false` —
|
|
499
324
|
`srb tc` allows it (`==` takes `BasicObject`) and nothing raises. sorbet-runtime
|
|
500
|
-
owns this question and ships the switch
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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.
|
|
325
|
+
owns this question and ships the switch: turn on
|
|
326
|
+
`T::Configuration.enable_legacy_t_enum_migration_mode` in dev and test, and route
|
|
327
|
+
`soft_assert_handler` wherever your other soft assertions go. It covers your own
|
|
328
|
+
`T::Enum`s too, which is why it belongs there rather than in the generated
|
|
329
|
+
classes. Careful reading it: in that mode the comparison answers **true** (it
|
|
330
|
+
serializes first), so the handler, not the return value, is the signal.
|
|
512
331
|
|
|
513
332
|
## Selections
|
|
514
333
|
|
|
515
|
-
- **Fragments** — inline fragments and named spreads flatten into the
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
always nilable.
|
|
334
|
+
- **Fragments** — inline fragments and named spreads flatten into the selection;
|
|
335
|
+
type conditions match exact names or interfaces/unions the type belongs to.
|
|
336
|
+
- **`@skip` / `@include`** — a directive-conditional field may be absent from the
|
|
337
|
+
response regardless of schema nullability, so its generated type is always
|
|
338
|
+
nilable.
|
|
521
339
|
- **Aliases** — result keys follow aliases; props are the underscored alias.
|
|
522
340
|
|
|
523
|
-
Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
341
|
+
Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching for
|
|
342
|
+
the wire name is a classic stumble, so it fails helpfully at both layers: `srb
|
|
343
|
+
tc` flags it statically, and at runtime (consoles, dynamic mode) the struct
|
|
344
|
+
raises a NoMethodError naming the prop that does exist — `use 'name_with_owner'`
|
|
345
|
+
for the exact wire name, `did you mean ...?` for a near-miss typo in either
|
|
346
|
+
casing.
|
|
529
347
|
|
|
530
348
|
A name that would shadow a method every struct answers — `class`, `hash`,
|
|
531
349
|
`display`, `to_json`, and `supplied` on an input — takes a trailing underscore
|
|
532
|
-
instead: `class` → `class_`, in results and input types alike
|
|
533
|
-
source says so on the line above the prop. Only the Ruby name moves
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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.
|
|
350
|
+
instead: `class` → `class_`, in results and input types alike, and the generated
|
|
351
|
+
source says so on the line above the prop. Only the Ruby name moves. It is the
|
|
352
|
+
one Ruby name for the field, so `.new`, `.coerce`, a result's `#to_h` and pattern
|
|
353
|
+
matching, and an `InputError`'s `#path` all use `class_` (an input error's
|
|
354
|
+
`#coordinate` still names the schema's `Tricky.class`) — while the wire keeps the
|
|
355
|
+
schema's spelling in both directions, so the query, the request, the response,
|
|
356
|
+
and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
|
|
357
|
+
trailing underscore.
|
|
543
358
|
|
|
544
359
|
### Abstract types
|
|
545
360
|
|
|
@@ -550,35 +365,35 @@ plus a catch-all `Other`, wrapped in a module with
|
|
|
550
365
|
unaliased and unconditional — the wire response carries no type tag unless you
|
|
551
366
|
ask, and `from_h` reads it on every response. One `__typename` inside each
|
|
552
367
|
`... 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
|
-
|
|
368
|
+
dispatch runs before any member's selection applies, and a member the query never
|
|
369
|
+
named would carry none at all.
|
|
555
370
|
|
|
556
371
|
Size follows the query, not the schema: two `... on` conditions against GitHub's
|
|
557
372
|
`Node` — an interface with a few hundred implementations — emit three structs,
|
|
558
|
-
not a few hundred. Anything the query didn't name — a member you have no
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
373
|
+
not a few hundred. Anything the query didn't name — a member you have no fragment
|
|
374
|
+
on, or one the schema grew *after* you generated — deserializes into `Other`,
|
|
375
|
+
carrying what the abstract type itself guarantees (an interface's selected
|
|
376
|
+
interface-level fields; for a union, `__typename`). Adding a union member
|
|
377
|
+
upstream is a non-breaking change, and it stays one here.
|
|
563
378
|
|
|
564
379
|
Two selections have nothing to dispatch between, so they skip the module and
|
|
565
380
|
become the struct directly: **no conditions at all** (interface-level fields
|
|
566
381
|
only) → one shared struct; **exactly one condition and nothing else** → that
|
|
567
382
|
type's struct, always nilable, since a non-matching runtime type comes back as
|
|
568
383
|
`nil` — so narrowing doubles as filtering. "Nothing else" is what keeps the miss
|
|
569
|
-
legible: a field every member answers — spelled bare, or inside a fragment on
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
384
|
+
legible: a field every member answers — spelled bare, or inside a fragment on the
|
|
385
|
+
abstract type itself, which is the same selection — puts the field back on the
|
|
386
|
+
dispatch path, so the other members keep what they sent. Narrowing reads the match
|
|
387
|
+
off `__typename` when the selection carries one unaliased and unguarded, and off
|
|
388
|
+
"the object came back empty" when it doesn't — so an all-`@skip`/`@include`
|
|
389
|
+
narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
|
|
390
|
+
match would be indistinguishable from a miss.
|
|
576
391
|
|
|
577
392
|
When a whole union field is selected as one named *shared* fragment
|
|
578
393
|
(`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
|
|
579
394
|
for the fragment — and each query aliases it, so the same union is one Ruby type
|
|
580
|
-
family across queries, not a fresh dispatch module per query. Like shared
|
|
581
|
-
|
|
395
|
+
family across queries, not a fresh dispatch module per query. Like shared inputs,
|
|
396
|
+
it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
582
397
|
|
|
583
398
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
584
399
|
|
|
@@ -600,28 +415,25 @@ end
|
|
|
600
415
|
Two things a `case` on the `__typename` string can't give you. `when Book`
|
|
601
416
|
*narrows*: inside the branch `item` is statically a `Book`, so its fields
|
|
602
417
|
typecheck and a `Disc` field is a compile error. And after every branch the
|
|
603
|
-
`T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
deliberately not "every type in the schema", which is what keeps a `case` you
|
|
609
|
-
wrote today compiling when upstream adds a member. To make the compiler force
|
|
610
|
-
your hand on a new one, name it in the query.
|
|
418
|
+
`T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable — **write a
|
|
419
|
+
fragment for another member, regenerate, and the `T.absurd` stops compiling until
|
|
420
|
+
you handle it.** It is exhaustive over the members *this query asked about*, plus
|
|
421
|
+
`Other` — deliberately not "every type in the schema", which is what keeps a
|
|
422
|
+
`case` you wrote today compiling when upstream adds a member.
|
|
611
423
|
|
|
612
424
|
`__typename` is still there as a plain `String`, with one use the class can't
|
|
613
|
-
cover: two *differently-selected* occurrences of the same union are distinct
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
425
|
+
cover: two *differently-selected* occurrences of the same union are distinct type
|
|
426
|
+
families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
|
|
427
|
+
written for one won't span the other. Select the union through a shared fragment
|
|
428
|
+
to hold it as one type across queries ([above](#abstract-types)); if all you have
|
|
429
|
+
is the bare tag, `__typename` is the common denominator, unchecked.
|
|
618
430
|
|
|
619
431
|
## Type helpers
|
|
620
432
|
|
|
621
|
-
Derived values (display names, emoji, predicates) belong next to the data but
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
433
|
+
Derived values (display names, emoji, predicates) belong next to the data but not
|
|
434
|
+
*in* it — rewriting wire values on the way in destroys the raw truth. Register a
|
|
435
|
+
plain module and every struct generated from that GraphQL type includes it,
|
|
436
|
+
whatever query it appears in:
|
|
625
437
|
|
|
626
438
|
```ruby
|
|
627
439
|
module PetHelpers
|
|
@@ -643,9 +455,9 @@ same reason — [getting started](getting_started.md#2-run-the-generator) has th
|
|
|
643
455
|
rule and the boot order behind it. Editing the *mixin* in development needs a
|
|
644
456
|
restart, unlike a `.graphql` edit: a reload hands the constant a new module
|
|
645
457
|
object, and the `include` that took the old one doesn't run again.
|
|
646
|
-
|
|
647
|
-
is `module_eval`'d into
|
|
648
|
-
`GraphWeaver::TypeHelpers`:
|
|
458
|
+
|
|
459
|
+
For quick decoration, build the mixin inline — the block is `module_eval`'d into
|
|
460
|
+
a fresh module auto-named under `GraphWeaver::TypeHelpers`:
|
|
649
461
|
|
|
650
462
|
```ruby
|
|
651
463
|
GraphWeaver.extend_type("Pet") do
|
|
@@ -664,28 +476,10 @@ one a boot creates.
|
|
|
664
476
|
checks a mixin's method bodies in the module's own scope, not the including
|
|
665
477
|
struct's, so a helper reading a wire field (`name`, `birthday`) fails with
|
|
666
478
|
"method does not exist on the module" — and the block form has no source on disk
|
|
667
|
-
for `srb tc` to read at all
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
479
|
+
for `srb tc` to read at all. A *named* module can carry real sigs, though, by
|
|
480
|
+
declaring the fields it leans on: `abstract!` plus a
|
|
481
|
+
`sig { abstract.returns(String) }; def name; end` is how a mixin says "whatever
|
|
482
|
+
includes me has these", and the struct's `const`s satisfy them.
|
|
689
483
|
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
690
484
|
beats `# typed: false` for a helper you want checked.
|
|
691
485
|
|
|
@@ -735,51 +529,43 @@ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional:
|
|
|
735
529
|
`optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
|
|
736
530
|
the path just omits the accessor instead of failing generation. Reach for it when
|
|
737
531
|
the alias lives on a universal type like `Query` — where a strict alias would
|
|
738
|
-
force *every* query to select the path — or when it only fits some selections.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
532
|
+
force *every* query to select the path — or when it only fits some selections. It
|
|
533
|
+
excuses a field the query didn't select, not a segment the schema doesn't have: a
|
|
534
|
+
typo or a wire-cased name (`findPets` for `find_pets`) still raises, since no
|
|
535
|
+
selection could ever satisfy it.
|
|
742
536
|
|
|
743
537
|
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
744
538
|
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
745
|
-
bodies.
|
|
746
|
-
|
|
747
|
-
Every form above, and every error it raises, is a named example in
|
|
539
|
+
bodies. Every form above, and every error it raises, is a named example in
|
|
748
540
|
[`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
|
|
749
541
|
|
|
750
542
|
## Clients
|
|
751
543
|
|
|
752
544
|
A client is anything satisfying the [execute contract](transports.md) — a
|
|
753
545
|
`GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
|
|
754
|
-
|
|
755
|
-
`
|
|
756
|
-
[
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
every
|
|
769
|
-
`
|
|
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.
|
|
546
|
+
A module knows which graph it belongs to, and the graph knows how to reach it:
|
|
547
|
+
resolution is per call (`client:`) → a test mode's stand-in → the client its
|
|
548
|
+
[graph](getting_started.md#more-than-one-schema) names → `GraphWeaver.client`;
|
|
549
|
+
the canonical list is in [transports](transports.md#client-resolution). There is
|
|
550
|
+
no setter — `MyQuery.client` reads back what the module would execute through,
|
|
551
|
+
and a [parsed](#dynamic-mode) module, which has no graph, runs
|
|
552
|
+
against whatever parsed it. A graph naming its own client is no reason a module
|
|
553
|
+
escapes [testing's `graphql:` tag](testing.md), which is exactly the instruction
|
|
554
|
+
to replace it; what the *example* says still wins.
|
|
555
|
+
|
|
556
|
+
`client` lives in the gem (`GraphWeaver::QueryModule`, extended by every
|
|
557
|
+
generated module). A generated file says nothing about transport — only a private
|
|
558
|
+
`GRAPH` naming its graph, which is also how `graphql: :fake` fabricates each
|
|
559
|
+
module's own schema with two graphs in play, and the
|
|
560
|
+
`:graph` on every [instrumentation event](logging.md#the-payload) the module's
|
|
561
|
+
`execute` produces.
|
|
776
562
|
|
|
777
563
|
## Deserializing a response from another client
|
|
778
564
|
|
|
779
565
|
`execute` is two steps: make the request, then cast the JSON into the typed
|
|
780
566
|
structs. Only the second step is GraphWeaver-specific, and it's exposed on its
|
|
781
|
-
own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
|
|
782
|
-
|
|
567
|
+
own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post, a
|
|
568
|
+
batching layer, a recorded fixture) and hand the result over:
|
|
783
569
|
|
|
784
570
|
```ruby
|
|
785
571
|
raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
|
|
@@ -791,30 +577,161 @@ person = response.data!.person # typed, no network
|
|
|
791
577
|
person = PersonQuery.from_response!(raw).person # or skip the envelope
|
|
792
578
|
```
|
|
793
579
|
|
|
794
|
-
`execute` *is* `from_response(client.execute(...))`, so the envelope is
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
`"extensions"` keys included. Don't symbolize or snake_case it first.
|
|
580
|
+
`execute` *is* `from_response(client.execute(...))`, so the envelope is identical.
|
|
581
|
+
The one requirement: pass the response **verbatim** — a hash (or anything with
|
|
582
|
+
`#to_h`) with the standard GraphQL shape and **wire-cased string keys**
|
|
583
|
+
(`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
|
|
584
|
+
`"extensions"` keys included. Don't symbolize or snake_case it first. Which is
|
|
585
|
+
checked, since symbolizing is the likeliest thing to go wrong at this seam: a
|
|
586
|
+
hash carrying neither `"data"` nor `"errors"` raises a `GraphWeaver::CastError`
|
|
587
|
+
naming the keys it *did* find, rather than handing back an envelope that reports
|
|
588
|
+
success with no data. `nil` and a bare String are refused the same way.
|
|
589
|
+
|
|
590
|
+
## Generating
|
|
591
|
+
|
|
592
|
+
Queries live as `.graphql` files (the source of truth), generation writes the
|
|
593
|
+
Ruby, and verification fails when the two drift. The conventional layout
|
|
594
|
+
(configurable via `GraphWeaver.queries_paths` / `generated_paths` /
|
|
595
|
+
`schema_path`):
|
|
596
|
+
|
|
597
|
+
```text
|
|
598
|
+
app/graphql/
|
|
599
|
+
schema.json # introspection dump (or schema.graphql SDL)
|
|
600
|
+
queries/ # *.graphql / *.gql, nested — hand-written, reviewed
|
|
601
|
+
fragments/ # shared fragments, spread by name from any query
|
|
602
|
+
generated/
|
|
603
|
+
types.rb # manifest: requires + forward declarations, in load order
|
|
604
|
+
types/ # one file per shared type
|
|
605
|
+
*_query.rb # one module per query — generated, checked in, never edited
|
|
606
|
+
*_mutation.rb # ...and per mutation
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
```sh
|
|
610
|
+
rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
611
|
+
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"` to
|
|
615
|
+
your Rakefile. Scalar/enum/type registrations are baked into generated source, so
|
|
616
|
+
they must run first — in Rails they do, since the tasks depend on `:environment`.
|
|
617
|
+
Or call the same APIs directly:
|
|
799
618
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
619
|
+
```ruby
|
|
620
|
+
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
621
|
+
GraphWeaver.generate!(schema:) # write the modules
|
|
622
|
+
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**`verify_generated!` costs what `generate!` costs**, minus the writes — it
|
|
626
|
+
recomputes the whole plan whether nothing is stale or everything is. So it
|
|
627
|
+
belongs in *one* example per suite run, not in a `before` or an assertion per
|
|
628
|
+
example, where it reads like a cheap check and isn't.
|
|
629
|
+
|
|
630
|
+
`generate!` returns every file the plan produces, but rewrites only the ones whose
|
|
631
|
+
bytes changed; `GraphWeaver.changed_files` is that subset. So
|
|
632
|
+
`rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
|
|
633
|
+
date` for the rest, and a watching dev server has one module to reload instead of
|
|
634
|
+
all of them. The unregistered-scalar report is the rake task's `puts`, so off rake
|
|
635
|
+
read `GraphWeaver.untyped_scalars` for the unioned list — or set
|
|
636
|
+
[`GraphWeaver.logger`](logging.md), which `generate!` names them on at `info` as
|
|
637
|
+
it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
|
|
638
|
+
generating without one fails pointing at exactly that.
|
|
639
|
+
|
|
640
|
+
**A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
|
|
641
|
+
Input types, schema enums, and unions hoisted from shared fragments are all one
|
|
642
|
+
kind of thing — a type that would otherwise be copied into every query that
|
|
643
|
+
touches it — so they live in one module, one file each, and a query module that
|
|
644
|
+
uses any of them opens with `require_relative "types"`. Rename the constant
|
|
645
|
+
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
646
|
+
generates against two schemas. One module is one namespace, so a shared fragment
|
|
647
|
+
whose name is already a schema type in that module is refused at generation,
|
|
648
|
+
naming both.
|
|
649
|
+
|
|
650
|
+
**Generation prunes.** Rename or delete a `.graphql` and the module it used to
|
|
651
|
+
produce is deleted on the next `generate!` — which says so, since a deletion you
|
|
652
|
+
didn't expect is the one worth reading; `verify` flags it as stale until you
|
|
653
|
+
regenerate. Only files carrying GraphWeaver's header
|
|
654
|
+
(`# Generated by GraphWeaver <version> — do not edit.`) are ever deleted, so
|
|
655
|
+
hand-written files in the output directory are safe. A run that finds **no**
|
|
656
|
+
queries says where it looked rather than exiting 0 in silence, and
|
|
657
|
+
`verify_generated!` fails outright.
|
|
658
|
+
|
|
659
|
+
**A refusal writes nothing at all** — not even the files that planned cleanly —
|
|
660
|
+
so a failed run leaves the tree exactly as it was, and it reports *every* query it
|
|
661
|
+
refused rather than the first.
|
|
662
|
+
|
|
663
|
+
**Generation is deterministic.** The same schema and queries produce
|
|
664
|
+
byte-identical files, on any machine, in any order — everything with a
|
|
665
|
+
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
666
|
+
sorted, and a spec asserts it both across calls and against the checked-in
|
|
667
|
+
fixtures. So regenerating a file you didn't change produces no diff,
|
|
668
|
+
`verify_generated!` never fails spuriously, and a generated file is worth
|
|
669
|
+
reviewing line by line.
|
|
670
|
+
|
|
671
|
+
Regenerate when: a query changes, the schema changes, a registration changes, or
|
|
672
|
+
GraphWeaver itself upgrades — **any release can change what codegen emits**, patch
|
|
673
|
+
releases included, and `verify_generated!` is what catches it. The rake tasks that
|
|
674
|
+
spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:check`
|
|
675
|
+
— are in [getting started](getting_started.md#5-verify-in-ci); a
|
|
676
|
+
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
677
|
+
|
|
678
|
+
### Loading what it wrote
|
|
679
|
+
|
|
680
|
+
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
681
|
+
boot from a `to_prepare` block, after your initializers and after any
|
|
682
|
+
registrations of your own in one. Elsewhere it's explicit, factory_bot-style:
|
|
683
|
+
`GraphWeaver.load_generated!` requires every file under `generated_paths`.
|
|
684
|
+
|
|
685
|
+
**Outside Rails, four things have to agree**, and nothing wires them together for
|
|
686
|
+
you — a script that generates its own modules sets all four:
|
|
687
|
+
|
|
688
|
+
1. `queries_paths` — where `generate!` reads `.graphql` files.
|
|
689
|
+
2. `generated_paths` — where it writes, and where `load_generated!` reads. Point
|
|
690
|
+
them at the same directory or generation is invisible.
|
|
691
|
+
3. the call above, before the first `execute` — nothing else requires the files.
|
|
692
|
+
4. `GraphWeaver.client =` — a module belonging to no declared graph has no
|
|
693
|
+
other [client](#clients) to reach for.
|
|
694
|
+
|
|
695
|
+
Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
|
|
696
|
+
gets `PersonQuery: client must respond to #execute(query, variables:), got
|
|
697
|
+
NilClass` from a module that otherwise looks fine.
|
|
698
|
+
|
|
699
|
+
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
700
|
+
`fragments_paths` — and every entry is read (entries may be globs; the generated
|
|
701
|
+
default includes `app/graphql/*/generated`, so per-schema layouts load too).
|
|
702
|
+
Assigning a String wraps it, so pointing at one directory stays a one-liner.
|
|
703
|
+
`generate!` writes into the first `generated_paths` entry — one run, one output
|
|
704
|
+
directory. `schema_path` is the one singular setting: a run reads one schema, so a
|
|
705
|
+
list would name a dump nothing ever opens. A relative path resolves against
|
|
706
|
+
`GraphWeaver.root` — `Rails.root` in a Rails app, the working directory otherwise
|
|
707
|
+
— so where you started the process doesn't change which files it reads.
|
|
708
|
+
|
|
709
|
+
Plain requires, not Zeitwerk: Zeitwerk would expect `Generated::PersonQuery` from
|
|
710
|
+
`generated/person_query.rb`. In development a query edit regenerates and reloads
|
|
711
|
+
before the next request; everywhere else generated code changes only on
|
|
712
|
+
regeneration — restart, like a schema migration, or call
|
|
713
|
+
`GraphWeaver.reload_generated!` after regenerating in another terminal.
|
|
805
714
|
|
|
806
715
|
## Dynamic mode
|
|
807
716
|
|
|
808
|
-
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
717
|
+
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled into
|
|
718
|
+
an anonymous container — no global constants leak). Same runtime semantics;
|
|
719
|
+
invisible to `srb tc`, so prefer the build step where static checking matters.
|
|
720
|
+
`GraphWeaver.run(source, query, **variables)` — or `client.run` — is the one-shot
|
|
721
|
+
form: parse and execute in one call, no module kept. In development
|
|
722
|
+
`client.load_queries!` parses every query file into modules with the same names
|
|
723
|
+
generation would use.
|
|
724
|
+
|
|
725
|
+
A parsed module **runs against whatever parsed it** — `client.parse(query)` and
|
|
726
|
+
`load_queries!` bind the client they came from, and `GraphWeaver.parse(client:)`
|
|
727
|
+
says it outright. That is a property of parsing, not a slot you can set later:
|
|
728
|
+
it generates no file, so it has no graph to read a client off, and a per-call
|
|
729
|
+
`client:` still wins over it.
|
|
814
730
|
|
|
815
731
|
In an app with [more than one graph](getting_started.md#more-than-one-schema), a
|
|
816
732
|
parsed module belongs to one of them — that is what a `graphql:` tag runs it
|
|
817
|
-
against, the same thing generation
|
|
733
|
+
against and whose `client` it reaches for, the same thing generation writes into
|
|
734
|
+
a file. It is read off the schema
|
|
818
735
|
you parsed against when a graph runs that class in-process; say it outright
|
|
819
736
|
otherwise:
|
|
820
737
|
|
|
@@ -823,5 +740,5 @@ PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)
|
|
|
823
740
|
```
|
|
824
741
|
|
|
825
742
|
Generated source is eval'd, so inputs are validated: module names must be
|
|
826
|
-
constant names, and query heredocs can't be terminated early. Still: queries
|
|
827
|
-
|
|
743
|
+
constant names, and query heredocs can't be terminated early. Still: queries are
|
|
744
|
+
code — don't feed untrusted strings to parse.
|