graph_weaver 0.5.1 → 0.6.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/CHANGELOG.md +432 -0
- data/Gemfile.lock +19 -19
- data/README.md +75 -54
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +34 -4
- data/docs/real_world.md +4 -4
- data/docs/scalars.md +206 -46
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +279 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +184 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +203 -119
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/generated_modules.md
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
# Generated modules
|
|
2
2
|
|
|
3
|
-
`
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
`GraphWeaver::Codegen` turns one GraphQL operation into one `# typed: strict`
|
|
9
|
+
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. The setup
|
|
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).
|
|
10
14
|
For consoles and dev there's [dynamic mode](#dynamic-mode); for one-off
|
|
11
15
|
scripts, `client.run!` skips modules entirely.
|
|
12
16
|
|
|
13
17
|
## Generating
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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`):
|
|
19
23
|
|
|
20
24
|
```text
|
|
21
25
|
app/graphql/
|
|
@@ -29,60 +33,15 @@ app/graphql/
|
|
|
29
33
|
*_mutation.rb # ...and per mutation
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
**A type shared across query modules lives in `GraphQLTypes` and is aliased
|
|
33
|
-
in.** Input types, schema enums, and unions hoisted from shared fragments are
|
|
34
|
-
all one kind of thing — a type that would otherwise be copied into every query
|
|
35
|
-
that touches it — so they live in one module, one file each, and a query module
|
|
36
|
-
that uses any of them opens with `require_relative "types"`. Rename the constant
|
|
37
|
-
(`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
|
|
38
|
-
generates against two schemas, in the same initializer that already gives each
|
|
39
|
-
its own paths.
|
|
40
|
-
|
|
41
|
-
One module is one namespace, so a shared fragment whose name is already a schema
|
|
42
|
-
type in that module is refused at generation, naming both.
|
|
43
|
-
|
|
44
|
-
**Naming.** A module is named after its **file**, suffixed with the operation
|
|
45
|
-
the file defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
|
|
46
|
-
`save_list_entry.graphql` → `SaveListEntryMutation` in
|
|
47
|
-
`save_list_entry_mutation.rb`. The operation name written *inside* the file
|
|
48
|
-
never names the module (it goes on the wire as `operationName`); leave it off
|
|
49
|
-
and the module's name is written into the document instead. The same rule
|
|
50
|
-
runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
|
|
51
|
-
`client.load_queries!`.
|
|
52
|
-
|
|
53
|
-
Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
|
|
54
|
-
found, but the module name still comes from the file name alone, so it is
|
|
55
|
-
`PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
|
|
56
|
-
generation, naming both, rather than one silently overwriting the other; so is a
|
|
57
|
-
file holding two operations, since one file can't name two modules.
|
|
58
|
-
|
|
59
|
-
Change a file's `query` to `mutation` and its constant changes with it; the
|
|
60
|
-
next `generate!` prunes the old file, and `verify` fails until you regenerate.
|
|
61
|
-
|
|
62
|
-
Parsing a raw query *string* has no file to name it after, so it uses the
|
|
63
|
-
operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
|
|
64
|
-
`Query` for an anonymous one (its constants are container-scoped, so collisions
|
|
65
|
-
are impossible) while `Codegen.generate` insists on a deliberate name. Pass
|
|
66
|
-
`module_name:`/`name:` to override any of this.
|
|
67
|
-
|
|
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
|
-
Rake tasks (self-registering in Rails; elsewhere add
|
|
74
|
-
`require "graph_weaver/tasks"` to your Rakefile):
|
|
75
|
-
|
|
76
36
|
```sh
|
|
77
37
|
rake graph_weaver:generate # queries_paths -> generated_paths.first
|
|
78
38
|
rake graph_weaver:verify # fail if anything is stale — run in CI
|
|
79
39
|
```
|
|
80
40
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Or call the same APIs directly:
|
|
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:
|
|
86
45
|
|
|
87
46
|
```ruby
|
|
88
47
|
schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
|
|
@@ -90,14 +49,40 @@ GraphWeaver.generate!(schema:) # write the modules
|
|
|
90
49
|
GraphWeaver.verify_generated!(schema:) # the freshness guard, one line in a spec
|
|
91
50
|
```
|
|
92
51
|
|
|
52
|
+
`generate!` returns every file the plan produces, but rewrites only the ones
|
|
53
|
+
whose bytes changed; `GraphWeaver.changed_files` is that subset. So
|
|
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.
|
|
57
|
+
|
|
58
|
+
The schema dump is step 0 — codegen reads it, never a live endpoint.
|
|
59
|
+
`cache: true` on a url client writes it on first introspection
|
|
60
|
+
(`GraphWeaver.new(url, cache: true).schema` in a console bootstraps it);
|
|
61
|
+
generating without one fails pointing at exactly that.
|
|
62
|
+
|
|
63
|
+
**A type shared across query modules lives in `GraphQLTypes` and is aliased
|
|
64
|
+
in.** Input types, schema enums, and unions hoisted from shared fragments are
|
|
65
|
+
all one kind of thing — a type that would otherwise be copied into every query
|
|
66
|
+
that touches it — so they live in one module, one file each, and a query module
|
|
67
|
+
that uses any of them opens with `require_relative "types"`. Rename the constant
|
|
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
|
+
|
|
93
73
|
**Generation prunes.** Rename or delete a `.graphql` and the module it used
|
|
94
|
-
to produce is deleted on the next `generate
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
98
82
|
|
|
99
83
|
In Rails, loading is automatic — the Railtie requires every generated file at
|
|
100
|
-
boot, after your initializers
|
|
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
|
|
101
86
|
explicit, factory_bot-style:
|
|
102
87
|
|
|
103
88
|
```ruby
|
|
@@ -118,16 +103,23 @@ GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
|
118
103
|
Assigning a String wraps it, so pointing at one directory stays a one-liner.
|
|
119
104
|
`generate!` writes into the first `generated_paths` entry — one run, one output
|
|
120
105
|
directory. `schema_path` is the one singular setting: a run reads one schema,
|
|
121
|
-
so a list would name a dump nothing ever opens.
|
|
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.
|
|
122
110
|
|
|
123
111
|
(Plain requires, not Zeitwerk: Zeitwerk would expect
|
|
124
|
-
`Generated::PersonQuery` from `generated/person_query.rb
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
131
123
|
[getting started](getting_started.md#5-verify-in-ci); a
|
|
132
124
|
[`schema_stale?`](errors.md) error in production is the late signal.
|
|
133
125
|
|
|
@@ -135,19 +127,13 @@ In development, skip the build entirely — `client.load_queries!` parses
|
|
|
135
127
|
every query file into modules with the same names generation would use
|
|
136
128
|
(see [dynamic mode](#dynamic-mode)).
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
The same schema and the same queries produce **byte-identical files** — on any
|
|
141
|
-
machine, in any order, however many times you run it. Everything with a
|
|
130
|
+
**Generation is deterministic.** The same schema and queries produce
|
|
131
|
+
byte-identical files, on any machine, in any order — everything with a
|
|
142
132
|
non-obvious order (schema members, enum values, requires, hoisted names) is
|
|
143
133
|
sorted, and a spec asserts it both across calls and against the checked-in
|
|
144
|
-
fixtures.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`graph_weaver:generate` in a PR shows exactly what moved, `verify_generated!`
|
|
148
|
-
never fails spuriously, and a generated file is worth reviewing line by line.
|
|
149
|
-
(A GraphWeaver upgrade may legitimately change emission — that's a version
|
|
150
|
-
bump, and the changelog says when to regenerate.)
|
|
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.
|
|
151
137
|
|
|
152
138
|
## Anatomy
|
|
153
139
|
|
|
@@ -178,11 +164,10 @@ end
|
|
|
178
164
|
|
|
179
165
|
- `execute` returns the **envelope** — `GraphWeaver::Response[Result]` with
|
|
180
166
|
`#data`, `#data!`, `#errors`, `#extensions` — so partial data and
|
|
181
|
-
cost/throttle metadata survive.
|
|
182
|
-
|
|
183
|
-
`GraphWeaver::QueryError`.
|
|
167
|
+
cost/throttle metadata survive. `execute!` is the shortcut: the typed
|
|
168
|
+
**result**, or a raised `GraphWeaver::QueryError`. See [errors](errors.md).
|
|
184
169
|
- `from_response` / `from_response!` are the **network-free half** of the
|
|
185
|
-
pair — same envelope, but from a response hash you already have (
|
|
170
|
+
pair — same envelope, but from a response hash you already have (below).
|
|
186
171
|
- `OPERATION_NAME` rides along on every request as the spec's
|
|
187
172
|
`operationName`, so Apollo Studio, Hasura and your APM key traces, rate
|
|
188
173
|
limits and slow-query reports on the operation instead of lumping every
|
|
@@ -192,34 +177,75 @@ end
|
|
|
192
177
|
document doesn't declare. A document that names its own operation is left
|
|
193
178
|
exactly as written.
|
|
194
179
|
|
|
195
|
-
##
|
|
180
|
+
## Naming
|
|
196
181
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
182
|
+
**A module is named after its file**, suffixed with the operation the file
|
|
183
|
+
defines — `person.graphql` → `PersonQuery` in `person_query.rb`,
|
|
184
|
+
`save_list_entry.graphql` → `SaveListEntryMutation` in
|
|
185
|
+
`save_list_entry_mutation.rb`. The operation name written *inside* the file
|
|
186
|
+
never names the module (it goes on the wire as `operationName`); leave it off
|
|
187
|
+
and the module's name is written into the document instead. The same rule
|
|
188
|
+
runs at all three doors: `generate!`, `GraphWeaver.parse(path)`, and
|
|
189
|
+
`client.load_queries!`.
|
|
201
190
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
191
|
+
Subdirectories are yours to organize with — `queries/admin/pets.graphql` is
|
|
192
|
+
found, but the module name still comes from the file name alone, so it is
|
|
193
|
+
`PetsQuery` in `pets_query.rb`. Two files with the same base name are refused at
|
|
194
|
+
generation, naming both, rather than one silently overwriting the other; so is a
|
|
195
|
+
file holding two operations, since one file can't name two modules. Change a
|
|
196
|
+
file's `query` to `mutation` and its constant changes with it; the next
|
|
197
|
+
`generate!` prunes the old file, and `verify` fails until you regenerate.
|
|
205
198
|
|
|
206
|
-
|
|
207
|
-
|
|
199
|
+
Parsing a raw query *string* has no file to name it after, so it uses the
|
|
200
|
+
operation name (`query GetPerson` → `GetPerson`); dynamic `parse` falls back to
|
|
201
|
+
`Query` for an anonymous one (its constants are container-scoped, so collisions
|
|
202
|
+
are impossible) while `Codegen.generate` insists on a deliberate name. Override
|
|
203
|
+
with `name:` on either.
|
|
208
204
|
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
Whatever it lands on, the module wears it: a cast failure inside a parsed module
|
|
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.
|
|
209
|
+
|
|
210
|
+
**Every nested type is named for the response key that selects it**, camelized
|
|
211
|
+
(`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
|
|
212
|
+
`Entities`). Structs nest the way the selection does, so the constant path
|
|
213
|
+
reads like the query:
|
|
214
|
+
|
|
215
|
+
```graphql
|
|
216
|
+
query { repository { stargazers { edges { node { login } } } } }
|
|
211
217
|
```
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
```ruby
|
|
220
|
+
StargazersQuery::Result::Repository::Stargazers::Edges::Node
|
|
221
|
+
```
|
|
216
222
|
|
|
217
|
-
The
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`
|
|
222
|
-
|
|
223
|
+
The name is a function of that field's own position and nothing else, which is
|
|
224
|
+
the property that matters when generated code is checked in and referenced from
|
|
225
|
+
app code: **adding, removing, or reordering an unrelated selection can never
|
|
226
|
+
rename a struct you already use.**
|
|
227
|
+
[`spec/naming_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/naming_spec.rb) asserts each of those three
|
|
228
|
+
edits leaves the name alone.
|
|
229
|
+
|
|
230
|
+
The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
231
|
+
generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
|
|
232
|
+
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
233
|
+
|
|
234
|
+
Two kinds of name don't come from a key, both equally position-determined:
|
|
235
|
+
|
|
236
|
+
- **Union and interface members** are named for the type condition that
|
|
237
|
+
produces them (`... on Book` → `Book`) inside the container named for the
|
|
238
|
+
field, plus the catch-all `Other`. A union hoisted out of a shared fragment
|
|
239
|
+
is named for the fragment.
|
|
240
|
+
- Where several fields share one collapsed union type (identical selections),
|
|
241
|
+
it takes the first of their keys alphabetically; and a name that would shadow
|
|
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.
|
|
223
249
|
|
|
224
250
|
## Variables become typed kwargs
|
|
225
251
|
|
|
@@ -232,12 +258,39 @@ AddPetMutation.execute!(name: "Rex", species: AddPetMutation::Species::Dog)
|
|
|
232
258
|
```
|
|
233
259
|
|
|
234
260
|
- required vs optional falls out of nullability and defaults: nullable or
|
|
235
|
-
defaulted variables become optional kwargs
|
|
236
|
-
|
|
261
|
+
defaulted variables become optional kwargs
|
|
262
|
+
- **absent and `null` are different things, and the kwarg says which.**
|
|
263
|
+
Leaving a keyword out omits the variable, so the server's default applies;
|
|
264
|
+
passing `nil` sends `null`, which is how a mutation clears a field.
|
|
265
|
+
`bio: params[:bio]` therefore sends `null` when the param is missing — pass
|
|
266
|
+
the keyword only when you mean to. A non-null variable can't carry `null`,
|
|
267
|
+
so `nil` there still means omit.
|
|
237
268
|
- enum variables accept the enum or its wire value (`species: Species::Dog`
|
|
238
269
|
or `species: "DOG"`)
|
|
239
270
|
- custom scalars serialize through the [scalar registry](scalars.md)
|
|
240
271
|
|
|
272
|
+
**The kwarg is typed exactly as the schema types it, and the value is coerced
|
|
273
|
+
anyway.** Those aren't in tension, because they answer different questions:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
StargazersQuery.execute(first: 10) # typechecks
|
|
277
|
+
StargazersQuery.execute(first: "10") # srb tc error — you know it's a literal
|
|
278
|
+
StargazersQuery.execute(first: params[:first]) # typechecks, and "10" becomes 10
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`first:` is `Integer`, never `T.any(Integer, String)`, so `srb tc` still catches
|
|
282
|
+
a call site that has the wrong thing. But a Rails param is `T.untyped` — sorbet
|
|
283
|
+
lets it through, and `execute` converts it in its body from what the scalar
|
|
284
|
+
already knows ([the table is in scalars.md](scalars.md#going-out--what-a-variable-kwarg-accepts)).
|
|
285
|
+
A value that won't convert raises `GraphWeaver::InputError` naming the variable,
|
|
286
|
+
the operation and the value.
|
|
287
|
+
|
|
288
|
+
That is why the emitted sig carries `.checked(:never)`: sorbet-runtime would
|
|
289
|
+
otherwise reject the String before the body could read it. Coercion is what
|
|
290
|
+
stands in its place for the arguments — stricter, and with a better message —
|
|
291
|
+
and the `Result` it returns is a `T::Struct`, so its props are still checked one
|
|
292
|
+
by one.
|
|
293
|
+
|
|
241
294
|
One kwarg per declared variable, always — so adding a variable to a query
|
|
242
295
|
adds a kwarg and leaves every existing call site alone. Two names are refused at
|
|
243
296
|
generation, `$client` and `$variables`: the generated `execute` body already
|
|
@@ -262,7 +315,11 @@ AdoptMutation.execute!(input: AdoptMutation::AdoptionInput.new(name: "Rex", spec
|
|
|
262
315
|
|
|
263
316
|
A struct is typed consts plus a compact per-field `FIELDS` table the
|
|
264
317
|
`GraphWeaver::InputStruct` runtime drives — `serialize` (aliased `to_h`) builds
|
|
265
|
-
the wire hash
|
|
318
|
+
the wire hash, `coerce` builds from a plain hash. `coerce` remembers which keys
|
|
319
|
+
the hash had, so `{nickname: nil}` sends `null` and `{}` omits the field. A
|
|
320
|
+
struct built with `.new` can't tell the two apart — every unset prop is nil
|
|
321
|
+
either way — so `nil` there means omit; reach for `coerce` to send an explicit
|
|
322
|
+
null.
|
|
266
323
|
Nested inputs work, including recursive ones — a self-referential filter
|
|
267
324
|
generates cleanly, with `_and:`/`_not:` typed as the struct itself:
|
|
268
325
|
|
|
@@ -297,6 +354,10 @@ So a value read out of one query hands straight back into another's variable,
|
|
|
297
354
|
`case`/`T.absurd` is exhaustive across your app, and the class a field gets
|
|
298
355
|
doesn't depend on what else the query happened to reference.
|
|
299
356
|
|
|
357
|
+
An enum value that camelizes to nothing — `_` and `__` are both legal GraphQL —
|
|
358
|
+
is refused at generation: there is no constant to name it. Map the enum onto one
|
|
359
|
+
of yours instead.
|
|
360
|
+
|
|
300
361
|
`register_enum` replaces the generated `T::Enum` with your own app enum — see
|
|
301
362
|
[scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits
|
|
302
363
|
the enums into the query module itself; there's no cross-query set to share
|
|
@@ -307,8 +368,6 @@ against, but one enum is still one class within that module.
|
|
|
307
368
|
- **Fragments** — inline fragments and named spreads flatten into the
|
|
308
369
|
selection; type conditions match exact names or interfaces/unions the type
|
|
309
370
|
belongs to.
|
|
310
|
-
- **Unions and interfaces** — one struct per type condition the selection
|
|
311
|
-
names, plus a catch-all `Other`. Detail [below](#abstract-types).
|
|
312
371
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
313
372
|
the response regardless of schema nullability, so its generated type is
|
|
314
373
|
always nilable.
|
|
@@ -340,12 +399,16 @@ member upstream is a non-breaking change, and it stays one here.
|
|
|
340
399
|
|
|
341
400
|
Two selections have nothing to dispatch between, so they skip the module and
|
|
342
401
|
become the struct directly: **no conditions at all** (interface-level fields
|
|
343
|
-
only) → one shared struct; **exactly one condition** → that
|
|
344
|
-
always nilable, since a non-matching runtime type comes back as
|
|
345
|
-
narrowing doubles as filtering.
|
|
346
|
-
|
|
347
|
-
which is
|
|
348
|
-
|
|
402
|
+
only) → one shared struct; **exactly one condition and nothing else** → that
|
|
403
|
+
type's struct, always nilable, since a non-matching runtime type comes back as
|
|
404
|
+
`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
|
+
the abstract type itself, which is the same selection — puts the field back on
|
|
407
|
+
the dispatch path, so the other members keep what they sent. Narrowing reads the
|
|
408
|
+
match off `__typename` when the selection carries one unaliased and unguarded,
|
|
409
|
+
and off "the object came back empty" when it doesn't — so an
|
|
410
|
+
all-`@skip`/`@include` narrowed fragment, or one whose `__typename` is itself
|
|
411
|
+
guarded, is refused: a match would be indistinguishable from a miss.
|
|
349
412
|
|
|
350
413
|
When a whole union field is selected as one named *shared* fragment
|
|
351
414
|
(`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
|
|
@@ -389,44 +452,6 @@ type families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a
|
|
|
389
452
|
fragment to hold it as one type across queries ([above](#abstract-types)); if
|
|
390
453
|
all you have is the bare tag, `__typename` is the common denominator, unchecked.
|
|
391
454
|
|
|
392
|
-
## Naming nested types
|
|
393
|
-
|
|
394
|
-
Module names come from the file ([above](#generating)). **Every nested type is
|
|
395
|
-
named for the response key that selects it**, camelized
|
|
396
|
-
(`stargazers` → `Stargazers`, `nameWithOwner` → `NameWithOwner`, `_entities` →
|
|
397
|
-
`Entities`). Structs nest the way the selection does, so the constant path
|
|
398
|
-
reads like the query:
|
|
399
|
-
|
|
400
|
-
```graphql
|
|
401
|
-
query { repository { stargazers { edges { node { login } } } } }
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
```ruby
|
|
405
|
-
StargazersQuery::Result::Repository::Stargazers::Edges::Node
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
The name is a function of that field's own position and nothing else, which is
|
|
409
|
-
the property that matters when generated code is checked in and referenced from
|
|
410
|
-
app code: **adding, removing, or reordering an unrelated selection can never
|
|
411
|
-
rename a struct you already use.**
|
|
412
|
-
[`spec/naming_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/naming_spec.rb) asserts each of those three
|
|
413
|
-
edits leaves the name alone.
|
|
414
|
-
|
|
415
|
-
The key is used verbatim — no pluralization heuristics, so a list field `pets`
|
|
416
|
-
generates `Pets`, not `Pet`. To choose the name yourself, alias the field in the
|
|
417
|
-
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
418
|
-
|
|
419
|
-
Two kinds of name don't come from a key, both equally position-determined:
|
|
420
|
-
|
|
421
|
-
- **Union and interface members** are named for the type condition that
|
|
422
|
-
produces them (`... on Book` → `Book`) inside the container named for the
|
|
423
|
-
field, plus the catch-all `Other`. A union hoisted out of a shared fragment
|
|
424
|
-
is named for the fragment.
|
|
425
|
-
- Where several fields share one collapsed union type (identical selections),
|
|
426
|
-
it takes the first of their keys alphabetically; and a name that would shadow
|
|
427
|
-
the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
|
|
428
|
-
(`Pet2`), since a bare `Pet` inside `class Pet` would resolve to the child.
|
|
429
|
-
|
|
430
455
|
## Type helpers
|
|
431
456
|
|
|
432
457
|
Derived values (display names, emoji, predicates) belong next to the data but
|
|
@@ -448,7 +473,10 @@ pet.name # => "Shelby" — the wire value stays honest
|
|
|
448
473
|
|
|
449
474
|
The methods live on the struct, so they see its wire fields at runtime and
|
|
450
475
|
fakes/cassettes get the behavior automatically; registrations are additive
|
|
451
|
-
(repeated ones stack).
|
|
476
|
+
(repeated ones stack). Editing the *mixin* in development needs a restart,
|
|
477
|
+
unlike a `.graphql` edit: a reload hands the constant a new module object, and
|
|
478
|
+
the `include` that took the old one doesn't run again.
|
|
479
|
+
For quick decoration, build the mixin inline — the block
|
|
452
480
|
is `module_eval`'d into a fresh module auto-named under
|
|
453
481
|
`GraphWeaver::TypeHelpers`:
|
|
454
482
|
|
|
@@ -525,10 +553,10 @@ Every form above, and every error it raises, is a named example in
|
|
|
525
553
|
|
|
526
554
|
## Clients
|
|
527
555
|
|
|
528
|
-
A client is anything
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
556
|
+
A client is anything satisfying the [execute contract](transports.md) — a
|
|
557
|
+
`GraphWeaver::Client`, a transport, a `Retry`, a live schema class, a fake.
|
|
558
|
+
Resolution is per call (`client:`) → per module → baked constant →
|
|
559
|
+
`GraphWeaver.client`; the canonical list is in
|
|
532
560
|
[transports](transports.md#client-resolution).
|
|
533
561
|
|
|
534
562
|
Generate *without* a baked constant when you want modules to follow the
|
|
@@ -536,9 +564,38 @@ app default (`GraphWeaver.client =` in an initializer) — that's also what
|
|
|
536
564
|
lets [testing's `graphql:` tag](testing.md) swap in a client per example.
|
|
537
565
|
|
|
538
566
|
`client`/`client=` live in the gem (`GraphWeaver::QueryModule`, extended by
|
|
539
|
-
every generated module). A baked constant is emitted as
|
|
540
|
-
resolved on first use so a module can load before the
|
|
541
|
-
its client.
|
|
567
|
+
every generated module). A baked constant is emitted as a private
|
|
568
|
+
`DEFAULT_CLIENT`, resolved on first use so a module can load before the
|
|
569
|
+
initializer that builds its client.
|
|
570
|
+
|
|
571
|
+
## Deserializing a response from another client
|
|
572
|
+
|
|
573
|
+
`execute` is two steps: make the request, then cast the JSON into the typed
|
|
574
|
+
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
|
+
a batching layer, a recorded fixture) and hand the result over:
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
raw = my_graphql_client.post(PersonQuery::QUERY, id: "1")
|
|
580
|
+
# => {"data" => {"person" => {...}}, "errors" => [...], "extensions" => {...}}
|
|
581
|
+
|
|
582
|
+
response = PersonQuery.from_response(raw) # GraphWeaver::Response[Result]
|
|
583
|
+
person = response.data!.person # typed, no network
|
|
584
|
+
|
|
585
|
+
person = PersonQuery.from_response!(raw).person # or skip the envelope
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
`execute` *is* `from_response(client.execute(...))`, so the envelope is
|
|
589
|
+
identical. The one requirement: pass the response **verbatim** — a hash (or
|
|
590
|
+
anything with `#to_h`) with the standard GraphQL shape and **wire-cased string
|
|
591
|
+
keys** (`"person"`, `"nameWithOwner"`), the top-level `"data"` / `"errors"` /
|
|
592
|
+
`"extensions"` keys included. Don't symbolize or snake_case it first.
|
|
593
|
+
|
|
594
|
+
Which is checked, since symbolizing is the likeliest thing to go wrong at this
|
|
595
|
+
seam: a hash carrying neither `"data"` nor `"errors"` raises a
|
|
596
|
+
`GraphWeaver::TypeError` naming the keys it *did* find, rather than handing back
|
|
597
|
+
an envelope that reports success with no data. `nil` and a bare String are
|
|
598
|
+
refused the same way.
|
|
542
599
|
|
|
543
600
|
## Dynamic mode
|
|
544
601
|
|