graph_weaver 0.4.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: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
4
- data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
3
+ metadata.gz: fac36591b869f046295946094078847ed33a1aa15aa8a2402e6a46f6e17ebaa0
4
+ data.tar.gz: c366d845da86029839f57214ba6216b78f2a870b67873851c2514d3d00aaa78d
5
5
  SHA512:
6
- metadata.gz: 4d496923726cf4296f048f7f60305045aa1bfe042c007bcf10b87d34673bc451af85bf6165187e7f8a7b606dd0176ad54648c0ea504f69cb966d9addc9ba4503
7
- data.tar.gz: ae5b74bd0de58a9f89fbc218e0103ab805c2b62bb5a9172bff6ebd38d890ad4804429789877b7a7c9ce343cb0d24f969fe8eb5785222736c042bc56008a2648a
6
+ metadata.gz: 37c814f83659a3189407b4c8e4531db4782fc4f38cd0e62df4f8e473a75c713adc3d0bb43de62a51854c1d6f0aeea1195e89848e83bd61c74018a840bfe56924
7
+ data.tar.gz: 787508b524cb027fb31281379f64bc566b1bb23709a79883928005ced38cf478c6c90b62a09d2de66317a6d7415eea77014667a5f543472a6f42d8ad043634de
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
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
+
1
40
  ### v0.4.0 (2026-07-28)
2
41
  - Shared unions: when a named shared fragment is the whole selection on a union
3
42
  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.4)
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.4)
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,43 @@ 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 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
@@ -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
@@ -349,6 +349,14 @@ class GraphWeaver::Codegen
349
349
  out << "#{pad} rescue TypeError, ArgumentError, KeyError => e"
350
350
  out << "#{pad} raise GraphWeaver::TypeError.new(struct: self, error: e)"
351
351
  out << "#{pad} end"
352
+
353
+ # alias delegators (extend_type alias:) — typed accessors that project a
354
+ # selected field onto the struct, next to the honest wire data
355
+ node.aliases.each do |a|
356
+ out << ""
357
+ out << "#{pad} sig { returns(#{a.type}) }"
358
+ out << "#{pad} def #{a.name} = #{a.expr}"
359
+ end
352
360
  out << "#{pad}end"
353
361
  end
354
362
 
@@ -414,7 +422,8 @@ class GraphWeaver::Codegen
414
422
 
415
423
  # execute returns the full envelope; execute! is the strict shortcut for
416
424
  # `execute(...).data!` — the typed result, or a raised QueryError.
417
- forward = (["client"] + params.map { |param| "#{kwarg_name(param)}: #{kwarg_name(param)}" }).join(", ")
425
+ # kwargs forward via hash shorthand (key == value)
426
+ forward = (["client"] + params.map { |param| "#{kwarg_name(param)}:" }).join(", ")
418
427
 
419
428
  if flatten
420
429
  out << " # $#{variables.first.wire}'s fields, flattened into kwargs (single input-object variable)"
@@ -102,9 +102,38 @@ 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
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
114
+ aliases = take_aliases(kw)
115
+ entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
116
+ add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
117
+ end
118
+
119
+ # Pull alias: out of the keyword rest and normalize it; any other keyword
120
+ # is a typo worth flagging rather than silently dropping.
121
+ def take_aliases(kw)
122
+ aliases = normalize_aliases(kw.delete(:alias))
123
+ raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
124
+ aliases
125
+ end
126
+
127
+ # { accessor => [path, segments] } from a path string (accessor named
128
+ # after the last segment), an array of such, or an { accessor => path } hash.
129
+ def normalize_aliases(input)
130
+ case input
131
+ when nil then {}
132
+ when String then { input.split(".").last => input.split(".") }
133
+ when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
134
+ when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
135
+ else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
136
+ end
108
137
  end
109
138
 
110
139
  def type_registry
@@ -113,11 +142,13 @@ class GraphWeaver::Codegen
113
142
 
114
143
  # shared with Client#extend_type: build/validate the mixins and
115
144
  # append them to a registry entry
116
- def add_type_helpers(entry, graphql_name, mixins, requires, block)
145
+ def add_type_helpers(entry, graphql_name, mixins, requires, block, aliases = {})
117
146
  mixins = mixins.dup
118
147
  mixins << helper_module(graphql_name, block) if block
119
148
 
120
- raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
149
+ if mixins.empty? && aliases.empty?
150
+ raise ArgumentError, "pass one or more helper modules, a block, or alias:"
151
+ end
121
152
  mixins.each do |mixin|
122
153
  unless mixin.is_a?(Module) && mixin.name
123
154
  raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
@@ -126,6 +157,7 @@ class GraphWeaver::Codegen
126
157
 
127
158
  entry[:mixins].concat(mixins)
128
159
  entry[:requires].concat(Array(requires))
160
+ (entry[:aliases] ||= {}).merge!(aliases)
129
161
  entry
130
162
  end
131
163
 
@@ -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
@@ -465,9 +465,72 @@ class GraphWeaver::Codegen
465
465
  node.fields << ObjectNode::Field.new(prop, key, child)
466
466
  end
467
467
 
468
+ node.aliases = resolve_aliases(node)
468
469
  node
469
470
  end
470
471
 
472
+ # Resolve each registered alias (extend_type alias:) for this struct's type
473
+ # against its actual selection — path -> a typed delegator emitted into the
474
+ # struct body. Validated here, per query, so an unselected or untraversable
475
+ # path fails at generation with a pointed message.
476
+ def resolve_aliases(node)
477
+ type_aliases(node.graphql_type).map do |name, segments|
478
+ resolve_alias(node, name, segments)
479
+ end
480
+ end
481
+
482
+ # Registered aliases for a GraphQL type: global registry plus this client's
483
+ # overlay (client-scoped wins on a name clash).
484
+ def type_aliases(graphql_name)
485
+ global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
486
+ (global.merge(@types[graphql_name]&.dig(:aliases) || {}))
487
+ end
488
+
489
+ ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
490
+
491
+ # Walk a dotted path (Ruby prop names) through this struct's selected fields,
492
+ # building the delegator expression (`meta&.tag`) and its return type. Any hop
493
+ # is nilable -> the accessor is nilable; a list hop or an unselected segment
494
+ # raises. The leaf may be any node (scalar, enum, nested struct).
495
+ def resolve_alias(node, name, segments)
496
+ if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
497
+ raise GraphWeaver::Error,
498
+ "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
499
+ end
500
+
501
+ current = T.let(node, T.untyped)
502
+ parts = []
503
+ nilable = T.let(false, T::Boolean)
504
+ segments.each_with_index do |seg, i|
505
+ field = current.fields.find { |f| f.prop == seg }
506
+ unless field
507
+ props = current.fields.map(&:prop)
508
+ suggestion = defined?(DidYouMean::SpellChecker) &&
509
+ DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
510
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
511
+ raise GraphWeaver::Error,
512
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
513
+ end
514
+ nilable ||= !field.node.non_null?
515
+
516
+ if i == segments.size - 1
517
+ leaf = field.node.bare_type
518
+ type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
519
+ return ObjectNode::Alias.new(name, (parts << seg).join, type)
520
+ end
521
+
522
+ inner = T.let(field.node, T.untyped)
523
+ inner = inner.of while inner.is_a?(NonNull)
524
+ raise GraphWeaver::Error, "alias #{name.inspect}: cannot traverse list-typed '#{seg}'" if inner.is_a?(List)
525
+ unless inner.is_a?(ObjectNode)
526
+ raise GraphWeaver::Error, "alias #{name.inspect}: '#{seg}' is not an object to traverse into"
527
+ end
528
+
529
+ parts << seg << (field.node.non_null? ? "." : "&.")
530
+ current = inner
531
+ end
532
+ end
533
+
471
534
  # The concrete type conditions a selection mentions (inline fragments
472
535
  # and named spreads), minus conditions naming the abstract type itself.
473
536
  def concrete_conditions(core, selections)
@@ -26,19 +26,151 @@ module GraphWeaver::SchemaLoader
26
26
  raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
27
27
  end
28
28
 
29
- GraphQL::Schema.from_definition(source)
29
+ build_sdl(source)
30
30
  else # a file path
31
31
  case File.extname(source)
32
32
  when ".json"
33
33
  GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
34
34
  when ".graphql", ".gql"
35
- GraphQL::Schema.from_definition(File.read(source))
35
+ build_sdl(File.read(source))
36
36
  else
37
37
  raise ArgumentError, "unsupported schema format: #{source}"
38
38
  end
39
39
  end
40
40
  end
41
41
 
42
+ # Build a schema from SDL, first stripping Apollo Federation composition
43
+ # machinery when the SDL is a composed supergraph — so a supergraph dump
44
+ # (often the only artifact for the merged graph, and what the router
45
+ # actually serves) loads like any schema, with the federation plumbing
46
+ # gone rather than leaked into schema.types. Plain SDL passes through.
47
+ def self.build_sdl(sdl)
48
+ GraphQL::Schema.from_definition(federation_sdl?(sdl) ? strip_federation(sdl) : sdl)
49
+ end
50
+ private_class_method :build_sdl
51
+
52
+ # A composed Fed2 supergraph is marked by @join__* directives (every merged
53
+ # type carries them); a plain schema has none.
54
+ def self.federation_sdl?(sdl)
55
+ sdl.match?(/@join__\w/)
56
+ end
57
+
58
+ FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
59
+ FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
60
+
61
+ # Whether a type/directive name belongs to the federation composition layer.
62
+ def self.federation_name?(name)
63
+ !!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
64
+ end
65
+ private_class_method :federation_name?
66
+
67
+ # Drop the composition machinery from supergraph SDL: the synthetic
68
+ # join__*/link__* type and directive definitions, and every @join__*/@link
69
+ # application on the types that remain. What's left is the merged graph's
70
+ # ordinary type shapes — exactly what codegen reads. Parsing is lenient (it's
71
+ # schema *building* that rejects the join directives), so we parse, filter the
72
+ # AST, and reprint clean SDL for from_definition — no graphql-ruby monkeypatch
73
+ # and no join__* leaking into schema.types.
74
+ def self.strip_federation(sdl)
75
+ doc = GraphQL.parse(sdl)
76
+ defs = remove_inaccessible(doc.definitions)
77
+ .reject { |defn| defn.respond_to?(:name) && federation_name?(defn.name) }
78
+ .map { |defn| strip_federation_directives(defn) }
79
+ GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
80
+ end
81
+
82
+ # Derive the API schema by dropping every element marked @inaccessible —
83
+ # present in the federated graph but hidden from the public API the router
84
+ # serves (its common use is safely rolling out a field on a shared type).
85
+ # Cascades: a field/argument/union-member/implements referencing a removed
86
+ # type goes too, and a type left with no fields/values/members is itself
87
+ # removed — repeated to a fixpoint. So codegen matches exactly what clients
88
+ # can query, without the over-permitting a raw supergraph would allow and
89
+ # without Apollo's JS tooling to subtract the API schema.
90
+ def self.remove_inaccessible(definitions)
91
+ removed = definitions.select { |d| type_definition?(d) && inaccessible?(d) }.map(&:name).to_set
92
+ loop do
93
+ survivors = definitions
94
+ .reject { |d| type_definition?(d) && removed.include?(d.name) }
95
+ .map { |d| prune_inaccessible(d, removed) }
96
+ newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
97
+ return survivors if (newly - removed.to_a).empty?
98
+
99
+ removed.merge(newly)
100
+ end
101
+ end
102
+ private_class_method :remove_inaccessible
103
+
104
+ # A type-system type definition (object/interface/union/enum/input/scalar) —
105
+ # not a directive or schema definition.
106
+ def self.type_definition?(node)
107
+ node.respond_to?(:name) &&
108
+ !node.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) &&
109
+ node.class.name.end_with?("TypeDefinition")
110
+ end
111
+ private_class_method :type_definition?
112
+
113
+ def self.inaccessible?(node)
114
+ node.respond_to?(:directives) && node.directives.any? { |d| d.name == "inaccessible" }
115
+ end
116
+ private_class_method :inaccessible?
117
+
118
+ # Whether pruning left the type with nothing the SDL grammar allows to be
119
+ # empty — a fieldless object/interface/input, a valueless enum, a memberless
120
+ # union — so it must be removed and its references cascaded.
121
+ def self.type_emptied?(node)
122
+ (node.respond_to?(:fields) && node.fields && node.fields.empty?) ||
123
+ (node.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && node.values.empty?) ||
124
+ (node.is_a?(GraphQL::Language::Nodes::UnionTypeDefinition) && node.types.empty?)
125
+ end
126
+ private_class_method :type_emptied?
127
+
128
+ # the unwrapped (through NON_NULL/LIST) type name a field or argument references
129
+ def self.unwrapped_type_name(node)
130
+ type = node.type
131
+ type = type.of_type while type.respond_to?(:of_type)
132
+ type.name
133
+ end
134
+ private_class_method :unwrapped_type_name
135
+
136
+ # Remove @inaccessible children and children referencing a removed type,
137
+ # from a type's fields (and their arguments), enum values, union members,
138
+ # and implemented interfaces.
139
+ def self.prune_inaccessible(node, removed)
140
+ gone = lambda do |child|
141
+ inaccessible?(child) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
142
+ end
143
+
144
+ changes = {}
145
+ if node.respond_to?(:fields) && node.fields
146
+ changes[:fields] = node.fields.reject(&gone).map do |field|
147
+ args = field.respond_to?(:arguments) && field.arguments
148
+ args && args.any? ? field.merge(arguments: args.reject(&gone)) : field
149
+ end
150
+ end
151
+ changes[:values] = node.values.reject(&gone) if node.respond_to?(:values) && node.values
152
+ changes[:types] = node.types.reject { |t| removed.include?(t.name) } if node.respond_to?(:types) && node.types
153
+ if node.respond_to?(:interfaces) && node.interfaces
154
+ changes[:interfaces] = node.interfaces.reject { |i| removed.include?(i.name) }
155
+ end
156
+ changes.empty? ? node : node.merge(changes)
157
+ end
158
+ private_class_method :prune_inaccessible
159
+
160
+ # Recursively remove @join__*/@link applications from a definition and its
161
+ # fields, arguments, and enum values.
162
+ def self.strip_federation_directives(node)
163
+ changes = {}
164
+ if node.respond_to?(:directives) && node.directives
165
+ changes[:directives] = node.directives.reject { |d| federation_name?(d.name) }
166
+ end
167
+ changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
168
+ changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
169
+ changes[:values] = node.values.map { |c| strip_federation_directives(c) } if node.respond_to?(:values) && node.values
170
+ changes.empty? ? node : node.merge(changes)
171
+ end
172
+ private_class_method :strip_federation_directives
173
+
42
174
  # Run the standard introspection query through a transport and build a
43
175
  # schema from the result:
44
176
  #
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.4"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -17,9 +17,6 @@ require_relative "graph_weaver/railtie" if defined?(::Rails::Railtie)
17
17
 
18
18
  # opt-in extras:
19
19
  # require "graph_weaver/transport/faraday" # Faraday transport
20
- # require "graph_weaver/directive_defaults_patch" # fix graphql-ruby
21
- # dropping directive argument defaults when loading SDL (needed for
22
- # Apollo supergraph SDL until rmosolgo/graphql-ruby#5659 ships)
23
20
  module GraphWeaver
24
21
  class << self
25
22
  # A client for one GraphQL server — transport, schema, and scoped
@@ -348,13 +345,15 @@ module GraphWeaver
348
345
  end
349
346
 
350
347
  # Include app-owned helper modules into every struct generated from a
351
- # GraphQL type — derived values live as methods next to the honest
352
- # wire data, and srb tc checks them against each query's selection:
348
+ # GraphQL type — derived values live as methods next to the honest wire
349
+ # data, on the struct at runtime (fakes/cassettes included):
353
350
  #
354
351
  # GraphWeaver.extend_type("Pet", PetHelpers)
355
352
  #
356
- # Or build the mixin inline with a block (module_eval'd into an
357
- # auto-named module quick, but invisible to srb tc):
353
+ # A helper that reads a wire field is checked by srb tc in the module's
354
+ # own scope, not the struct's, so the field won't resolve — write it
355
+ # # typed: false or reach it via T.unsafe(self). Or build the mixin inline
356
+ # with a block (module_eval'd into an auto-named module — invisible to srb):
358
357
  #
359
358
  # GraphWeaver.extend_type("Pet") do
360
359
  # def display_name = "#{name} the pet"
@@ -362,8 +361,8 @@ module GraphWeaver
362
361
  #
363
362
  # Additive (repeated and client-scoped registrations stack). Global;
364
363
  # client.extend_type scopes to one client.
365
- def extend_type(graphql_name, *mixins, requires: nil, &block)
366
- Codegen.extend_type(graphql_name, *mixins, requires:, &block)
364
+ def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
365
+ Codegen.extend_type(graphql_name, *mixins, requires:, **kw, &block)
367
366
  end
368
367
 
369
368
  # Restore the built-in scalars, dropping every custom registration —
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '2'
18
+ version: 2.6.7
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '2'
25
+ version: 2.6.7
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: sorbet-runtime
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -253,7 +253,6 @@ files:
253
253
  - lib/graph_weaver/codegen/enum_type.rb
254
254
  - lib/graph_weaver/codegen/nodes.rb
255
255
  - lib/graph_weaver/codegen/scalar_type.rb
256
- - lib/graph_weaver/directive_defaults_patch.rb
257
256
  - lib/graph_weaver/errors.rb
258
257
  - lib/graph_weaver/hints.rb
259
258
  - lib/graph_weaver/inflect.rb
@@ -1,32 +0,0 @@
1
- # typed: false — monkeypatch; `self.class` resolves against the prepended host
2
- # frozen_string_literal: true
3
-
4
- require "graphql"
5
-
6
- # graphql-ruby's SDL builder (BuildFromDefinition#prepare_directives)
7
- # passes only the directive arguments present at the usage site, but
8
- # Directive#initialize validates ALL defined arguments — so a defaulted
9
- # non-null argument (`extension: Boolean! = false`) raises
10
- # InvalidArgumentError when omitted, even though the SDL spec makes it
11
- # optional. Real Apollo supergraph SDL (join v0.3) hits this on every
12
- # @join__type usage.
13
- #
14
- # Fill in the declared defaults before validation. This is what upstream
15
- # should do; present in graphql 2.6.3 (latest at time of writing).
16
- #
17
- # TODO: delete this file (and its requires) once
18
- # https://github.com/rmosolgo/graphql-ruby/pull/5659 lands in a released
19
- # graphql version and the Gemfile picks it up.
20
- module DirectiveDefaultsPatch
21
- def initialize(owner, **arguments)
22
- self.class.all_argument_definitions.each do |arg_defn|
23
- if !arguments.key?(arg_defn.keyword) && arg_defn.default_value?
24
- arguments[arg_defn.keyword] = arg_defn.default_value
25
- end
26
- end
27
-
28
- super(owner, **arguments)
29
- end
30
- end
31
-
32
- GraphQL::Schema::Directive.prepend(DirectiveDefaultsPatch)