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 +4 -4
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +1 -2
- data/Gemfile.lock +3 -3
- data/docs/federation.md +36 -58
- data/docs/generated_modules.md +33 -0
- data/docs/scalars.md +70 -9
- data/graph_weaver.gemspec +3 -1
- data/lib/graph_weaver/client.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +17 -1
- data/lib/graph_weaver/codegen/enum_type.rb +63 -5
- data/lib/graph_weaver/codegen/nodes.rb +7 -3
- data/lib/graph_weaver/codegen/scalar_type.rb +4 -1
- data/lib/graph_weaver/codegen.rb +175 -18
- data/lib/graph_weaver/hints.rb +3 -5
- data/lib/graph_weaver/input_struct.rb +7 -0
- data/lib/graph_weaver/response.rb +8 -1
- data/lib/graph_weaver/schema_loader.rb +162 -2
- data/lib/graph_weaver/selection.rb +20 -3
- data/lib/graph_weaver/testing/cassette.rb +28 -3
- data/lib/graph_weaver/testing/fake_client.rb +18 -12
- data/lib/graph_weaver/transport/faraday.rb +7 -0
- data/lib/graph_weaver/transport.rb +8 -5
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +17 -9
- metadata +3 -4
- data/lib/graph_weaver/directive_defaults_patch.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f01ed46a3a7d500364530c522e28f5b1f70f9db803988f940e487630bca0f10c
|
|
4
|
+
data.tar.gz: ee2e237e936ca60054b6465f62e14714c359235abfab376fb3b14e232ef851f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
A supergraph SDL works as-is. When `SchemaLoader` sees the `@join__*` markers it
|
|
24
|
+
strips the composition machinery before building the schema — the synthetic
|
|
25
|
+
`join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
|
|
26
|
+
application on the real types — so what codegen sees is the merged graph's
|
|
27
|
+
ordinary type shapes, with no federation plumbing leaking into `schema.types`.
|
|
28
|
+
(It's a pure AST rewrite of the SDL; no graphql-ruby monkeypatch, and plain
|
|
29
|
+
schemas pass through untouched.) Field shapes — nullability, args, enums,
|
|
30
|
+
inputs — are identical to the API schema, so your generated structs are correct;
|
|
31
|
+
and because codegen is **query-driven**, nothing federation-internal could
|
|
32
|
+
generate code anyway.
|
|
33
|
+
|
|
34
|
+
A supergraph is a **superset** of the API schema — it carries elements the
|
|
35
|
+
public API hides, marked `@inaccessible`. Weaver removes those on load (below),
|
|
36
|
+
so the schema it generates against is the API schema, not the superset.
|
|
34
37
|
|
|
35
38
|
### `@inaccessible`
|
|
36
39
|
|
|
@@ -44,57 +47,32 @@ It's a fed-v2 feature — common in mature, multi-team graphs with lots of share
|
|
|
44
47
|
types, rare in small or young ones, and targeted where present (a handful of
|
|
45
48
|
elements, not every field).
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
Weaver derives the API schema from the supergraph for you: loading strips every
|
|
51
|
+
`@inaccessible` element and cascades — a field/argument/union-member/interface
|
|
52
|
+
referencing a removed type goes too, and a type left empty is removed in turn —
|
|
53
|
+
so codegen validates against **exactly** what clients can query. There's no
|
|
54
|
+
over-permit gap, and no need for Apollo's JS tooling (`@apollo/federation-internals`)
|
|
55
|
+
to subtract the API schema first; feed weaver the raw supergraph and you get the
|
|
56
|
+
router's contract. (This is a pure SDL rewrite at load time — see
|
|
57
|
+
[`SchemaLoader`](../lib/graph_weaver/schema_loader.rb).)
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
Other federation directives hide nothing from the schema, so weaver keeps the
|
|
60
|
+
field and ignores the directive: `@requiresScopes` / `@policy` / `@authenticated`
|
|
61
|
+
enforce access at runtime; `@tag` / `@requires` / `@provides` / `@external` are
|
|
62
|
+
metadata.
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
grep -c '@inaccessible' supergraph.graphql
|
|
57
|
-
```
|
|
64
|
+
## Which schema to feed
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
directly.
|
|
66
|
+
Any of these — they all produce the same generated code:
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
## The exact contract: the API schema
|
|
68
|
-
|
|
69
|
-
To make codegen match the router precisely (no over-permit), feed weaver the
|
|
70
|
-
**API schema** instead of the raw supergraph. Deriving it is a *subtraction*
|
|
71
|
-
from the composed graph — not composition — done with Apollo's tooling:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
rover supergraph compose --config supergraph.yaml > supergraph.graphql
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
```js
|
|
78
|
-
// then, in JS, strip to the API schema:
|
|
79
|
-
import { Supergraph } from '@apollo/federation-internals';
|
|
80
|
-
import { printSchema } from 'graphql';
|
|
81
|
-
|
|
82
|
-
const api = Supergraph.fromString(supergraphSdl).apiSchema().toGraphQLJSSchema();
|
|
83
|
-
process.stdout.write(printSchema(api)); // api.graphql — feed this to weaver
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Check the API SDL in (many teams already emit it in CI) and point weaver at it:
|
|
87
|
-
codegen then validates against exactly what clients can query, with no live
|
|
88
|
-
gateway involved.
|
|
89
|
-
|
|
90
|
-
## Which to use
|
|
91
|
-
|
|
92
|
-
- **No `@inaccessible`** → point weaver at the supergraph and move on.
|
|
93
|
-
- **Uses `@inaccessible`, and you want codegen to catch hidden-field mistakes** →
|
|
94
|
-
feed the derived API schema (above), or introspect the live router.
|
|
68
|
+
- **The supergraph SDL** — weaver strips the `@join__*`/`@link` machinery and
|
|
69
|
+
derives the API schema (removing `@inaccessible`). The common case.
|
|
70
|
+
- **The API schema SDL** — already subtracted (e.g. emitted in your CI); loads
|
|
71
|
+
as an ordinary schema.
|
|
72
|
+
- **The live router** — introspect it; it already serves the API schema.
|
|
95
73
|
|
|
96
74
|
A large real supergraph carries more constructs than a toy one (interface
|
|
97
75
|
objects via `@join__type(isInterfaceObject:)`, `@join__unionMember`, enum join
|
|
98
|
-
directives). The
|
|
99
|
-
your actual composed schema plus a couple of representative
|
|
100
|
-
relying on it.
|
|
76
|
+
directives). The stripping holds across them, but the honest check is to run
|
|
77
|
+
codegen against your actual composed schema plus a couple of representative
|
|
78
|
+
queries before relying on it.
|
data/docs/generated_modules.md
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
17
|
+
# 2.6.7 fills defaulted directive arguments when building from SDL
|
|
18
|
+
# (rmosolgo/graphql-ruby#5659) — needed since we dropped our own patch for it
|
|
19
|
+
s.add_dependency "graphql", ">= 2.6.7"
|
|
18
20
|
s.add_dependency "sorbet-runtime"
|
|
19
21
|
|
|
20
22
|
s.add_development_dependency "apollo-federation" # federation integration subgraphs
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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,
|
|
150
|
-
#
|
|
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
|
|
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
|