graph_weaver 0.6.1 → 0.7.1
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 +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- 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 +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- data/CHANGELOG.md +0 -2355
data/docs/generated_modules.md
CHANGED
|
@@ -1,139 +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
|
-
```
|
|
5
|
+
file — there is no runtime schema, no lazy wrapper, no reflection.
|
|
51
6
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
|
|
55
|
-
date` for the rest, and a watching dev server has one module to reload instead
|
|
56
|
-
of all of them.
|
|
7
|
+
Read this when you want to predict the output, or when a generated name isn't
|
|
8
|
+
the one you expected:
|
|
57
9
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
(
|
|
61
|
-
|
|
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
|
|
62
16
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
69
|
-
generates against two schemas. One module is one namespace, so a shared fragment
|
|
70
|
-
whose name is already a schema type in that module is refused at generation,
|
|
71
|
-
naming both.
|
|
72
|
-
|
|
73
|
-
**Generation prunes.** Rename or delete a `.graphql` and the module it used
|
|
74
|
-
to produce is deleted on the next `generate!` — which says so, since a
|
|
75
|
-
deletion you didn't expect is the one worth reading. `verify` flags it as stale
|
|
76
|
-
until you regenerate. Only files carrying GraphWeaver's header —
|
|
77
|
-
`# Generated by GraphWeaver <version> — do not edit.`, where the version is the
|
|
78
|
-
release that wrote the file — are ever deleted, so hand-written files in the
|
|
79
|
-
output directory are safe. A run that finds **no** queries says where it looked
|
|
80
|
-
rather than exiting 0 in silence, and `verify_generated!` fails outright: a
|
|
81
|
-
mistyped `queries_paths` used to leave a CI gate green forever.
|
|
82
|
-
|
|
83
|
-
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
84
|
-
boot from a `to_prepare` block, after your initializers and after any
|
|
85
|
-
registrations of your own in one (so a helper a file names is already there). Elsewhere it's
|
|
86
|
-
explicit, factory_bot-style:
|
|
87
|
-
|
|
88
|
-
```ruby
|
|
89
|
-
GraphWeaver.load_generated! # require every file under generated_paths
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
93
|
-
`fragments_paths` — and every entry is read (entries may be globs; the
|
|
94
|
-
generated default includes `app/graphql/*/generated`, so per-schema layouts
|
|
95
|
-
load too). Append a test-only schema or an engine's queries and every reader
|
|
96
|
-
walks them all:
|
|
97
|
-
|
|
98
|
-
```ruby
|
|
99
|
-
# e.g. in spec/support/graph_weaver.rb
|
|
100
|
-
GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Assigning a String wraps it, so pointing at one directory stays a one-liner.
|
|
104
|
-
`generate!` writes into the first `generated_paths` entry — one run, one output
|
|
105
|
-
directory. `schema_path` is the one singular setting: a run reads one schema,
|
|
106
|
-
so a list would name a dump nothing ever opens. A relative path resolves
|
|
107
|
-
against `GraphWeaver.root` — `Rails.root` in a Rails app, the working directory
|
|
108
|
-
otherwise — so where you started the process doesn't change which files it
|
|
109
|
-
reads, and every path it reports back is relative to that same root.
|
|
110
|
-
|
|
111
|
-
(Plain requires, not Zeitwerk: Zeitwerk would expect
|
|
112
|
-
`Generated::PersonQuery` from `generated/person_query.rb`. In development a
|
|
113
|
-
query edit regenerates and reloads before the next request; everywhere else
|
|
114
|
-
generated code changes only on regeneration — restart, like a schema
|
|
115
|
-
migration. `GraphWeaver.reload_generated!` does the reload by hand, after
|
|
116
|
-
regenerating in another terminal.)
|
|
117
|
-
|
|
118
|
-
Regenerate when: a query changes, the schema changes, a registration changes,
|
|
119
|
-
or GraphWeaver itself upgrades — **any release can change what codegen emits**,
|
|
120
|
-
patch releases included, and `verify_generated!` is what catches it. The rake
|
|
121
|
-
tasks that spot a *schema* change for you — `schema:diff`, `schema:refresh`,
|
|
122
|
-
`queries:check` — are in
|
|
123
|
-
[getting started](getting_started.md#5-verify-in-ci); a
|
|
124
|
-
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
125
|
-
|
|
126
|
-
In development, skip the build entirely — `client.load_queries!` parses
|
|
127
|
-
every query file into modules with the same names generation would use
|
|
128
|
-
(see [dynamic mode](#dynamic-mode)).
|
|
129
|
-
|
|
130
|
-
**Generation is deterministic.** The same schema and queries produce
|
|
131
|
-
byte-identical files, on any machine, in any order — everything with a
|
|
132
|
-
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
133
|
-
sorted, and a spec asserts it both across calls and against the checked-in
|
|
134
|
-
fixtures. So regenerating a file you didn't change produces no diff,
|
|
135
|
-
`verify_generated!` never fails spuriously, and a generated file is worth
|
|
136
|
-
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.
|
|
137
22
|
|
|
138
23
|
## Anatomy
|
|
139
24
|
|
|
@@ -162,55 +47,104 @@ module PersonQuery
|
|
|
162
47
|
end
|
|
163
48
|
```
|
|
164
49
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
```
|
|
78
|
+
|
|
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.
|
|
179
101
|
|
|
180
102
|
## Naming
|
|
181
103
|
|
|
182
104
|
**A module is named after its file**, suffixed with the operation the file
|
|
183
105
|
defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
|
|
184
106
|
`save_list_entry.graphql` → `SaveListEntryMutation` in
|
|
185
|
-
`save_list_entry_mutation.rb`. The operation name written *inside* the file
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
190
121
|
|
|
191
122
|
Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
|
|
192
123
|
found, but the module name still comes from the file name alone, so it is
|
|
193
|
-
`PetsQuery
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
129
|
+
|
|
130
|
+
**A graph's `namespace:` nests what it generates**, and is the answer when two
|
|
131
|
+
schemas in one app each have a `person.graphql`: `namespace: "Billing"` makes
|
|
132
|
+
that one `Billing::PersonQuery` in the same `person_query.rb`, and its shared
|
|
133
|
+
types module `Billing::GraphQLTypes`. Nothing else about the rule changes. See
|
|
134
|
+
[getting started](getting_started.md#more-than-one-schema).
|
|
198
135
|
|
|
199
136
|
Parsing a raw query *string* has no file to name it after, so it uses the
|
|
200
137
|
operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
|
|
201
138
|
`Query` for an anonymous one (its constants are container-scoped, so collisions
|
|
202
139
|
are impossible) while `Codegen.generate` insists on a deliberate name. Override
|
|
203
|
-
with `name:` on either.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
reads `GraphWeaver.parse::PersonQuery::Result::Person`, not a hex object
|
|
207
|
-
address. Assign the module to a constant and every nested struct upgrades to
|
|
208
|
-
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.
|
|
209
143
|
|
|
210
144
|
**Every nested type is named for the response key that selects it**, camelized
|
|
211
145
|
(`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
|
|
212
|
-
`Entities`). Structs nest the way the selection does, so the constant path
|
|
213
|
-
|
|
146
|
+
`Entities`). Structs nest the way the selection does, so the constant path reads
|
|
147
|
+
like the query:
|
|
214
148
|
|
|
215
149
|
```graphql
|
|
216
150
|
query { repository { stargazers { edges { node { login } } } } }
|
|
@@ -231,21 +165,19 @@ The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
|
231
165
|
generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
|
|
232
166
|
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
233
167
|
|
|
234
|
-
|
|
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.
|
|
235
174
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
243
|
-
(`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
|
|
244
|
-
|
|
245
|
-
A generated name that would shadow a constant the file *uses* is refused
|
|
246
|
-
instead — a key `date` beside a `Date` scalar prop turns `Date.iso8601` into a
|
|
247
|
-
`NoMethodError` in a file that typechecks. The message names both; alias either
|
|
248
|
-
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.
|
|
249
181
|
|
|
250
182
|
## Variables become typed kwargs
|
|
251
183
|
|
|
@@ -259,15 +191,19 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
|
|
|
259
191
|
|
|
260
192
|
- required vs optional falls out of nullability and defaults: nullable or
|
|
261
193
|
defaulted variables become optional kwargs
|
|
262
|
-
- **absent and `null` are different things, and the kwarg says which.**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
- enum variables accept the enum or its wire value (`species: Species::Dog`
|
|
269
|
-
|
|
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"`)
|
|
270
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.
|
|
271
207
|
|
|
272
208
|
**The kwarg is typed exactly as the schema types it, and the value is coerced
|
|
273
209
|
anyway.** Those aren't in tension, because they answer different questions:
|
|
@@ -286,26 +222,18 @@ A value that won't convert raises `GraphWeaver::InputError` naming the variable,
|
|
|
286
222
|
the operation and the value.
|
|
287
223
|
|
|
288
224
|
That is why the emitted sig carries `.checked(:never)`: sorbet-runtime would
|
|
289
|
-
otherwise reject the String before the body could read it. Coercion
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
adds a kwarg and leaves every existing call site alone. Two names are refused at
|
|
296
|
-
generation, `$client` and `$variables`: the generated `execute` body already
|
|
297
|
-
owns them, and `def self.execute(client:, client: nil)` doesn't even parse.
|
|
298
|
-
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.)
|
|
299
231
|
|
|
300
232
|
**Input objects** take the generated `T::Struct` or a plain hash — `.coerce`
|
|
301
233
|
normalizes underscored Symbol/String keys, enums accept wire values, nested
|
|
302
234
|
inputs accept hashes, and an unknown key raises with a spellchecked hint rather
|
|
303
235
|
than silently dropping:
|
|
304
236
|
|
|
305
|
-
```graphql
|
|
306
|
-
mutation($input: AdoptionInput!) { adopt(input: $input) { ... } }
|
|
307
|
-
```
|
|
308
|
-
|
|
309
237
|
```ruby
|
|
310
238
|
AdoptMutation.execute!(input: { name: "Rex", species: "DOG", nickname: "Rexy" })
|
|
311
239
|
|
|
@@ -319,25 +247,48 @@ the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
|
|
|
319
247
|
the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
|
|
320
248
|
struct built with `.new` can't tell the two apart — every unset prop is nil
|
|
321
249
|
either way — so `nil` there means omit; reach for `coerce` to send an explicit
|
|
322
|
-
null.
|
|
323
|
-
|
|
324
|
-
|
|
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.
|
|
261
|
+
|
|
262
|
+
### An input object generates its whole closure
|
|
263
|
+
|
|
264
|
+
A result type is generated per selection set, because a selection set *is* the
|
|
265
|
+
question. An input object has no selection set, so the only static answer to
|
|
266
|
+
"what can `$where` hold" is every input type it can transitively reach — and
|
|
267
|
+
codegen emits a file for each. On a hand-written schema that closure is usually
|
|
268
|
+
the one type and nothing else. On a generated one (Hasura, Gatsby), where every
|
|
269
|
+
`_bool_exp` references every other, one `$where` reaches a thousand of them.
|
|
270
|
+
|
|
271
|
+
The escape is to stop making the filter a variable. Write it as a literal in the
|
|
272
|
+
query with a variable per leaf, and codegen has ordinary scalars to generate
|
|
273
|
+
instead of the closure — on the query that emitted ~1,200 files, exactly one:
|
|
325
274
|
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
275
|
+
```graphql
|
|
276
|
+
query($name: String!, $minHeight: Int!) {
|
|
277
|
+
pokemon(where: { name: { _ilike: $name }, height: { _gte: $minHeight } }) {
|
|
278
|
+
name
|
|
279
|
+
}
|
|
280
|
+
}
|
|
331
281
|
```
|
|
332
282
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
285
|
+
`T.any(PokemonBoolExp, T::Hash[T.untyped, T.untyped])` — and a hash built from
|
|
286
|
+
`params`, which is how a filter is really assembled, takes the untyped branch.
|
|
287
|
+
Refusals land on the leaf too, so `path` is the form field rather than the
|
|
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.
|
|
341
292
|
|
|
342
293
|
## Enums: one GraphQL enum, one Ruby type
|
|
343
294
|
|
|
@@ -352,33 +303,58 @@ AddPetMutation.execute!(name: "Rex", species:) # same class, no conversion
|
|
|
352
303
|
|
|
353
304
|
So a value read out of one query hands straight back into another's variable,
|
|
354
305
|
`case`/`T.absurd` is exhaustive across your app, and the class a field gets
|
|
355
|
-
doesn't depend on what else the query happened to reference.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
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.
|
|
360
310
|
|
|
361
311
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
362
|
-
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
315
|
+
|
|
316
|
+
**The one misuse nothing catches** is comparing against the wire spelling:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
pet.species == "CAT" # => false, always, and silently
|
|
320
|
+
pet.species == GraphQLTypes::Species::Cat
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
A generated enum is a plain `T::Enum`, so `==` against a String is `false` —
|
|
324
|
+
`srb tc` allows it (`==` takes `BasicObject`) and nothing raises. sorbet-runtime
|
|
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.
|
|
365
331
|
|
|
366
332
|
## Selections
|
|
367
333
|
|
|
368
|
-
- **Fragments** — inline fragments and named spreads flatten into the
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
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.
|
|
374
339
|
- **Aliases** — result keys follow aliases; props are the underscored alias.
|
|
375
340
|
|
|
376
|
-
Props are always snake_case (`nameWithOwner` → `name_with_owner`). Reaching
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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.
|
|
347
|
+
|
|
348
|
+
A name that would shadow a method every struct answers — `class`, `hash`,
|
|
349
|
+
`display`, `to_json`, and `supplied` on an input — takes a trailing underscore
|
|
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.
|
|
382
358
|
|
|
383
359
|
### Abstract types
|
|
384
360
|
|
|
@@ -387,34 +363,37 @@ plus a catch-all `Other`, wrapped in a module with
|
|
|
387
363
|
`Type = T.type_alias { T.any(...) }` and a `from_h` that dispatches on
|
|
388
364
|
`__typename`. Generation therefore *requires* `__typename` in such a selection,
|
|
389
365
|
unaliased and unconditional — the wire response carries no type tag unless you
|
|
390
|
-
ask, and `from_h` reads it on every response.
|
|
366
|
+
ask, and `from_h` reads it on every response. One `__typename` inside each
|
|
367
|
+
`... on Type` does **not** substitute, however many of them there are: the
|
|
368
|
+
dispatch runs before any member's selection applies, and a member the query never
|
|
369
|
+
named would carry none at all.
|
|
391
370
|
|
|
392
371
|
Size follows the query, not the schema: two `... on` conditions against GitHub's
|
|
393
372
|
`Node` — an interface with a few hundred implementations — emit three structs,
|
|
394
|
-
not a few hundred. Anything the query didn't name — a member you have no
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
399
378
|
|
|
400
379
|
Two selections have nothing to dispatch between, so they skip the module and
|
|
401
380
|
become the struct directly: **no conditions at all** (interface-level fields
|
|
402
381
|
only) → one shared struct; **exactly one condition and nothing else** → that
|
|
403
382
|
type's struct, always nilable, since a non-matching runtime type comes back as
|
|
404
383
|
`nil` — so narrowing doubles as filtering. "Nothing else" is what keeps the miss
|
|
405
|
-
legible: a field every member answers — spelled bare, or inside a fragment on
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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.
|
|
412
391
|
|
|
413
392
|
When a whole union field is selected as one named *shared* fragment
|
|
414
393
|
(`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
|
|
415
394
|
for the fragment — and each query aliases it, so the same union is one Ruby type
|
|
416
|
-
family across queries, not a fresh dispatch module per query. Like shared
|
|
417
|
-
|
|
395
|
+
family across queries, not a fresh dispatch module per query. Like shared inputs,
|
|
396
|
+
it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
418
397
|
|
|
419
398
|
### Consuming a union — dispatch on the class, not `__typename`
|
|
420
399
|
|
|
@@ -436,28 +415,25 @@ end
|
|
|
436
415
|
Two things a `case` on the `__typename` string can't give you. `when Book`
|
|
437
416
|
*narrows*: inside the branch `item` is statically a `Book`, so its fields
|
|
438
417
|
typecheck and a `Disc` field is a compile error. And after every branch the
|
|
439
|
-
`T.any` is exhausted, so `T.absurd` asserts the `else` is unreachable —
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
deliberately not "every type in the schema", which is what keeps a `case` you
|
|
445
|
-
wrote today compiling when upstream adds a member. To make the compiler force
|
|
446
|
-
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.
|
|
447
423
|
|
|
448
424
|
`__typename` is still there as a plain `String`, with one use the class can't
|
|
449
|
-
cover: two *differently-selected* occurrences of the same union are distinct
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
454
430
|
|
|
455
431
|
## Type helpers
|
|
456
432
|
|
|
457
|
-
Derived values (display names, emoji, predicates) belong next to the data but
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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:
|
|
461
437
|
|
|
462
438
|
```ruby
|
|
463
439
|
module PetHelpers
|
|
@@ -473,12 +449,15 @@ pet.name # => "Shelby" — the wire value stays honest
|
|
|
473
449
|
|
|
474
450
|
The methods live on the struct, so they see its wire fields at runtime and
|
|
475
451
|
fakes/cassettes get the behavior automatically; registrations are additive
|
|
476
|
-
(repeated ones stack).
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
`
|
|
452
|
+
(repeated ones stack). The mixin is one of your own constants, so in Rails the
|
|
453
|
+
registration goes in a `to_prepare` block like `register_enum` does, and for the
|
|
454
|
+
same reason — [getting started](getting_started.md#2-run-the-generator) has the
|
|
455
|
+
rule and the boot order behind it. Editing the *mixin* in development needs a
|
|
456
|
+
restart, unlike a `.graphql` edit: a reload hands the constant a new module
|
|
457
|
+
object, and the `include` that took the old one doesn't run again.
|
|
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`:
|
|
482
461
|
|
|
483
462
|
```ruby
|
|
484
463
|
GraphWeaver.extend_type("Pet") do
|
|
@@ -486,12 +465,23 @@ GraphWeaver.extend_type("Pet") do
|
|
|
486
465
|
end
|
|
487
466
|
```
|
|
488
467
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
468
|
+
The name is where the block is written and what it extends:
|
|
469
|
+
`GraphWeaver::TypeHelpers::Pet` at the top level,
|
|
470
|
+
`GraphWeaver::TypeHelpers::Billing::Pet` inside `GraphWeaver.graph :billing`.
|
|
471
|
+
Generated code spells it, so it depends on your source and nothing else — two
|
|
472
|
+
graphs can extend the same type name, and the name a `generate` bakes in is the
|
|
473
|
+
one a boot creates.
|
|
474
|
+
|
|
475
|
+
**Neither form is statically checked as written**, for the same reason: `srb tc`
|
|
476
|
+
checks a mixin's method bodies in the module's own scope, not the including
|
|
477
|
+
struct's, so a helper reading a wire field (`name`, `birthday`) fails with
|
|
478
|
+
"method does not exist on the module" — and the block form has no source on disk
|
|
479
|
+
for `srb tc` to read at all. A *named* module can carry real sigs, though, by
|
|
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.
|
|
483
|
+
`T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
|
|
484
|
+
beats `# typed: false` for a helper you want checked.
|
|
495
485
|
|
|
496
486
|
### Flat accessors with `alias:`
|
|
497
487
|
|
|
@@ -539,16 +529,14 @@ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional:
|
|
|
539
529
|
`optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
|
|
540
530
|
the path just omits the accessor instead of failing generation. Reach for it when
|
|
541
531
|
the alias lives on a universal type like `Query` — where a strict alias would
|
|
542
|
-
force *every* query to select the path — or when it only fits some selections.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.
|
|
546
536
|
|
|
547
537
|
For anything beyond a passthrough projection — real logic, still typed — reopen
|
|
548
538
|
the generated struct in your own file and add sig'd methods; Sorbet merges the
|
|
549
|
-
bodies.
|
|
550
|
-
|
|
551
|
-
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
|
|
552
540
|
[`spec/aliases_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/aliases_spec.rb).
|
|
553
541
|
|
|
554
542
|
## Clients
|
|
@@ -557,23 +545,28 @@ A client is anything satisfying the [execute contract](transports.md) — a
|
|
|
557
545
|
`GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
|
|
558
546
|
Resolution is per call (`client:`) → per module → baked constant →
|
|
559
547
|
`GraphWeaver.client`; the canonical list is in
|
|
560
|
-
[transports](transports.md#client-resolution).
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
`client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
548
|
+
[transports](transports.md#client-resolution). Generate *without* a baked
|
|
549
|
+
constant when you want modules to follow the app default (`GraphWeaver.client =`
|
|
550
|
+
in an initializer). A baked one is no reason a module escapes
|
|
551
|
+
[testing's `graphql:` tag](testing.md), which is exactly the instruction to
|
|
552
|
+
replace the client generation chose; what the *example* says still wins.
|
|
553
|
+
|
|
554
|
+
`client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by every
|
|
555
|
+
generated module). A baked constant is emitted as a private `DEFAULT_CLIENT`,
|
|
556
|
+
resolved on first use so a module can load before the initializer that builds its
|
|
557
|
+
client. A module generated from a
|
|
558
|
+
[declared graph](getting_started.md#more-than-one-schema) also carries a private
|
|
559
|
+
`GRAPH` naming it — so with two graphs, `graphql: :fake` fabricates each module's
|
|
560
|
+
own schema instead of having to be told which one you meant, and it is the
|
|
561
|
+
`:graph` on every [instrumentation event](logging.md#the-payload) the module's
|
|
562
|
+
`execute` produces.
|
|
570
563
|
|
|
571
564
|
## Deserializing a response from another client
|
|
572
565
|
|
|
573
566
|
`execute` is two steps: make the request, then cast the JSON into the typed
|
|
574
567
|
structs. Only the second step is GraphWeaver-specific, and it's exposed on its
|
|
575
|
-
own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post,
|
|
576
|
-
|
|
568
|
+
own — so you can fetch with any GraphQL client (Apollo, a raw `Net::HTTP` post, a
|
|
569
|
+
batching layer, a recorded fixture) and hand the result over:
|
|
577
570
|
|
|
578
571
|
```ruby
|
|
579
572
|
raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
|
|
@@ -585,27 +578,161 @@ person = response.data!.person # typed, no network
|
|
|
585
578
|
person = PersonQuery.from_response!(raw).person # or skip the envelope
|
|
586
579
|
```
|
|
587
580
|
|
|
588
|
-
`execute` *is* `from_response(client.execute(...))`, so the envelope is
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
`"extensions"` keys included. Don't symbolize or snake_case it first.
|
|
581
|
+
`execute` *is* `from_response(client.execute(...))`, so the envelope is identical.
|
|
582
|
+
The one requirement: pass the response **verbatim** — a hash (or anything with
|
|
583
|
+
`#to_h`) with the standard GraphQL shape and **wire-cased string keys**
|
|
584
|
+
(`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
|
|
585
|
+
`"extensions"` keys included. Don't symbolize or snake_case it first. Which is
|
|
586
|
+
checked, since symbolizing is the likeliest thing to go wrong at this seam: a
|
|
587
|
+
hash carrying neither `"data"` nor `"errors"` raises a `GraphWeaver::CastError`
|
|
588
|
+
naming the keys it *did* find, rather than handing back an envelope that reports
|
|
589
|
+
success with no data. `nil` and a bare String are refused the same way.
|
|
590
|
+
|
|
591
|
+
## Generating
|
|
592
|
+
|
|
593
|
+
Queries live as `.graphql` files (the source of truth), generation writes the
|
|
594
|
+
Ruby, and verification fails when the two drift. The conventional layout
|
|
595
|
+
(configurable via `GraphWeaver.queries_paths` / `generated_paths` /
|
|
596
|
+
`schema_path`):
|
|
597
|
+
|
|
598
|
+
```text
|
|
599
|
+
app/graphql/
|
|
600
|
+
schema.json # introspection dump (or schema.graphql SDL)
|
|
601
|
+
queries/ # *.graphql / *.gql, nested — hand-written, reviewed
|
|
602
|
+
fragments/ # shared fragments, spread by name from any query
|
|
603
|
+
generated/
|
|
604
|
+
types.rb # manifest: requires + forward declarations, in load order
|
|
605
|
+
types/ # one file per shared type
|
|
606
|
+
*_query.rb # one module per query — generated, checked in, never edited
|
|
607
|
+
*_mutation.rb # ...and per mutation
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
```sh
|
|
611
|
+
rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
612
|
+
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
613
|
+
```
|
|
593
614
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
615
|
+
The tasks self-register in Rails; elsewhere add `require "graph_weaver/tasks"` to
|
|
616
|
+
your Rakefile. Scalar/enum/type registrations are baked into generated source, so
|
|
617
|
+
they must run first — in Rails they do, since the tasks depend on `:environment`.
|
|
618
|
+
Or call the same APIs directly:
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
622
|
+
GraphWeaver.generate!(schema:) # write the modules
|
|
623
|
+
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**`verify_generated!` costs what `generate!` costs**, minus the writes — it
|
|
627
|
+
recomputes the whole plan whether nothing is stale or everything is. So it
|
|
628
|
+
belongs in *one* example per suite run, not in a `before` or an assertion per
|
|
629
|
+
example, where it reads like a cheap check and isn't.
|
|
630
|
+
|
|
631
|
+
`generate!` returns every file the plan produces, but rewrites only the ones whose
|
|
632
|
+
bytes changed; `GraphWeaver.changed_files` is that subset. So
|
|
633
|
+
`rake graph_weaver:generate` prints `wrote` for what moved and `N already up to
|
|
634
|
+
date` for the rest, and a watching dev server has one module to reload instead of
|
|
635
|
+
all of them. The unregistered-scalar report is the rake task's `puts`, so off rake
|
|
636
|
+
read `GraphWeaver.untyped_scalars` for the unioned list — or set
|
|
637
|
+
[`GraphWeaver.logger`](logging.md), which `generate!` names them on at `info` as
|
|
638
|
+
it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
|
|
639
|
+
generating without one fails pointing at exactly that.
|
|
640
|
+
|
|
641
|
+
**A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
|
|
642
|
+
Input types, schema enums, and unions hoisted from shared fragments are all one
|
|
643
|
+
kind of thing — a type that would otherwise be copied into every query that
|
|
644
|
+
touches it — so they live in one module, one file each, and a query module that
|
|
645
|
+
uses any of them opens with `require_relative "types"`. Rename the constant
|
|
646
|
+
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
647
|
+
generates against two schemas. One module is one namespace, so a shared fragment
|
|
648
|
+
whose name is already a schema type in that module is refused at generation,
|
|
649
|
+
naming both.
|
|
650
|
+
|
|
651
|
+
**Generation prunes.** Rename or delete a `.graphql` and the module it used to
|
|
652
|
+
produce is deleted on the next `generate!` — which says so, since a deletion you
|
|
653
|
+
didn't expect is the one worth reading; `verify` flags it as stale until you
|
|
654
|
+
regenerate. Only files carrying GraphWeaver's header
|
|
655
|
+
(`# Generated by GraphWeaver <version> — do not edit.`) are ever deleted, so
|
|
656
|
+
hand-written files in the output directory are safe. A run that finds **no**
|
|
657
|
+
queries says where it looked rather than exiting 0 in silence, and
|
|
658
|
+
`verify_generated!` fails outright.
|
|
659
|
+
|
|
660
|
+
**A refusal writes nothing at all** — not even the files that planned cleanly —
|
|
661
|
+
so a failed run leaves the tree exactly as it was, and it reports *every* query it
|
|
662
|
+
refused rather than the first.
|
|
663
|
+
|
|
664
|
+
**Generation is deterministic.** The same schema and queries produce
|
|
665
|
+
byte-identical files, on any machine, in any order — everything with a
|
|
666
|
+
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
667
|
+
sorted, and a spec asserts it both across calls and against the checked-in
|
|
668
|
+
fixtures. So regenerating a file you didn't change produces no diff,
|
|
669
|
+
`verify_generated!` never fails spuriously, and a generated file is worth
|
|
670
|
+
reviewing line by line.
|
|
671
|
+
|
|
672
|
+
Regenerate when: a query changes, the schema changes, a registration changes, or
|
|
673
|
+
GraphWeaver itself upgrades — **any release can change what codegen emits**, patch
|
|
674
|
+
releases included, and `verify_generated!` is what catches it. The rake tasks that
|
|
675
|
+
spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:check`
|
|
676
|
+
— are in [getting started](getting_started.md#5-verify-in-ci); a
|
|
677
|
+
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
678
|
+
|
|
679
|
+
### Loading what it wrote
|
|
680
|
+
|
|
681
|
+
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
682
|
+
boot from a `to_prepare` block, after your initializers and after any
|
|
683
|
+
registrations of your own in one. Elsewhere it's explicit, factory_bot-style:
|
|
684
|
+
`GraphWeaver.load_generated!` requires every file under `generated_paths`.
|
|
685
|
+
|
|
686
|
+
**Outside Rails, four things have to agree**, and nothing wires them together for
|
|
687
|
+
you — a script that generates its own modules sets all four:
|
|
688
|
+
|
|
689
|
+
1. `queries_paths` — where `generate!` reads `.graphql` files.
|
|
690
|
+
2. `generated_paths` — where it writes, and where `load_generated!` reads. Point
|
|
691
|
+
them at the same directory or generation is invisible.
|
|
692
|
+
3. the call above, before the first `execute` — nothing else requires the files.
|
|
693
|
+
4. `GraphWeaver.client =` — a module generated without a baked
|
|
694
|
+
[`client:`](#clients) has none of its own.
|
|
695
|
+
|
|
696
|
+
Miss (3) and the script gets a `NameError` for its own module; miss (4) and it
|
|
697
|
+
gets `PersonQuery: client must respond to #execute(query, variables:), got
|
|
698
|
+
NilClass` from a module that otherwise looks fine.
|
|
699
|
+
|
|
700
|
+
Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
701
|
+
`fragments_paths` — and every entry is read (entries may be globs; the generated
|
|
702
|
+
default includes `app/graphql/*/generated`, so per-schema layouts load too).
|
|
703
|
+
Assigning a String wraps it, so pointing at one directory stays a one-liner.
|
|
704
|
+
`generate!` writes into the first `generated_paths` entry — one run, one output
|
|
705
|
+
directory. `schema_path` is the one singular setting: a run reads one schema, so a
|
|
706
|
+
list would name a dump nothing ever opens. A relative path resolves against
|
|
707
|
+
`GraphWeaver.root` — `Rails.root` in a Rails app, the working directory otherwise
|
|
708
|
+
— so where you started the process doesn't change which files it reads.
|
|
709
|
+
|
|
710
|
+
Plain requires, not Zeitwerk: Zeitwerk would expect `Generated::PersonQuery` from
|
|
711
|
+
`generated/person_query.rb`. In development a query edit regenerates and reloads
|
|
712
|
+
before the next request; everywhere else generated code changes only on
|
|
713
|
+
regeneration — restart, like a schema migration, or call
|
|
714
|
+
`GraphWeaver.reload_generated!` after regenerating in another terminal.
|
|
599
715
|
|
|
600
716
|
## Dynamic mode
|
|
601
717
|
|
|
602
|
-
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
718
|
+
`GraphWeaver.parse` generates + evals in one step (no build artifact, evaled into
|
|
719
|
+
an anonymous container — no global constants leak). Same runtime semantics;
|
|
720
|
+
invisible to `srb tc`, so prefer the build step where static checking matters.
|
|
721
|
+
`GraphWeaver.run(source, query, **variables)` — or `client.run` — is the one-shot
|
|
722
|
+
form: parse and execute in one call, no module kept. In development
|
|
723
|
+
`client.load_queries!` parses every query file into modules with the same names
|
|
724
|
+
generation would use.
|
|
725
|
+
|
|
726
|
+
In an app with [more than one graph](getting_started.md#more-than-one-schema), a
|
|
727
|
+
parsed module belongs to one of them — that is what a `graphql:` tag runs it
|
|
728
|
+
against, the same thing generation bakes into a file. It is read off the schema
|
|
729
|
+
you parsed against when a graph runs that class in-process; say it outright
|
|
730
|
+
otherwise:
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
PersonQuery = GraphWeaver.parse(schema: BILLING, query: "…", graph: :billing)
|
|
734
|
+
```
|
|
608
735
|
|
|
609
736
|
Generated source is eval'd, so inputs are validated: module names must be
|
|
610
|
-
constant names, and query heredocs can't be terminated early. Still: queries
|
|
611
|
-
|
|
737
|
+
constant names, and query heredocs can't be terminated early. Still: queries are
|
|
738
|
+
code — don't feed untrusted strings to parse.
|