graph_weaver 0.4.0 → 0.4.6

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: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
4
- data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
3
+ metadata.gz: f01ed46a3a7d500364530c522e28f5b1f70f9db803988f940e487630bca0f10c
4
+ data.tar.gz: ee2e237e936ca60054b6465f62e14714c359235abfab376fb3b14e232ef851f9
5
5
  SHA512:
6
- metadata.gz: 4d496923726cf4296f048f7f60305045aa1bfe042c007bcf10b87d34673bc451af85bf6165187e7f8a7b606dd0176ad54648c0ea504f69cb966d9addc9ba4503
7
- data.tar.gz: ae5b74bd0de58a9f89fbc218e0103ab805c2b62bb5a9172bff6ebd38d890ad4804429789877b7a7c9ce343cb0d24f969fe8eb5785222736c042bc56008a2648a
6
+ metadata.gz: bb3adc8c4d4f3164a34a650fe989c785d45559a78c52b10bc93abe21e4ab26c9fb3892b76694c563cbc62b2e342b2bd49b87cf2f7119afe9439facc8dc837e94
7
+ data.tar.gz: ae4521a62bb818a9d8da0053533c4749a56e021f88c343acd25123b04016176a942da9e824bcb41df32083ae1c28db6a81b32079490c15014940bb016d821be6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,85 @@
1
+ ### v0.4.6 (2026-07-30)
2
+ Bug fixes from a full-library review (all with regression coverage):
3
+ - alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
4
+ (`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
5
+ names/segments are validated as identifiers (were interpolated verbatim,
6
+ allowing injection); `optional:` no longer swallows a reserved-name/collision
7
+ mistake; a real field named `first`/`last` resolves as a field.
8
+ - Shared unions: a hoisted member selecting a mapped enum now emits its
9
+ `<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
10
+ fragment whose name collides with `Result`/`QUERY` is refused.
11
+ - A named interface fragment holding inline `... on X` conditions now dispatches
12
+ instead of silently dropping those fields; fragment cycles raise a clear error
13
+ in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
14
+ - Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
15
+ `errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
16
+ `data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
17
+ `InputError`.
18
+ - Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
19
+ fabricating shapes the generated struct couldn't cast); `fail_at` fires every
20
+ execute; symbol-keyed cassette variables no longer crash on reload; the
21
+ Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
22
+ - Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
23
+ schema raises a pointed error. Client accepts `retries: nil` on a schema
24
+ source; `register_scalar` rejects an anonymous class.
25
+ - FakeClient: an Integer `list_size` now means exactly that length (a Range
26
+ randomizes within it). Codegen rejects two variables that underscore to the
27
+ same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
28
+ silently dropping `headers:`/block when handed a prebuilt connection.
29
+
30
+ ### v0.4.5 (2026-07-30)
31
+ - `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
32
+ element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
33
+ yields `def entity = _entities&.first`, and navigation continues into the
34
+ element (`_entities.first.name`). Typed from the selection: a single inline
35
+ fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
36
+ selection on the union. Selectors are checked against the node shape — `.first`
37
+ on a non-list raises. Cleanly retires the `result._entities&.first&.field`
38
+ boilerplate of single-entity federation `_entities` queries.
39
+ - `extend_type(..., optional: true)` makes its aliases lenient: a query whose
40
+ selection doesn't fit the path omits the accessor instead of failing
41
+ generation. For an alias on a universal type (a `Query` accessor a strict alias
42
+ would force every query to satisfy), or one that only fits some selections.
43
+
44
+ ### v0.4.4 (2026-07-30)
45
+ - Supergraph loading now derives the **API schema**: `@inaccessible` elements
46
+ (present in the federated graph but hidden from what the router serves) are
47
+ removed on load, cascading — a field/argument/union-member/interface
48
+ referencing a removed type goes too, and a type left empty is removed in turn.
49
+ So codegen validates against exactly what clients can query, with no
50
+ over-permitting and no Apollo JS tooling to subtract the API schema first.
51
+ Plain (non-federation) SDL is untouched.
52
+
53
+ ### v0.4.3 (2026-07-30)
54
+ - Federation-aware supergraph loading: `SchemaLoader` (and `Client.new`) now
55
+ load a composed Apollo Federation v2 supergraph SDL directly. When the `@join__*`
56
+ markers are present it strips the composition machinery — the synthetic
57
+ `join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
58
+ application — via an AST rewrite before `from_definition`, so the merged type
59
+ shapes load cleanly with nothing federation-internal leaking into
60
+ `schema.types`. Plain SDL is untouched. A query can now be typed against the
61
+ composed supergraph, not just per-subgraph schema objects.
62
+ - Removed `directive_defaults_patch.rb` (the graphql-ruby monkeypatch); the
63
+ preprocessor supersedes it and `graphql-ruby` fixed the underlying issue. The
64
+ gem now requires `graphql >= 2.6.7`.
65
+
66
+ ### v0.4.2 (2026-07-30)
67
+ - `extend_type` accepts `alias:` — project a selected field (possibly nested)
68
+ onto a flat, typed accessor emitted into the struct body:
69
+ `extend_type("Widget", alias: { tag: "meta.tag" })` generates a sig'd
70
+ `def tag = meta&.tag`. Retires hand-written value objects that only flattened
71
+ a passthrough field. Takes a `{ name => path }` hash, a bare path string
72
+ (accessor named after the last segment), or an array of paths. Typed from the
73
+ selection — a nullable hop makes the accessor nilable and nil-safe; the leaf
74
+ may be a scalar, enum, or nested struct. Validated per query at generation: an
75
+ unselected/misspelled segment (with `did you mean`), a list hop, or a name
76
+ collision raises. Stacks and is client-scopable like the mixin forms.
77
+
78
+ ### v0.4.1 (2026-07-29)
79
+ - Generated `execute!` forwards its kwargs to `execute` via hash shorthand
80
+ (`execute(client, name:, species:)` rather than `name: name, species: species`)
81
+ — cosmetic only. Regenerate to refresh (`verify` flags the drift otherwise).
82
+
1
83
  ### v0.4.0 (2026-07-28)
2
84
  - Shared unions: when a named shared fragment is the whole selection on a union
3
85
  field (`feed { ...FeedItemFields }`), its type is hoisted once into a
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.4.0)
5
- graphql (>= 2)
4
+ graph_weaver (0.4.6)
5
+ graphql (>= 2.6.7)
6
6
  sorbet-runtime
7
7
 
8
8
  GEM
@@ -176,7 +176,7 @@ 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.4.0)
179
+ graph_weaver (0.4.6)
180
180
  graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
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.
@@ -270,6 +270,39 @@ mode) the struct raises a NoMethodError naming the prop that does exist —
270
270
  `use 'name_with_owner'` for the exact wire name, `did you mean ...?` for
271
271
  a near-miss typo in either casing.
272
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
+
273
306
  ## Naming
274
307
 
275
308
  Module names derive from the operation name (`query GetPerson` → `GetPerson`);
data/docs/scalars.md CHANGED
@@ -174,12 +174,18 @@ pet.display_name # => "Shelby 🦴"
174
174
  pet.name # => "Shelby" — the wire value stays honest
175
175
  ```
176
176
 
177
- Because the include is emitted into the generated source, `srb tc` checks
178
- the helpers against each query's actual selection — a helper that calls
179
- `birthday` on a query that never selected it is a **static error**, which
180
- doubles as selection-completeness checking. Registrations are additive
181
- (global plus client-scoped stack), and fakes/cassettes get the behavior
182
- 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.
183
189
 
184
190
  For quick decoration, build the mixin inline — the block is
185
191
  `module_eval`'d into a fresh module auto-named under
@@ -191,6 +197,61 @@ api.extend_type("Pet") do
191
197
  end
192
198
  ```
193
199
 
194
- Same runtime behavior, one caveat: `srb tc` can't see into block-defined
195
- methods, so prefer a named module where static checking matters
196
- 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 selector on a
236
+ non-list, or a name that collides with a real field all fail with a pointed
237
+ error. Registrations stack and are client-scopable, like the mixin forms.
238
+
239
+ A segment can also be `first` or `last` to pick one element out of a list hop —
240
+ always nilable, since the list may be empty. This is what turns an
241
+ `_entities`-style "array that logically holds one thing" into a clean accessor:
242
+
243
+ ```ruby
244
+ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
245
+
246
+ # sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
247
+ # def entity = _entities&.first # (a multi-fragment selection types it as the union)
248
+ ```
249
+
250
+ `optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
251
+ the path just omits the accessor instead of failing generation. Reach for it when
252
+ the alias lives on a universal type like `Query` — where a strict alias would
253
+ force *every* query to select the path — or when it only fits some selections.
254
+
255
+ For anything beyond a passthrough projection — real logic, still typed — reopen
256
+ the generated struct in your own file and add sig'd methods; Sorbet merges the
257
+ 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
@@ -35,7 +35,7 @@ class GraphWeaver::Client
35
35
 
36
36
  @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
37
37
  else
38
- if auth || middleware || retries != false
38
+ if auth || middleware || retries
39
39
  raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
40
40
  end
41
41
  if cache || ttl
@@ -103,10 +103,11 @@ class GraphWeaver::Client
103
103
  # struct this client generates from the named GraphQL type — pass
104
104
  # modules, or a block to build one inline. Additive with global
105
105
  # registrations (see GraphWeaver.extend_type).
106
- def extend_type(graphql_name, *mixins, requires: nil, &block)
106
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
107
107
  validate_registration!("type", graphql_name.to_s)
108
- entry = @types[graphql_name.to_s] ||= { mixins: [], requires: [] }
109
- 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)
110
111
  end
111
112
 
112
113
  # Parse a query (a .graphql path or raw string) into a typed module
@@ -194,6 +194,13 @@ class GraphWeaver::Codegen
194
194
  end
195
195
  out << "module #{@module_name}"
196
196
  out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
197
+ # a member selecting a mapped enum (register_enum) reads its module-level
198
+ # <NAME>_FROM_WIRE table — emit those here so from_h resolves them, the
199
+ # same way emit_variable_types does for the query module
200
+ @mapped_enums.each_value do |mapped|
201
+ emit_mapped_enum(mapped, out, 1)
202
+ out << ""
203
+ end
197
204
  unions.each do |union|
198
205
  emit_union(union, out, 1)
199
206
  out << ""
@@ -349,6 +356,14 @@ class GraphWeaver::Codegen
349
356
  out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
350
357
  out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
351
358
  out << "#{pad} end"
359
+
360
+ # alias delegators (extend_type alias:) — typed accessors that project a
361
+ # selected field onto the struct, next to the honest wire data
362
+ node.aliases.each do |a|
363
+ out << ""
364
+ out << "#{pad} sig { returns(#{a.type}) }"
365
+ out << "#{pad} def #{a.name} = #{a.expr}"
366
+ end
352
367
  out << "#{pad}end"
353
368
  end
354
369
 
@@ -414,7 +429,8 @@ class GraphWeaver::Codegen
414
429
 
415
430
  # execute returns the full envelope; execute! is the strict shortcut for
416
431
  # `execute(...).data!` — the typed result, or a raised QueryError.
417
- forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
432
+ # kwargs forward via hash shorthand (key == value)
433
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
418
434
 
419
435
  if flatten
420
436
  out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
@@ -102,9 +102,64 @@ class GraphWeaver::Codegen
102
102
  # end
103
103
  #
104
104
  # Additive: repeated registrations (and client-scoped ones) stack.
105
- def extend_type(graphql_name, *mixins, requires: nil, &block)
106
- entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
107
- add_type_helpers(entry, graphql_name, mixins, requires, block)
105
+ #
106
+ # alias: projects a (possibly nested) selected field onto a flat, typed
107
+ # accessor on the struct — the one derivation codegen can type itself, so
108
+ # it's emitted into the struct body where the field is in scope:
109
+ #
110
+ # GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
111
+ # GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
112
+ # GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
113
+ #
114
+ # A path segment is a field, or `first`/`last` to pick one element out of a
115
+ # list hop (always nilable): `alias: { entity: "_entities.first" }`.
116
+ #
117
+ # optional: true makes the aliases lenient — a query whose selection doesn't
118
+ # fit the path just omits the accessor instead of failing generation. Use it
119
+ # for a root-type accessor (a Query alias every query would otherwise have to
120
+ # satisfy) or one that only fits some selections.
121
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
122
+ aliases = take_aliases(kw)
123
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
124
+ add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
125
+ end
126
+
127
+ # Pull alias:/optional: out of the keyword rest and normalize; any other
128
+ # keyword is a typo worth flagging rather than silently dropping.
129
+ def take_aliases(kw)
130
+ aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
131
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
132
+ aliases
133
+ end
134
+
135
+ # accessor names and path segments are interpolated verbatim into generated
136
+ # source, so — like module_name — they must be plain identifiers, never
137
+ # arbitrary text that could inject code
138
+ ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
139
+ ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
140
+
141
+ # { accessor => { segments:, optional: } } from a path string (accessor
142
+ # named after the last segment), an array of such, or an { accessor => path }
143
+ # hash. `optional:` marks every alias in this registration as lenient.
144
+ def normalize_aliases(input, optional:)
145
+ pairs = case input
146
+ when nil then []
147
+ when String then [[input.split(".").last, input.split(".")]]
148
+ when Array then input.map { |path| [path.split(".").last, path.split(".")] }
149
+ when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
150
+ else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
151
+ end
152
+ pairs.to_h do |name, segments|
153
+ unless name.to_s.match?(ALIAS_NAME)
154
+ raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
155
+ end
156
+ raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
157
+
158
+ bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
159
+ raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
160
+
161
+ [name, { segments:, optional: }]
162
+ end
108
163
  end
109
164
 
110
165
  def type_registry
@@ -113,11 +168,13 @@ class GraphWeaver::Codegen
113
168
 
114
169
  # shared with Client#extend_type: build/validate the mixins and
115
170
  # append them to a registry entry
116
- def add_type_helpers(entry, graphql_name, mixins, requires, block)
171
+ def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
117
172
  mixins = mixins.dup
118
173
  mixins << helper_module(graphql_name, block) if block
119
174
 
120
- raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
175
+ if mixins.empty? && aliases.empty?
176
+ raise ArgumentError, "pass one or more helper modules, a block, or alias:"
177
+ end
121
178
  mixins.each do |mixin|
122
179
  unless mixin.is_a?(Module) && mixin.name
123
180
  raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
@@ -126,6 +183,7 @@ class GraphWeaver::Codegen
126
183
 
127
184
  entry[:mixins].concat(mixins)
128
185
  entry[:requires].concat(Array(requires))
186
+ (entry[:aliases] ||= {}).merge!(aliases)
129
187
  entry
130
188
  end
131
189
 
@@ -144,16 +144,20 @@ class GraphWeaver::Codegen
144
144
 
145
145
  class ObjectNode < Node
146
146
  Field = Struct.new(:prop, :key, :node)
147
+ # a resolved alias delegator (extend_type alias:): the accessor name, the
148
+ # Ruby path expression it reads (meta&.tag), and its Sorbet return type
149
+ Alias = Struct.new(:name, :expr, :type)
147
150
 
148
151
  attr_reader :class_name, :fields
149
- # the GraphQL type this struct was generated from, and any registered
150
- # helper modules to include (see Codegen.extend_type)
151
- attr_accessor :graphql_type, :mixins
152
+ # the GraphQL type this struct was generated from, any registered helper
153
+ # modules to include, and any resolved alias delegators (see extend_type)
154
+ attr_accessor :graphql_type, :mixins, :aliases
152
155
 
153
156
  def initialize(class_name)
154
157
  @class_name = class_name
155
158
  @fields = []
156
159
  @mixins = []
160
+ @aliases = []
157
161
  end
158
162
 
159
163
  def bare_type = class_name
@@ -123,7 +123,10 @@ class GraphWeaver::Codegen
123
123
 
124
124
  def type_name(type)
125
125
  case type
126
- when Module then type.name
126
+ when Module
127
+ # an anonymous class has no name to emit — it would land as a literal
128
+ # `nil` in generated source
129
+ type.name || raise(ArgumentError, "type: must be a named class/module, got an anonymous one")
127
130
  when String then type
128
131
  else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
129
132
  end