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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e66ac94243bdc7f065534845a722bfd209c8d7b23a3a5fa1eff6041e3f563655
4
- data.tar.gz: 413217dbda71c297abe537dc996b8e8413821365d9e966493bf7301d6351a6c7
3
+ metadata.gz: fac36591b869f046295946094078847ed33a1aa15aa8a2402e6a46f6e17ebaa0
4
+ data.tar.gz: c366d845da86029839f57214ba6216b78f2a870b67873851c2514d3d00aaa78d
5
5
  SHA512:
6
- metadata.gz: 62dab00fca85973dbff0e695dcd9970a554ca98b6a717a375fa7f2cde349b2967672ce541bf108d3b058b54b47ce7d274f6c8f11f069360a59e95fcb86ff8ae5
7
- data.tar.gz: 3a11e52459c3b0158d831c8815ced9c4ca5cd323435ffac4e36a8648e83179dd87763a4570aaa56b240d0d7d77d3b91e43d1706f40112d3eae2fa608950b797c
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.3.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.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.2)
103
- sorbet (0.6.13347)
104
- sorbet-static (= 0.6.13347)
105
- sorbet-runtime (0.6.13347)
106
- sorbet-static (0.6.13347-aarch64-linux)
107
- sorbet-static (0.6.13347-universal-darwin)
108
- sorbet-static (0.6.13347-x86_64-linux)
109
- sorbet-static-and-runtime (0.6.13347)
110
- sorbet (= 0.6.13347)
111
- sorbet-runtime (= 0.6.13347)
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.3.0)
180
- graphql (2.6.6) sha256=7438e2036b571c884377eb1529225470ba959365f26dc47152953c213bc38c31
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.2) sha256=c6459434efe4b948b46477cc2df2faa73ab365f83a33c7c17f81262f4f7f1244
210
- sorbet (0.6.13347) sha256=2830946e6efda8dc732c5d703350bed17f437fbea1ccfbef543a65e33ae445c1
211
- sorbet-runtime (0.6.13347) sha256=54e7221d9f4b63f58aec79f3043752a9ba7810347c6ae0d903531591cc2d5f01
212
- sorbet-static (0.6.13347-aarch64-linux) sha256=9bb60509bf9a8715d93e77cd55f96659575d81a5f47b52bd4f66b3dfe6f2182e
213
- sorbet-static (0.6.13347-universal-darwin) sha256=d12678a45369dba9db724103d8badda995e66a83ae35798f68d804c46a6d3e45
214
- sorbet-static (0.6.13347-x86_64-linux) sha256=5a4803d2ba4fc1964e23f3aa4073b224c345c2a72a5f5317623a4bae7ce2c8da
215
- sorbet-static-and-runtime (0.6.13347) sha256=2130e3c20326948a3edd20385b444345cb7327eea9b141f373486e7c087a498d
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. It declares its own `@join__*`/`@link` directives
24
- and `join__*` types, so graphql-ruby builds it; weaver reads field types and
25
- args, not directives, so the join plumbing is ignored; and because codegen is
26
- **query-driven**, the federation-internal types generate no code unless a query
27
- names them (none would). Field shapes nullability, args, enums, inputs are
28
- identical to the API schema, so your generated structs are correct.
29
-
30
- The one caveat: a supergraph is a **superset** of the API schema, so weaver can
31
- only ever **over-permit** it will never reject a valid query, but it won't
32
- flag one that selects a field the API *hides*. In practice that's exactly one
33
- thing: `@inaccessible`.
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
- Reading the raw supergraph, weaver would let you select an `@inaccessible`
48
- field but the router serves the API schema and rejects it at runtime (a
49
- "field doesn't exist" error, surfaced as [`QueryError` / `schema_stale?`](errors.md)).
50
- So the gap is narrow (you'd have to query a hidden field on purpose) and fails
51
- loudly, not silently.
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
- One grep says whether it affects you at all:
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
- ```sh
56
- grep -c '@inaccessible' supergraph.graphql
57
- ```
64
+ ## Which schema to feed
58
65
 
59
- Zero, and the supergraph *is* the API schema for weaver's purposes — feed it
60
- directly.
66
+ Any of these they all produce the same generated code:
61
67
 
62
- Other federation directives hide nothing from the schema:
63
- `@requiresScopes` / `@policy` / `@authenticated` keep the field and enforce
64
- access at runtime; `@tag` / `@requires` / `@provides` / `@external` are metadata
65
- weaver ignores.
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 mechanism holds, but the honest check is to run codegen against
99
- your actual composed schema plus a couple of representative queries before
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.
@@ -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 with
214
- `generate!(inputs_module:)`; opt out with
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`);
@@ -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
- Because the include is emitted into the generated source, `srb tc` checks
165
- the helpers against each query's actual selection — a helper that calls
166
- `birthday` on a query that never selected it is a **static error**, which
167
- doubles as selection-completeness checking. Registrations are additive
168
- (global plus client-scoped stack), and fakes/cassettes get the behavior
169
- automatically since it lives on the struct.
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, one caveat: `srb tc` can't see into block-defined
182
- methods, so prefer a named module where static checking matters
183
- complexity on demand.
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
- s.add_dependency "graphql", ">= 2"
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
@@ -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. Same signature as
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
- entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
108
- GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
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