graph_weaver 0.2.2 → 0.4.0

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: 7673fd6efc0459bdb3097e73e1c3f8fe265910bfdaf349bf7a358adc1ece1830
4
- data.tar.gz: f5ce9f4c484e1fb8f5eb074eaaac502ca62fb3ecf6ab90d9157a2a055e88c3b3
3
+ metadata.gz: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
4
+ data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
5
5
  SHA512:
6
- metadata.gz: a75dd5a723dd52108e2f449bd1c5f0e1dc9a71065106614c57854f318024f700e16596cd4764cf9768f02d536cc040bd0554bdd75762eeba23c09b71a7051cf5
7
- data.tar.gz: 377c07a20d2cf47873425c72ad84709c36555396fea1a2102c7bdc3f79e07e8e823ed9639238aecac8066f352e65599a10ae15a71013a7de3a0a4d3675bf169c
6
+ metadata.gz: 4d496923726cf4296f048f7f60305045aa1bfe042c007bcf10b87d34673bc451af85bf6165187e7f8a7b606dd0176ad54648c0ea504f69cb966d9addc9ba4503
7
+ data.tar.gz: ae5b74bd0de58a9f89fbc218e0103ab805c2b62bb5a9172bff6ebd38d890ad4804429789877b7a7c9ce343cb0d24f969fe8eb5785222736c042bc56008a2648a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,68 @@
1
+ ### v0.4.0 (2026-07-28)
2
+ - Shared unions: when a named shared fragment is the whole selection on a union
3
+ field (`feed { ...FeedItemFields }`), its type is hoisted once into a
4
+ `GraphQLUnions` module and every query that spreads it aliases the same type —
5
+ so a union selected across many queries is one Ruby type family (one
6
+ exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
7
+ the shared fragment is the opt-in. Triggers only for an exact lone spread;
8
+ mixing other fields, or shadowing with a query-local fragment, keeps the union
9
+ inlined. Module name derives from the output path (override with
10
+ `GraphWeaver.unions_module=`); dynamic `parse` still inlines.
11
+ - Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
12
+ Directory-based generation always emits each input type once into a shared
13
+ module — the self-contained-module opt-out added complexity for little value.
14
+ Single-query `parse` / `Codegen.generate` still inline their types (there's
15
+ no set to share against). Only affects callers who passed
16
+ `shared_inputs: false`.
17
+
18
+ ### v0.3.4 (2026-07-29)
19
+ - Shared fragments: define reusable named fragments once (default
20
+ `app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
21
+ spread them from any query. Each query inlines only the fragments it
22
+ transitively spreads, so the sent query stays self-contained. Fragment files
23
+ hold only fragments; names are unique across them. Works in `generate!` and
24
+ dynamic `parse`.
25
+
26
+ ### v0.3.3 (2026-07-29)
27
+ - Union member-type dedup: a union selected more than once on a struct now
28
+ collapses to one Ruby type family instead of a distinct per-field family with
29
+ identical members — so a consumer gets a single exhaustive
30
+ `case … T.absurd` across every field of that union. Structurally different
31
+ selections stay distinct types. (First cut: same-struct siblings; regenerate
32
+ checked-in modules to pick it up.)
33
+
34
+ ### v0.3.2 (2026-07-29)
35
+ - `register_scalar` accepts a `Type.field` coordinate to override how one
36
+ field's scalar deserializes — so the same scalar can be different Ruby types
37
+ across fields (`register_scalar("User.birthday", Date)` while
38
+ `ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
39
+ scalar-name registration; both stack global-then-client. The coordinate is
40
+ validated against the schema (a typo'd or non-scalar field raises). Same
41
+ method, same signature — a `.` in the name selects the field form.
42
+
43
+ ### v0.3.1 (2026-07-28)
44
+ - `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
45
+ `extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
46
+ (`class Module; include T::Sig`) and skips the now-redundant `extend` — so
47
+ generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
48
+ Force with `true`/`false`; `false` requires the global include.
49
+
50
+ ### v0.3.0 (2026-07-28)
51
+ - Renamed `register_type` to `extend_type` to disambiguate intent: it
52
+ *decorates* a generated struct with mixin modules/helpers — it doesn't
53
+ define or replace a type's deserialization (that's `register_scalar` /
54
+ `register_enum`, for leaf types, whose Ruby shape is fixed; a composite's
55
+ shape is per-query, so there's nothing fixed to replace). No deprecation —
56
+ the old name is dropped.
57
+ - Invalid query input now raises `GraphWeaver::InputError` (under the
58
+ `GraphWeaver::Error` umbrella) instead of a raw `ArgumentError` /
59
+ `KeyError` / sorbet `TypeError`: an unknown or typo'd input key, a
60
+ missing required field, an out-of-range enum, or a wrong-typed field
61
+ when an input object is built from a hash through `coerce`. Carries
62
+ `#field` / `#struct` and a JSON-ready `#to_h` — one rescue point for
63
+ returning a 422 at an API boundary. Top-level *scalar* kwargs still
64
+ fail like any Ruby method call (sorbet `TypeError` / `ArgumentError`).
65
+
1
66
  ### v0.2.2 (2026-07-22)
2
67
  - Generated modules expose from_response / from_response! alongside
3
68
  execute / execute!: deserialize a raw GraphQL response (fetched by any
data/CLAUDE.md ADDED
@@ -0,0 +1,70 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ A typed GraphQL client for Ruby: generates `# typed: strict` Ruby (nested
8
+ `T::Struct`s + a typed `execute`) from your queries, checked against the schema
9
+ at generation time. Sorbet is core to the product.
10
+
11
+ ## Sorbet typing policy — type by value, not for coverage
12
+
13
+ Sorbet being core does **not** mean every file should be `# typed: strict`. Type
14
+ where it pays off in developer experience; leave the rest at `# typed: true`.
15
+
16
+ - **Strict (full sigs) — developer-facing contracts.** The types users touch:
17
+ `response.rb` (the envelope every `execute` returns), the error hierarchy
18
+ (`errors.rb`), and the **generated code** (emitted `# typed: strict`). Concrete
19
+ types here give downstream apps real call-site checking + autocomplete — that's
20
+ the product.
21
+ - **`# typed: true` (loose) — dynamic / boundary internals.** The codegen
22
+ (`codegen.rb`, `codegen/nodes.rb`, `codegen/emit.rb`, `codegen/scalar_type.rb`,
23
+ `codegen/enum_type.rb`) walks graphql-ruby's approximately-typed AST and builds
24
+ modules/strings dynamically; `client.rb` wraps a graphql-ruby schema and a
25
+ duck-typed transport. Strict here is ~all `T.untyped` — paperwork that documents
26
+ shape without catching anything. **Don't promote these to strict.**
27
+ - Rule of thumb: if a sig would be mostly `T.untyped`, it isn't worth writing.
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).
31
+
32
+ ## Design invariants (don't "fix" these)
33
+
34
+ - **The client slot is duck-typed.** A transport, `Retry`, a live graphql-ruby
35
+ schema class, or a test fake all satisfy one contract —
36
+ `execute(query, variables:) => {"data" => ..., "errors" => ...}` — with no
37
+ shared base class. **Don't formalize it as a strict Sorbet interface**: a
38
+ graphql-ruby `Schema` class fits the slot without inheriting anything, and a
39
+ strict interface would exclude it. This is why the transport/client seams stay
40
+ loosely typed.
41
+ - **Codegen is query-driven.** Structs are generated per selection set, only for
42
+ the types a query actually touches — not the whole schema (so extra schema
43
+ types, e.g. federation `join__*`, generate no code).
44
+ - **Leaf codecs vs composite decoration.** `register_scalar` / `register_enum`
45
+ *define/replace* how a leaf deserializes (its Ruby shape is fixed);
46
+ `extend_type` only *decorates* a generated composite struct with mixins — it
47
+ can't replace one, because a composite's shape varies per query. Don't add a
48
+ "replace a composite's deserializer" path.
49
+
50
+ ## Green before commit
51
+
52
+ ```sh
53
+ bundle exec rspec # full suite
54
+ bundle exec srb tc # Sorbet typecheck (CI gates on this too)
55
+ ```
56
+
57
+ Both must pass. Sorbet sigs are runtime-checked by sorbet-runtime, so a wrong
58
+ sig surfaces as an rspec failure, not only a `srb tc` error — a green suite
59
+ validates the sigs against real usage.
60
+
61
+ ## Version bumps
62
+
63
+ Bump `lib/graph_weaver/version.rb` and, in the **same commit**:
64
+
65
+ - update `Gemfile.lock` (the gem pins its own version there; CI runs a frozen
66
+ `bundle install`, which fails at the *setup* step with exit code 16 — before
67
+ tests — if the lock is stale), and
68
+ - add a `CHANGELOG.md` entry.
69
+
70
+ `gem push` (the actual RubyGems release) is a separate, manual step.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.2.2)
4
+ graph_weaver (0.4.0)
5
5
  graphql (>= 2)
6
6
  sorbet-runtime
7
7
 
@@ -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.2.2)
180
- graphql (2.6.6) sha256=7438e2036b571c884377eb1529225470ba959365f26dc47152953c213bc38c31
179
+ graph_weaver (0.4.0)
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/README.md CHANGED
@@ -113,6 +113,8 @@ api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
113
113
  `@skip`/`@include`, naming, clients, dynamic mode
114
114
  - **[Against a real API](docs/real_world.md)** — the exploratory tour:
115
115
  introspect a live endpoint (GitHub end to end), dynamic mode, schema caching
116
+ - **[Federation](docs/federation.md)** — Apollo Federation: supergraph vs API
117
+ schema, feeding weaver a composed graph, the `@inaccessible` caveat
116
118
  - **[Transports](docs/transports.md)** — clients, the execute contract,
117
119
  Faraday, retries and backoff
118
120
  - **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
data/docs/errors.md CHANGED
@@ -31,6 +31,7 @@ it failed:
31
31
  | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
32
32
  | `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
33
33
  | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
34
+ | `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field — `#field`, `#struct` |
34
35
  | `ValidationError` | build time: the query didn't validate against the schema |
35
36
 
36
37
  ```ruby
@@ -64,11 +65,30 @@ Defaults match the rescue block above: transport failures always retry,
64
65
  override with `retry_if:`), and GraphQL-level codes only when listed in
65
66
  `retry_codes:`. Exhausting `tries:` re-raises the last error.
66
67
 
67
- Two deliberate exceptions live *outside* the hierarchy, because typed
68
- kwargs should fail like any Ruby method call: a wrong-typed variable
69
- raises sorbet-runtime's `TypeError` ("Parameter 'page': Expected type
70
- T.nilable(Integer), got type String"), and a missing required variable
71
- raises a plain `ArgumentError` ("missing keyword: :id").
68
+ **Top-level scalar variables** fail like any Ruby method call, *outside* the
69
+ hierarchy on purpose passing the wrong Ruby type for a scalar kwarg is a
70
+ programming bug, not caller input: a wrong-typed one raises sorbet-runtime's
71
+ `TypeError` ("Parameter 'page': Expected type T.nilable(Integer), got type
72
+ String"), a missing required one a plain `ArgumentError` ("missing keyword: :id").
73
+
74
+ **Input-object variables** are the caller-input case, so they're *inside* the
75
+ hierarchy. When you pass an input object as a hash (or struct) it's built
76
+ through the generated `coerce`, and any problem there — an unknown/typo'd key, a
77
+ missing required field, an out-of-range enum, a wrong-typed field — raises
78
+ `GraphWeaver::InputError`. That's one rescue point for turning invalid input
79
+ into a 422:
80
+
81
+ ```ruby
82
+ rescue GraphWeaver::InputError => e
83
+ render json: e.to_h, status: :unprocessable_entity
84
+ # { "error" => "GraphWeaver::InputError",
85
+ # "message" => "unknown key(s) for …Input: staus (did you mean 'status'?)",
86
+ # "field" => "staus", "struct" => "…Input" }
87
+ end
88
+ ```
89
+
90
+ A nested filter reports the innermost input type, so the error points at the
91
+ input that actually held the bad field.
72
92
 
73
93
  Business/validation failures returned *as data* (Shopify-style `userErrors { field
74
94
  message code }`) aren't errors here — they're just fields you selected, so they
@@ -0,0 +1,100 @@
1
+ # Federation
2
+
3
+ GraphWeaver generates a client for the **whole** graph, so with Apollo
4
+ Federation you point it at the *composed* schema — the supergraph or the API
5
+ schema — not at individual subgraph files.
6
+
7
+ ## The schema you feed it
8
+
9
+ Three artifacts, easy to mix up:
10
+
11
+ | Artifact | What it is | Feed to weaver? |
12
+ |----------|------------|-----------------|
13
+ | **Subgraph SDL** | one service's `.graphql`, with `@key`/`@shareable`/`extend schema @link` | No — it's a fragment of the graph, and its federation directives are declared elsewhere (imported via `@link`), so plain SDL loading rejects them |
14
+ | **Supergraph** | the composed graph, annotated with `@join__*`/`@link` machinery | Yes — self-contained (see below) |
15
+ | **API schema** | the supergraph with federation internals stripped — exactly what the router serves and what an introspection query returns | Yes — the exact client contract |
16
+
17
+ `SchemaLoader.load` (and `Client.new(path_or_sdl)`) take the supergraph or the
18
+ API schema as an SDL file or introspection dump; or point weaver at the router
19
+ URL to introspect the API schema live.
20
+
21
+ ## Pointing weaver at a supergraph
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`.
34
+
35
+ ### `@inaccessible`
36
+
37
+ A federation-v2 directive marking an element as *present in the federated graph
38
+ but removed from the public API schema*. Its common use is safely rolling out a
39
+ change to a **shared type**: add the field to one subgraph marked
40
+ `@inaccessible` (so composition doesn't require every subgraph to have it yet),
41
+ roll it out to the rest, then drop the directive to publish it. (Apollo
42
+ contracts also pair `@tag` + `@inaccessible` to build filtered API variants.)
43
+ It's a fed-v2 feature — common in mature, multi-team graphs with lots of shared
44
+ types, rare in small or young ones, and targeted where present (a handful of
45
+ elements, not every field).
46
+
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.
52
+
53
+ One grep says whether it affects you at all:
54
+
55
+ ```sh
56
+ grep -c '@inaccessible' supergraph.graphql
57
+ ```
58
+
59
+ Zero, and the supergraph *is* the API schema for weaver's purposes — feed it
60
+ directly.
61
+
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.
95
+
96
+ A large real supergraph carries more constructs than a toy one (interface
97
+ 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.
@@ -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.
@@ -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
 
@@ -155,7 +168,7 @@ module PetHelpers
155
168
  def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
156
169
  end
157
170
 
158
- GraphWeaver.register_type("Pet", PetHelpers) # or api.register_type(...)
171
+ GraphWeaver.extend_type("Pet", PetHelpers) # or api.extend_type(...)
159
172
 
160
173
  pet.display_name # => "Shelby 🦴"
161
174
  pet.name # => "Shelby" — the wire value stays honest
@@ -173,7 +186,7 @@ For quick decoration, build the mixin inline — the block is
173
186
  `GraphWeaver::TypeHelpers` so generated files can reference it:
174
187
 
175
188
  ```ruby
176
- api.register_type("Pet") do
189
+ api.extend_type("Pet") do
177
190
  def display_name = "#{name} 🐶"
178
191
  end
179
192
  ```
@@ -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)
@@ -101,8 +102,8 @@ class GraphWeaver::Client
101
102
  # Client-scoped type helpers: include app-owned modules into every
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
- # registrations (see GraphWeaver.register_type).
105
- def register_type(graphql_name, *mixins, requires: nil, &block)
105
+ # registrations (see GraphWeaver.extend_type).
106
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
106
107
  validate_registration!("type", graphql_name.to_s)
107
108
  entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
108
109
  GraphWeaver::Codegen.add_type_helpers(entry, graphql_name, mixins, requires, block)
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
115
115
  names.uniq
116
116
  end
117
117
 
118
- def emit_shared_aliases(out, names)
118
+ def emit_shared_aliases(out, names, namespace = @inputs_namespace)
119
119
  return if names.empty?
120
120
 
121
- names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
121
+ names.each { |name| out << " #{name} = #{namespace}::#{name}" }
122
122
  out << ""
123
123
  end
124
124
 
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
175
175
  files
176
176
  end
177
177
 
178
+ # The shared unions artifact as a single file: every hoisted union as a
179
+ # <module_name>::<Name> module. Unions don't cross-reference (each is a
180
+ # self-contained fragment), so there's no need for per-type files or the
181
+ # forward declarations recursive input types require.
182
+ def emit_unions_file(unions)
183
+ out = []
184
+ out << "# typed: strict"
185
+ out << "# frozen_string_literal: true"
186
+ out << ""
187
+ out << "# Generated by GraphWeaver — do not edit. Shared union types for this"
188
+ out << "# schema (named fragments on union fields); query modules alias what they use."
189
+ out << ""
190
+ requires = @requires.uniq.sort
191
+ if requires.any?
192
+ requires.each { |req| out << "require #{req.inspect}" }
193
+ out << ""
194
+ end
195
+ out << "module #{@module_name}"
196
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
197
+ unions.each do |union|
198
+ emit_union(union, out, 1)
199
+ out << ""
200
+ end
201
+ out.pop if out.last == ""
202
+ out << "end"
203
+
204
+ { "unions.rb" => out.join("\n") + "\n" }
205
+ end
206
+
178
207
  # one type per file, wrapped in the namespace so bare sibling
179
208
  # references resolve lexically
180
209
  def inputs_file(files, name)
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
200
229
  def emit_module(root, variables)
201
230
  flatten = flatten_input(variables)
202
231
  aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
232
+ # hoisted unions the result tree references, aliased so <Name>::Type and
233
+ # <Name>.from_h resolve to the shared module
234
+ union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
203
235
 
204
236
  out = []
205
237
  out << "# typed: strict"
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
218
250
  out << "require_relative \"inputs\""
219
251
  out << ""
220
252
  end
253
+ if union_aliases.any?
254
+ out << "require_relative \"unions\""
255
+ out << ""
256
+ end
221
257
  out << "module #{@module_name}"
222
- out << " extend T::Sig"
223
- out << ""
258
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
224
259
  # a GraphQL block string could contain a bare GRAPHQL line, which
225
260
  # would terminate the heredoc early — pick a delimiter the query
226
261
  # can't collide with
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
235
270
  else
236
271
  emit_variable_types(out)
237
272
  end
273
+ emit_shared_aliases(out, union_aliases, @unions_namespace)
238
274
  emit_nested(root, out, 1)
239
275
  out << ""
240
276
  emit_execute(out, variables, flatten:)
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
282
318
  pad = " " * indent
283
319
 
284
320
  out << "#{pad}class #{node.class_name} < T::Struct"
285
- out << "#{pad} extend T::Sig"
321
+ out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
286
322
  out << "#{pad} include GraphWeaver::Hints"
287
323
  node.mixins.each do |mixin|
288
324
  out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
289
325
  end
290
326
  out << ""
291
327
 
292
- node.fields.filter_map { |field| field.node.nested }.each do |child|
328
+ # uniq by object identity: deduped sibling unions share one node, so the
329
+ # shared type is emitted once (both fields' consts already reference it).
330
+ node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
293
331
  emit_nested(child, out, indent + 1)
294
332
  out << ""
295
333
  end
@@ -318,8 +356,7 @@ class GraphWeaver::Codegen
318
356
  pad = " " * indent
319
357
 
320
358
  out << "#{pad}module #{node.class_name}"
321
- out << "#{pad} extend T::Sig"
322
- out << ""
359
+ out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
323
360
 
324
361
  node.members.each_value do |member|
325
362
  emit_object(member, out, indent + 1)
@@ -346,8 +383,7 @@ class GraphWeaver::Codegen
346
383
  out << " @client = T.let(nil, T.untyped)"
347
384
  out << ""
348
385
  out << " class << self"
349
- out << " extend T::Sig"
350
- out << ""
386
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
351
387
  out << " sig { params(client: T.untyped).void }"
352
388
  out << " attr_writer :client"
353
389
  out << ""