graph_weaver 0.3.0 → 0.4.4
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 +88 -0
- data/CLAUDE.md +1 -2
- data/Gemfile.lock +22 -22
- data/docs/federation.md +36 -58
- data/docs/generated_modules.md +40 -4
- data/docs/getting_started.md +52 -0
- data/docs/scalars.md +65 -9
- data/graph_weaver.gemspec +3 -1
- data/lib/graph_weaver/client.rb +6 -4
- data/lib/graph_weaver/codegen/emit.rb +56 -11
- data/lib/graph_weaver/codegen/enum_type.rb +37 -5
- data/lib/graph_weaver/codegen/nodes.rb +27 -3
- data/lib/graph_weaver/codegen.rb +261 -12
- data/lib/graph_weaver/schema_loader.rb +134 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +112 -45
- metadata +3 -4
- data/lib/graph_weaver/directive_defaults_patch.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fac36591b869f046295946094078847ed33a1aa15aa8a2402e6a46f6e17ebaa0
|
|
4
|
+
data.tar.gz: c366d845da86029839f57214ba6216b78f2a870b67873851c2514d3d00aaa78d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37c814f83659a3189407b4c8e4531db4782fc4f38cd0e62df4f8e473a75c713adc3d0bb43de62a51854c1d6f0aeea1195e89848e83bd61c74018a840bfe56924
|
|
7
|
+
data.tar.gz: 787508b524cb027fb31281379f64bc566b1bb23709a79883928005ced38cf478c6c90b62a09d2de66317a6d7415eea77014667a5f543472a6f42d8ad043634de
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
+
### v0.4.4 (2026-07-30)
|
|
2
|
+
- Supergraph loading now derives the **API schema**: `@inaccessible` elements
|
|
3
|
+
(present in the federated graph but hidden from what the router serves) are
|
|
4
|
+
removed on load, cascading — a field/argument/union-member/interface
|
|
5
|
+
referencing a removed type goes too, and a type left empty is removed in turn.
|
|
6
|
+
So codegen validates against exactly what clients can query, with no
|
|
7
|
+
over-permitting and no Apollo JS tooling to subtract the API schema first.
|
|
8
|
+
Plain (non-federation) SDL is untouched.
|
|
9
|
+
|
|
10
|
+
### v0.4.3 (2026-07-30)
|
|
11
|
+
- Federation-aware supergraph loading: `SchemaLoader` (and `Client.new`) now
|
|
12
|
+
load a composed Apollo Federation v2 supergraph SDL directly. When the `@join__*`
|
|
13
|
+
markers are present it strips the composition machinery — the synthetic
|
|
14
|
+
`join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
|
|
15
|
+
application — via an AST rewrite before `from_definition`, so the merged type
|
|
16
|
+
shapes load cleanly with nothing federation-internal leaking into
|
|
17
|
+
`schema.types`. Plain SDL is untouched. A query can now be typed against the
|
|
18
|
+
composed supergraph, not just per-subgraph schema objects.
|
|
19
|
+
- Removed `directive_defaults_patch.rb` (the graphql-ruby monkeypatch); the
|
|
20
|
+
preprocessor supersedes it and `graphql-ruby` fixed the underlying issue. The
|
|
21
|
+
gem now requires `graphql >= 2.6.7`.
|
|
22
|
+
|
|
23
|
+
### v0.4.2 (2026-07-30)
|
|
24
|
+
- `extend_type` accepts `alias:` — project a selected field (possibly nested)
|
|
25
|
+
onto a flat, typed accessor emitted into the struct body:
|
|
26
|
+
`extend_type("Widget", alias: { tag: "meta.tag" })` generates a sig'd
|
|
27
|
+
`def tag = meta&.tag`. Retires hand-written value objects that only flattened
|
|
28
|
+
a passthrough field. Takes a `{ name => path }` hash, a bare path string
|
|
29
|
+
(accessor named after the last segment), or an array of paths. Typed from the
|
|
30
|
+
selection — a nullable hop makes the accessor nilable and nil-safe; the leaf
|
|
31
|
+
may be a scalar, enum, or nested struct. Validated per query at generation: an
|
|
32
|
+
unselected/misspelled segment (with `did you mean`), a list hop, or a name
|
|
33
|
+
collision raises. Stacks and is client-scopable like the mixin forms.
|
|
34
|
+
|
|
35
|
+
### v0.4.1 (2026-07-29)
|
|
36
|
+
- Generated `execute!` forwards its kwargs to `execute` via hash shorthand
|
|
37
|
+
(`execute(client, name:, species:)` rather than `name: name, species: species`)
|
|
38
|
+
— cosmetic only. Regenerate to refresh (`verify` flags the drift otherwise).
|
|
39
|
+
|
|
40
|
+
### v0.4.0 (2026-07-28)
|
|
41
|
+
- Shared unions: when a named shared fragment is the whole selection on a union
|
|
42
|
+
field (`feed { ...FeedItemFields }`), its type is hoisted once into a
|
|
43
|
+
`GraphQLUnions` module and every query that spreads it aliases the same type —
|
|
44
|
+
so a union selected across many queries is one Ruby type family (one
|
|
45
|
+
exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
|
|
46
|
+
the shared fragment is the opt-in. Triggers only for an exact lone spread;
|
|
47
|
+
mixing other fields, or shadowing with a query-local fragment, keeps the union
|
|
48
|
+
inlined. Module name derives from the output path (override with
|
|
49
|
+
`GraphWeaver.unions_module=`); dynamic `parse` still inlines.
|
|
50
|
+
- Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
|
|
51
|
+
Directory-based generation always emits each input type once into a shared
|
|
52
|
+
module — the self-contained-module opt-out added complexity for little value.
|
|
53
|
+
Single-query `parse` / `Codegen.generate` still inline their types (there's
|
|
54
|
+
no set to share against). Only affects callers who passed
|
|
55
|
+
`shared_inputs: false`.
|
|
56
|
+
|
|
57
|
+
### v0.3.4 (2026-07-29)
|
|
58
|
+
- Shared fragments: define reusable named fragments once (default
|
|
59
|
+
`app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
|
|
60
|
+
spread them from any query. Each query inlines only the fragments it
|
|
61
|
+
transitively spreads, so the sent query stays self-contained. Fragment files
|
|
62
|
+
hold only fragments; names are unique across them. Works in `generate!` and
|
|
63
|
+
dynamic `parse`.
|
|
64
|
+
|
|
65
|
+
### v0.3.3 (2026-07-29)
|
|
66
|
+
- Union member-type dedup: a union selected more than once on a struct now
|
|
67
|
+
collapses to one Ruby type family instead of a distinct per-field family with
|
|
68
|
+
identical members — so a consumer gets a single exhaustive
|
|
69
|
+
`case … T.absurd` across every field of that union. Structurally different
|
|
70
|
+
selections stay distinct types. (First cut: same-struct siblings; regenerate
|
|
71
|
+
checked-in modules to pick it up.)
|
|
72
|
+
|
|
73
|
+
### v0.3.2 (2026-07-29)
|
|
74
|
+
- `register_scalar` accepts a `Type.field` coordinate to override how one
|
|
75
|
+
field's scalar deserializes — so the same scalar can be different Ruby types
|
|
76
|
+
across fields (`register_scalar("User.birthday", Date)` while
|
|
77
|
+
`ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
|
|
78
|
+
scalar-name registration; both stack global-then-client. The coordinate is
|
|
79
|
+
validated against the schema (a typo'd or non-scalar field raises). Same
|
|
80
|
+
method, same signature — a `.` in the name selects the field form.
|
|
81
|
+
|
|
82
|
+
### v0.3.1 (2026-07-28)
|
|
83
|
+
- `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
|
|
84
|
+
`extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
|
|
85
|
+
(`class Module; include T::Sig`) and skips the now-redundant `extend` — so
|
|
86
|
+
generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
|
|
87
|
+
Force with `true`/`false`; `false` requires the global include.
|
|
88
|
+
|
|
1
89
|
### v0.3.0 (2026-07-28)
|
|
2
90
|
- Renamed `register_type` to `extend_type` to disambiguate intent: it
|
|
3
91
|
*decorates* a generated struct with mixin modules/helpers — it doesn't
|
data/CLAUDE.md
CHANGED
|
@@ -26,8 +26,7 @@ where it pays off in developer experience; leave the rest at `# typed: true`.
|
|
|
26
26
|
shape without catching anything. **Don't promote these to strict.**
|
|
27
27
|
- Rule of thumb: if a sig would be mostly `T.untyped`, it isn't worth writing.
|
|
28
28
|
Concrete types = value; `T.untyped` sigs = paperwork.
|
|
29
|
-
- `railtie.rb` / `tasks.rb` are `# typed: ignore` (Rails/Rake DSL)
|
|
30
|
-
`directive_defaults_patch.rb` is `# typed: false` (a prepended monkeypatch).
|
|
29
|
+
- `railtie.rb` / `tasks.rb` are `# typed: ignore` (Rails/Rake DSL).
|
|
31
30
|
|
|
32
31
|
## Design invariants (don't "fix" these)
|
|
33
32
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.
|
|
5
|
-
graphql (>= 2)
|
|
4
|
+
graph_weaver (0.4.4)
|
|
5
|
+
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
8
8
|
GEM
|
|
@@ -42,7 +42,7 @@ GEM
|
|
|
42
42
|
google-protobuf (4.35.1-x86_64-linux-gnu)
|
|
43
43
|
bigdecimal
|
|
44
44
|
rake (~> 13.3)
|
|
45
|
-
graphql (2.6.
|
|
45
|
+
graphql (2.6.7)
|
|
46
46
|
base64
|
|
47
47
|
fiber-storage
|
|
48
48
|
logger
|
|
@@ -99,16 +99,16 @@ GEM
|
|
|
99
99
|
rubydex (0.2.7-arm64-darwin)
|
|
100
100
|
rubydex (0.2.7-x86_64-darwin)
|
|
101
101
|
rubydex (0.2.7-x86_64-linux)
|
|
102
|
-
simplecov (1.0.
|
|
103
|
-
sorbet (0.6.
|
|
104
|
-
sorbet-static (= 0.6.
|
|
105
|
-
sorbet-runtime (0.6.
|
|
106
|
-
sorbet-static (0.6.
|
|
107
|
-
sorbet-static (0.6.
|
|
108
|
-
sorbet-static (0.6.
|
|
109
|
-
sorbet-static-and-runtime (0.6.
|
|
110
|
-
sorbet (= 0.6.
|
|
111
|
-
sorbet-runtime (= 0.6.
|
|
102
|
+
simplecov (1.0.3)
|
|
103
|
+
sorbet (0.6.13365)
|
|
104
|
+
sorbet-static (= 0.6.13365)
|
|
105
|
+
sorbet-runtime (0.6.13365)
|
|
106
|
+
sorbet-static (0.6.13365-aarch64-linux)
|
|
107
|
+
sorbet-static (0.6.13365-universal-darwin)
|
|
108
|
+
sorbet-static (0.6.13365-x86_64-linux)
|
|
109
|
+
sorbet-static-and-runtime (0.6.13365)
|
|
110
|
+
sorbet (= 0.6.13365)
|
|
111
|
+
sorbet-runtime (= 0.6.13365)
|
|
112
112
|
spoom (1.8.3)
|
|
113
113
|
erubi (>= 1.10.0)
|
|
114
114
|
prism (>= 0.28.0)
|
|
@@ -176,8 +176,8 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.
|
|
180
|
-
graphql (2.6.
|
|
179
|
+
graph_weaver (0.4.4)
|
|
180
|
+
graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
183
183
|
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
|
@@ -206,13 +206,13 @@ CHECKSUMS
|
|
|
206
206
|
rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
|
|
207
207
|
rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
|
|
208
208
|
rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
|
|
209
|
-
simplecov (1.0.
|
|
210
|
-
sorbet (0.6.
|
|
211
|
-
sorbet-runtime (0.6.
|
|
212
|
-
sorbet-static (0.6.
|
|
213
|
-
sorbet-static (0.6.
|
|
214
|
-
sorbet-static (0.6.
|
|
215
|
-
sorbet-static-and-runtime (0.6.
|
|
209
|
+
simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
|
|
210
|
+
sorbet (0.6.13365) sha256=3a642fe7afb031ad670c2e6161b3d5492dd67705cbcafb132c9e87ded7ccf676
|
|
211
|
+
sorbet-runtime (0.6.13365) sha256=0657cddfd2319c9695a0b6e13aa7a7c7b093149e5589448c03b911adf1e243cb
|
|
212
|
+
sorbet-static (0.6.13365-aarch64-linux) sha256=14b7bf3a227ee102c119763158618bc6b677994cebfda2705b94bca56e4c25e1
|
|
213
|
+
sorbet-static (0.6.13365-universal-darwin) sha256=0f6033dde8a0dc7b7e72ada3ce73e1e3097843be16e473fb8bb2b8fb6199212b
|
|
214
|
+
sorbet-static (0.6.13365-x86_64-linux) sha256=f94daadcae55f2e0f7797f8fdc8a250c89ec62e11f2d41905ed6fb1e60ff708e
|
|
215
|
+
sorbet-static-and-runtime (0.6.13365) sha256=9768c114686f7cac0a6f2e222db4213887d294df6059802242ba6e4e08adbad9
|
|
216
216
|
spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
|
|
217
217
|
tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
|
|
218
218
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
data/docs/federation.md
CHANGED
|
@@ -20,17 +20,20 @@ URL to introspect the API schema live.
|
|
|
20
20
|
|
|
21
21
|
## Pointing weaver at a supergraph
|
|
22
22
|
|
|
23
|
-
A supergraph SDL works as-is.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
A supergraph SDL works as-is. When `SchemaLoader` sees the `@join__*` markers it
|
|
24
|
+
strips the composition machinery before building the schema — the synthetic
|
|
25
|
+
`join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
|
|
26
|
+
application on the real types — so what codegen sees is the merged graph's
|
|
27
|
+
ordinary type shapes, with no federation plumbing leaking into `schema.types`.
|
|
28
|
+
(It's a pure AST rewrite of the SDL; no graphql-ruby monkeypatch, and plain
|
|
29
|
+
schemas pass through untouched.) Field shapes — nullability, args, enums,
|
|
30
|
+
inputs — are identical to the API schema, so your generated structs are correct;
|
|
31
|
+
and because codegen is **query-driven**, nothing federation-internal could
|
|
32
|
+
generate code anyway.
|
|
33
|
+
|
|
34
|
+
A supergraph is a **superset** of the API schema — it carries elements the
|
|
35
|
+
public API hides, marked `@inaccessible`. Weaver removes those on load (below),
|
|
36
|
+
so the schema it generates against is the API schema, not the superset.
|
|
34
37
|
|
|
35
38
|
### `@inaccessible`
|
|
36
39
|
|
|
@@ -44,57 +47,32 @@ It's a fed-v2 feature — common in mature, multi-team graphs with lots of share
|
|
|
44
47
|
types, rare in small or young ones, and targeted where present (a handful of
|
|
45
48
|
elements, not every field).
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
Weaver derives the API schema from the supergraph for you: loading strips every
|
|
51
|
+
`@inaccessible` element and cascades — a field/argument/union-member/interface
|
|
52
|
+
referencing a removed type goes too, and a type left empty is removed in turn —
|
|
53
|
+
so codegen validates against **exactly** what clients can query. There's no
|
|
54
|
+
over-permit gap, and no need for Apollo's JS tooling (`@apollo/federation-internals`)
|
|
55
|
+
to subtract the API schema first; feed weaver the raw supergraph and you get the
|
|
56
|
+
router's contract. (This is a pure SDL rewrite at load time — see
|
|
57
|
+
[`SchemaLoader`](../lib/graph_weaver/schema_loader.rb).)
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
Other federation directives hide nothing from the schema, so weaver keeps the
|
|
60
|
+
field and ignores the directive: `@requiresScopes` / `@policy` / `@authenticated`
|
|
61
|
+
enforce access at runtime; `@tag` / `@requires` / `@provides` / `@external` are
|
|
62
|
+
metadata.
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
grep -c '@inaccessible' supergraph.graphql
|
|
57
|
-
```
|
|
64
|
+
## Which schema to feed
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
directly.
|
|
66
|
+
Any of these — they all produce the same generated code:
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
## The exact contract: the API schema
|
|
68
|
-
|
|
69
|
-
To make codegen match the router precisely (no over-permit), feed weaver the
|
|
70
|
-
**API schema** instead of the raw supergraph. Deriving it is a *subtraction*
|
|
71
|
-
from the composed graph — not composition — done with Apollo's tooling:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
rover supergraph compose --config supergraph.yaml > supergraph.graphql
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
```js
|
|
78
|
-
// then, in JS, strip to the API schema:
|
|
79
|
-
import { Supergraph } from '@apollo/federation-internals';
|
|
80
|
-
import { printSchema } from 'graphql';
|
|
81
|
-
|
|
82
|
-
const api = Supergraph.fromString(supergraphSdl).apiSchema().toGraphQLJSSchema();
|
|
83
|
-
process.stdout.write(printSchema(api)); // api.graphql — feed this to weaver
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Check the API SDL in (many teams already emit it in CI) and point weaver at it:
|
|
87
|
-
codegen then validates against exactly what clients can query, with no live
|
|
88
|
-
gateway involved.
|
|
89
|
-
|
|
90
|
-
## Which to use
|
|
91
|
-
|
|
92
|
-
- **No `@inaccessible`** → point weaver at the supergraph and move on.
|
|
93
|
-
- **Uses `@inaccessible`, and you want codegen to catch hidden-field mistakes** →
|
|
94
|
-
feed the derived API schema (above), or introspect the live router.
|
|
68
|
+
- **The supergraph SDL** — weaver strips the `@join__*`/`@link` machinery and
|
|
69
|
+
derives the API schema (removing `@inaccessible`). The common case.
|
|
70
|
+
- **The API schema SDL** — already subtracted (e.g. emitted in your CI); loads
|
|
71
|
+
as an ordinary schema.
|
|
72
|
+
- **The live router** — introspect it; it already serves the API schema.
|
|
95
73
|
|
|
96
74
|
A large real supergraph carries more constructs than a toy one (interface
|
|
97
75
|
objects via `@join__type(isInterfaceObject:)`, `@join__unionMember`, enum join
|
|
98
|
-
directives). The
|
|
99
|
-
your actual composed schema plus a couple of representative
|
|
100
|
-
relying on it.
|
|
76
|
+
directives). The stripping holds across them, but the honest check is to run
|
|
77
|
+
codegen against your actual composed schema plus a couple of representative
|
|
78
|
+
queries before relying on it.
|
data/docs/generated_modules.md
CHANGED
|
@@ -210,9 +210,8 @@ emitted **once per schema** — one file per type under
|
|
|
210
210
|
named from the output path: the conventional layout gets
|
|
211
211
|
`GraphQLInputs`, while a multi-schema layout names each schema's module
|
|
212
212
|
after its directory (`app/graphql/github/generated` → `GithubInputs`).
|
|
213
|
-
Override globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
-
`generate!(inputs_module:)
|
|
215
|
-
`generate!(shared_inputs: false)`. Per-type files keep schema drift
|
|
213
|
+
Override the module name globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
+
with `generate!(inputs_module:)`. Per-type files keep schema drift
|
|
216
215
|
reviewable: a migration diffs exactly the types it touched, and types
|
|
217
216
|
the schema drops are pruned on regeneration (`verify` flags strays).
|
|
218
217
|
Query modules alias what they touch,
|
|
@@ -254,7 +253,11 @@ mod.execute!(where:)
|
|
|
254
253
|
interface-level fields only → one shared struct; a single `... on X`
|
|
255
254
|
condition and nothing else → `X`'s struct, always nilable — a
|
|
256
255
|
non-matching runtime type comes back as `nil`, so narrowing doubles as
|
|
257
|
-
filtering.
|
|
256
|
+
filtering. When a whole union field is selected as one named *shared*
|
|
257
|
+
fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
|
|
258
|
+
`GraphQLUnions` module and each query aliases it — so the same union is one
|
|
259
|
+
Ruby type family across queries, not a fresh dispatch module per query. Like
|
|
260
|
+
shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
258
261
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
259
262
|
the response regardless of schema nullability, so its generated type is
|
|
260
263
|
always nilable.
|
|
@@ -267,6 +270,39 @@ mode) the struct raises a NoMethodError naming the prop that does exist —
|
|
|
267
270
|
`use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
|
|
268
271
|
a near-miss typo in either casing.
|
|
269
272
|
|
|
273
|
+
### Consuming a union — dispatch on the class, not `__typename`
|
|
274
|
+
|
|
275
|
+
`from_h` already reads `__typename` off the wire and builds the right member
|
|
276
|
+
struct, so what you hold is a real `Book` or `Disc`, not a tag. Branch on the
|
|
277
|
+
class and let Sorbet do the rest:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
items.each do |item| # item : T.any(Result::Item::Book, Result::Item::Disc)
|
|
281
|
+
case item
|
|
282
|
+
when Result::Item::Book then item.title # narrowed to Book — .title is available
|
|
283
|
+
when Result::Item::Disc then item.runtime # narrowed to Disc — .runtime is available
|
|
284
|
+
else T.absurd(item)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Two things a `case item.__typename` on the string can't give you. `when Book`
|
|
290
|
+
*narrows*: inside the branch `item` is statically a `Book`, so its fields
|
|
291
|
+
typecheck (a `Disc` field would be a compile error) — a string value narrows
|
|
292
|
+
nothing. And after every member, the `T.any` is exhausted, so `T.absurd` asserts
|
|
293
|
+
the `else` is unreachable: add a member to the union, regenerate, and the
|
|
294
|
+
`T.absurd` stops compiling until you handle it. Dispatching on the string tag
|
|
295
|
+
gets you neither — mistakes and schema growth fall through to a runtime raise.
|
|
296
|
+
|
|
297
|
+
`__typename` is still there as a plain `String` if you want the raw tag, but you
|
|
298
|
+
rarely need it to dispatch. Its one real use is the case the class can't cover:
|
|
299
|
+
two *differently-selected* occurrences of the same union are distinct type
|
|
300
|
+
families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
|
|
301
|
+
written for one won't span the other. To hold "the same union" as one type
|
|
302
|
+
across queries, select it through a shared fragment
|
|
303
|
+
([shared unions](#selections)); if all you have is the bare tag, `__typename` is
|
|
304
|
+
the common denominator (but an unchecked one).
|
|
305
|
+
|
|
270
306
|
## Naming
|
|
271
307
|
|
|
272
308
|
Module names derive from the operation name (`query GetPerson` → `GetPerson`);
|
data/docs/getting_started.md
CHANGED
|
@@ -86,6 +86,51 @@ reviewed like any other code — and never edited by hand.
|
|
|
86
86
|
PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Shared fragments
|
|
90
|
+
|
|
91
|
+
Define reusable fragments once and spread them from any query:
|
|
92
|
+
|
|
93
|
+
```graphql
|
|
94
|
+
# app/graphql/fragments/person_fields.graphql
|
|
95
|
+
fragment PersonFields on Person { name birthday }
|
|
96
|
+
|
|
97
|
+
# app/graphql/queries/person.graphql
|
|
98
|
+
query($id: ID!) { person(id: $id) { ...PersonFields } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each query inlines only the fragments it (transitively) spreads, so the sent
|
|
102
|
+
`QUERY` stays self-contained — the server never needs your fragment library.
|
|
103
|
+
Fragment files hold only fragments (no operations), and names are unique across
|
|
104
|
+
them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
|
|
105
|
+
default `app/graphql/fragments`).
|
|
106
|
+
|
|
107
|
+
### Shared unions
|
|
108
|
+
|
|
109
|
+
When a shared fragment *is* the whole selection on a union field, its type is
|
|
110
|
+
hoisted once into a `GraphQLUnions` module and every query that spreads it
|
|
111
|
+
aliases the same type — so a `union` selected across many queries becomes one
|
|
112
|
+
Ruby type family, and you write one exhaustive `case … when … T.absurd` that
|
|
113
|
+
works everywhere:
|
|
114
|
+
|
|
115
|
+
```graphql
|
|
116
|
+
# app/graphql/fragments/feed_item.graphql
|
|
117
|
+
fragment FeedItemFields on FeedItem {
|
|
118
|
+
__typename
|
|
119
|
+
... on Post { title }
|
|
120
|
+
... on Photo { url }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# any query
|
|
124
|
+
query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Hoisting is what the shared fragment buys you — there's no flag. It triggers
|
|
128
|
+
only when the union field's selection is exactly that one spread (mix in other
|
|
129
|
+
fields, or shadow the fragment with a query-local one of the same name, and the
|
|
130
|
+
union stays inlined in that query). Named like the inputs module from the output
|
|
131
|
+
path (`GraphQLUnions`, or `GithubUnions` in a multi-schema layout); override
|
|
132
|
+
with `GraphWeaver.unions_module=`.
|
|
133
|
+
|
|
89
134
|
## 6. Test against fakes
|
|
90
135
|
|
|
91
136
|
```ruby
|
|
@@ -127,6 +172,13 @@ generated files — dynamic `parse` is invisible to `srb tc`. Everything
|
|
|
127
172
|
works without Sorbet; codegen plus Sorbet is what moves type errors from
|
|
128
173
|
runtime to CI.
|
|
129
174
|
|
|
175
|
+
If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
|
|
176
|
+
per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
177
|
+
`Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
|
|
178
|
+
generation time and skips the `extend`; override with
|
|
179
|
+
`GraphWeaver.extend_t_sig = true`/`false`. (Generated code is machine-generated
|
|
180
|
+
and marked "do not edit," so excluding `generated/**` from rubocop is also fine.)
|
|
181
|
+
|
|
130
182
|
## Not Rails?
|
|
131
183
|
|
|
132
184
|
Everything above works the same, minus the Railtie conveniences: add
|
data/docs/scalars.md
CHANGED
|
@@ -14,6 +14,19 @@ them: `client.register_scalar(...)` overlays the global registry for that
|
|
|
14
14
|
client's generation only — so two servers can disagree about what a
|
|
15
15
|
`DateTime` is, and neither leaks into the other.
|
|
16
16
|
|
|
17
|
+
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
18
|
+
that one field — so the same scalar can deserialize as different Ruby types
|
|
19
|
+
across fields:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
|
|
23
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A field override wins over the scalar-name registration; both stack the same
|
|
27
|
+
global-then-client way. (GraphQL names can't contain `.`, so the coordinate is
|
|
28
|
+
unambiguous — and it's validated against the schema, so a typo'd field raises.)
|
|
29
|
+
|
|
17
30
|
Pass a real class as `type:` and the cast/serialize are **inferred** from it by
|
|
18
31
|
probing the deserialize side and pairing its serializer:
|
|
19
32
|
|
|
@@ -161,12 +174,18 @@ pet.display_name # => "Shelby 🦴"
|
|
|
161
174
|
pet.name # => "Shelby" — the wire value stays honest
|
|
162
175
|
```
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
177
|
+
The methods live on the struct, so they see its wire fields at runtime and
|
|
178
|
+
fakes/cassettes get the behavior automatically; registrations are additive
|
|
179
|
+
(global plus client-scoped stack). One caveat on *static* typing, though:
|
|
180
|
+
`srb tc` checks a mixin's method bodies in the module's own scope, not the
|
|
181
|
+
including struct's — so a helper that reads a wire field (`name`, `birthday`)
|
|
182
|
+
doesn't resolve it and fails with "method does not exist on the module." Write
|
|
183
|
+
such a helper at `# typed: false`, or reach the field through `T.unsafe(self)`
|
|
184
|
+
— either way its body isn't statically checked against the selection. (Sorbet's
|
|
185
|
+
`requires_ancestor` is the escape in principle, but it needs an experimental
|
|
186
|
+
flag and a concrete ancestor, which a per-query struct isn't.) The only place a
|
|
187
|
+
field-reading derivation type-checks natively is *inside* the struct body, where
|
|
188
|
+
the field is in scope — which is codegen's job, not a mixin's.
|
|
170
189
|
|
|
171
190
|
For quick decoration, build the mixin inline — the block is
|
|
172
191
|
`module_eval`'d into a fresh module auto-named under
|
|
@@ -178,6 +197,43 @@ api.extend_type("Pet") do
|
|
|
178
197
|
end
|
|
179
198
|
```
|
|
180
199
|
|
|
181
|
-
Same runtime behavior,
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
Same runtime behavior, less static reach: the block becomes a runtime module
|
|
201
|
+
with no source on disk, so `srb tc` can't see its methods at all — fine in
|
|
202
|
+
dynamic `parse`, but in a checked-in `# typed: strict` file it's an unresolved
|
|
203
|
+
reference. Prefer a named module (and mind the field-access caveat above) where
|
|
204
|
+
static checking matters — complexity on demand.
|
|
205
|
+
|
|
206
|
+
### Flat accessors with `alias:`
|
|
207
|
+
|
|
208
|
+
The one derivation the generator can type for you is a plain projection — a
|
|
209
|
+
selected field, possibly nested, exposed under a flat accessor. `alias:` emits a
|
|
210
|
+
sig'd delegator *into the struct body*, where the field is in scope, so it's
|
|
211
|
+
fully checked (the thing a mixin can't be):
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
|
|
215
|
+
|
|
216
|
+
# generated on the Widget struct:
|
|
217
|
+
# sig { returns(T.nilable(String)) }
|
|
218
|
+
# def tag = meta&.tag
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
So a hand-written value object that only existed to expose `tag` flat over
|
|
222
|
+
`data.dig("meta", "tag")` drops away — the generated struct answers `.tag`
|
|
223
|
+
directly. Forms:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
alias: { tag: "meta.tag" } # explicit accessor name
|
|
227
|
+
alias: "meta.tag" # accessor named after the last segment (`tag`)
|
|
228
|
+
alias: ["meta.tag", "meta.color"] # several at once
|
|
229
|
+
alias: { label: "name", tag: "meta.tag" }
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The path is the Ruby accessor chain (`meta.tag`), typed from the selection: any
|
|
233
|
+
nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
|
|
234
|
+
scalar, enum, or nested struct. It's validated against each query at generation —
|
|
235
|
+
an unselected or misspelled segment (`did you mean 'tag'?`), a path through a
|
|
236
|
+
list, or a name that collides with a real field all fail with a pointed error.
|
|
237
|
+
Registrations stack and are client-scopable, like the mixin forms. For anything
|
|
238
|
+
beyond a passthrough projection — real logic, still typed — reopen the generated
|
|
239
|
+
struct in your own file and add sig'd methods; Sorbet merges the bodies.
|
data/graph_weaver.gemspec
CHANGED
|
@@ -14,7 +14,9 @@ Gem::Specification.new do |s|
|
|
|
14
14
|
|
|
15
15
|
s.required_ruby_version = ">= 3.3"
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# 2.6.7 fills defaulted directive arguments when building from SDL
|
|
18
|
+
# (rmosolgo/graphql-ruby#5659) — needed since we dropped our own patch for it
|
|
19
|
+
s.add_dependency "graphql", ">= 2.6.7"
|
|
18
20
|
s.add_dependency "sorbet-runtime"
|
|
19
21
|
|
|
20
22
|
s.add_development_dependency "apollo-federation" # federation integration subgraphs
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -76,7 +76,8 @@ class GraphWeaver::Client
|
|
|
76
76
|
|
|
77
77
|
# Client-scoped scalar registration: consulted before the global
|
|
78
78
|
# registry when this client generates code, so two clients can map the
|
|
79
|
-
# same scalar name onto different Ruby types.
|
|
79
|
+
# same scalar name onto different Ruby types. A `Type.field` coordinate
|
|
80
|
+
# (e.g. "User.birthday") overrides just that field. Same signature as
|
|
80
81
|
# GraphWeaver.register_scalar.
|
|
81
82
|
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
82
83
|
validate_registration!("scalar", graphql_name.to_s)
|
|
@@ -102,10 +103,11 @@ class GraphWeaver::Client
|
|
|
102
103
|
# struct this client generates from the named GraphQL type — pass
|
|
103
104
|
# modules, or a block to build one inline. Additive with global
|
|
104
105
|
# registrations (see GraphWeaver.extend_type).
|
|
105
|
-
def extend_type(graphql_name, *mixins, requires: nil, &block)
|
|
106
|
+
def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
|
|
106
107
|
validate_registration!("type", graphql_name.to_s)
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
aliases = GraphWeaver::Codegen.take_aliases(kw)
|
|
109
|
+
entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
|
|
110
|
+
GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
# Parse a query (a .graphql path or raw string) into a typed module
|