graph_weaver 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/Gemfile.lock +21 -21
- data/docs/generated_modules.md +7 -4
- data/docs/getting_started.md +52 -0
- data/docs/scalars.md +13 -0
- data/lib/graph_weaver/client.rb +2 -1
- data/lib/graph_weaver/codegen/emit.rb +46 -10
- data/lib/graph_weaver/codegen/nodes.rb +20 -0
- data/lib/graph_weaver/codegen.rb +198 -12
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +104 -36
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
|
|
4
|
+
data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d496923726cf4296f048f7f60305045aa1bfe042c007bcf10b87d34673bc451af85bf6165187e7f8a7b606dd0176ad54648c0ea504f69cb966d9addc9ba4503
|
|
7
|
+
data.tar.gz: ae5b74bd0de58a9f89fbc218e0103ab805c2b62bb5a9172bff6ebd38d890ad4804429789877b7a7c9ce343cb0d24f969fe8eb5785222736c042bc56008a2648a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
### v0.4.0 (2026-07-28)
|
|
2
|
+
- Shared unions: when a named shared fragment is the whole selection on a union
|
|
3
|
+
field (`feed { ...FeedItemFields }`), its type is hoisted once into a
|
|
4
|
+
`GraphQLUnions` module and every query that spreads it aliases the same type —
|
|
5
|
+
so a union selected across many queries is one Ruby type family (one
|
|
6
|
+
exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
|
|
7
|
+
the shared fragment is the opt-in. Triggers only for an exact lone spread;
|
|
8
|
+
mixing other fields, or shadowing with a query-local fragment, keeps the union
|
|
9
|
+
inlined. Module name derives from the output path (override with
|
|
10
|
+
`GraphWeaver.unions_module=`); dynamic `parse` still inlines.
|
|
11
|
+
- Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
|
|
12
|
+
Directory-based generation always emits each input type once into a shared
|
|
13
|
+
module — the self-contained-module opt-out added complexity for little value.
|
|
14
|
+
Single-query `parse` / `Codegen.generate` still inline their types (there's
|
|
15
|
+
no set to share against). Only affects callers who passed
|
|
16
|
+
`shared_inputs: false`.
|
|
17
|
+
|
|
18
|
+
### v0.3.4 (2026-07-29)
|
|
19
|
+
- Shared fragments: define reusable named fragments once (default
|
|
20
|
+
`app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
|
|
21
|
+
spread them from any query. Each query inlines only the fragments it
|
|
22
|
+
transitively spreads, so the sent query stays self-contained. Fragment files
|
|
23
|
+
hold only fragments; names are unique across them. Works in `generate!` and
|
|
24
|
+
dynamic `parse`.
|
|
25
|
+
|
|
26
|
+
### v0.3.3 (2026-07-29)
|
|
27
|
+
- Union member-type dedup: a union selected more than once on a struct now
|
|
28
|
+
collapses to one Ruby type family instead of a distinct per-field family with
|
|
29
|
+
identical members — so a consumer gets a single exhaustive
|
|
30
|
+
`case … T.absurd` across every field of that union. Structurally different
|
|
31
|
+
selections stay distinct types. (First cut: same-struct siblings; regenerate
|
|
32
|
+
checked-in modules to pick it up.)
|
|
33
|
+
|
|
34
|
+
### v0.3.2 (2026-07-29)
|
|
35
|
+
- `register_scalar` accepts a `Type.field` coordinate to override how one
|
|
36
|
+
field's scalar deserializes — so the same scalar can be different Ruby types
|
|
37
|
+
across fields (`register_scalar("User.birthday", Date)` while
|
|
38
|
+
`ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
|
|
39
|
+
scalar-name registration; both stack global-then-client. The coordinate is
|
|
40
|
+
validated against the schema (a typo'd or non-scalar field raises). Same
|
|
41
|
+
method, same signature — a `.` in the name selects the field form.
|
|
42
|
+
|
|
43
|
+
### v0.3.1 (2026-07-28)
|
|
44
|
+
- `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
|
|
45
|
+
`extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
|
|
46
|
+
(`class Module; include T::Sig`) and skips the now-redundant `extend` — so
|
|
47
|
+
generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
|
|
48
|
+
Force with `true`/`false`; `false` requires the global include.
|
|
49
|
+
|
|
1
50
|
### v0.3.0 (2026-07-28)
|
|
2
51
|
- Renamed `register_type` to `extend_type` to disambiguate intent: it
|
|
3
52
|
*decorates* a generated struct with mixin modules/helpers — it doesn't
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.
|
|
4
|
+
graph_weaver (0.4.0)
|
|
5
5
|
graphql (>= 2)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -42,7 +42,7 @@ GEM
|
|
|
42
42
|
google-protobuf (4.35.1-x86_64-linux-gnu)
|
|
43
43
|
bigdecimal
|
|
44
44
|
rake (~> 13.3)
|
|
45
|
-
graphql (2.6.
|
|
45
|
+
graphql (2.6.7)
|
|
46
46
|
base64
|
|
47
47
|
fiber-storage
|
|
48
48
|
logger
|
|
@@ -99,16 +99,16 @@ GEM
|
|
|
99
99
|
rubydex (0.2.7-arm64-darwin)
|
|
100
100
|
rubydex (0.2.7-x86_64-darwin)
|
|
101
101
|
rubydex (0.2.7-x86_64-linux)
|
|
102
|
-
simplecov (1.0.
|
|
103
|
-
sorbet (0.6.
|
|
104
|
-
sorbet-static (= 0.6.
|
|
105
|
-
sorbet-runtime (0.6.
|
|
106
|
-
sorbet-static (0.6.
|
|
107
|
-
sorbet-static (0.6.
|
|
108
|
-
sorbet-static (0.6.
|
|
109
|
-
sorbet-static-and-runtime (0.6.
|
|
110
|
-
sorbet (= 0.6.
|
|
111
|
-
sorbet-runtime (= 0.6.
|
|
102
|
+
simplecov (1.0.3)
|
|
103
|
+
sorbet (0.6.13365)
|
|
104
|
+
sorbet-static (= 0.6.13365)
|
|
105
|
+
sorbet-runtime (0.6.13365)
|
|
106
|
+
sorbet-static (0.6.13365-aarch64-linux)
|
|
107
|
+
sorbet-static (0.6.13365-universal-darwin)
|
|
108
|
+
sorbet-static (0.6.13365-x86_64-linux)
|
|
109
|
+
sorbet-static-and-runtime (0.6.13365)
|
|
110
|
+
sorbet (= 0.6.13365)
|
|
111
|
+
sorbet-runtime (= 0.6.13365)
|
|
112
112
|
spoom (1.8.3)
|
|
113
113
|
erubi (>= 1.10.0)
|
|
114
114
|
prism (>= 0.28.0)
|
|
@@ -176,8 +176,8 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.
|
|
180
|
-
graphql (2.6.
|
|
179
|
+
graph_weaver (0.4.0)
|
|
180
|
+
graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
183
183
|
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
|
@@ -206,13 +206,13 @@ CHECKSUMS
|
|
|
206
206
|
rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
|
|
207
207
|
rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
|
|
208
208
|
rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
|
|
209
|
-
simplecov (1.0.
|
|
210
|
-
sorbet (0.6.
|
|
211
|
-
sorbet-runtime (0.6.
|
|
212
|
-
sorbet-static (0.6.
|
|
213
|
-
sorbet-static (0.6.
|
|
214
|
-
sorbet-static (0.6.
|
|
215
|
-
sorbet-static-and-runtime (0.6.
|
|
209
|
+
simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
|
|
210
|
+
sorbet (0.6.13365) sha256=3a642fe7afb031ad670c2e6161b3d5492dd67705cbcafb132c9e87ded7ccf676
|
|
211
|
+
sorbet-runtime (0.6.13365) sha256=0657cddfd2319c9695a0b6e13aa7a7c7b093149e5589448c03b911adf1e243cb
|
|
212
|
+
sorbet-static (0.6.13365-aarch64-linux) sha256=14b7bf3a227ee102c119763158618bc6b677994cebfda2705b94bca56e4c25e1
|
|
213
|
+
sorbet-static (0.6.13365-universal-darwin) sha256=0f6033dde8a0dc7b7e72ada3ce73e1e3097843be16e473fb8bb2b8fb6199212b
|
|
214
|
+
sorbet-static (0.6.13365-x86_64-linux) sha256=f94daadcae55f2e0f7797f8fdc8a250c89ec62e11f2d41905ed6fb1e60ff708e
|
|
215
|
+
sorbet-static-and-runtime (0.6.13365) sha256=9768c114686f7cac0a6f2e222db4213887d294df6059802242ba6e4e08adbad9
|
|
216
216
|
spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
|
|
217
217
|
tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
|
|
218
218
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
data/docs/generated_modules.md
CHANGED
|
@@ -210,9 +210,8 @@ emitted **once per schema** — one file per type under
|
|
|
210
210
|
named from the output path: the conventional layout gets
|
|
211
211
|
`GraphQLInputs`, while a multi-schema layout names each schema's module
|
|
212
212
|
after its directory (`app/graphql/github/generated` → `GithubInputs`).
|
|
213
|
-
Override globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
-
`generate!(inputs_module:)
|
|
215
|
-
`generate!(shared_inputs: false)`. Per-type files keep schema drift
|
|
213
|
+
Override the module name globally with `GraphWeaver.inputs_module=` or per run
|
|
214
|
+
with `generate!(inputs_module:)`. Per-type files keep schema drift
|
|
216
215
|
reviewable: a migration diffs exactly the types it touched, and types
|
|
217
216
|
the schema drops are pruned on regeneration (`verify` flags strays).
|
|
218
217
|
Query modules alias what they touch,
|
|
@@ -254,7 +253,11 @@ mod.execute!(where:)
|
|
|
254
253
|
interface-level fields only → one shared struct; a single `... on X`
|
|
255
254
|
condition and nothing else → `X`'s struct, always nilable — a
|
|
256
255
|
non-matching runtime type comes back as `nil`, so narrowing doubles as
|
|
257
|
-
filtering.
|
|
256
|
+
filtering. When a whole union field is selected as one named *shared*
|
|
257
|
+
fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
|
|
258
|
+
`GraphQLUnions` module and each query aliases it — so the same union is one
|
|
259
|
+
Ruby type family across queries, not a fresh dispatch module per query. Like
|
|
260
|
+
shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
|
|
258
261
|
- **`@skip` / `@include`** — a directive-conditional field may be absent from
|
|
259
262
|
the response regardless of schema nullability, so its generated type is
|
|
260
263
|
always nilable.
|
data/docs/getting_started.md
CHANGED
|
@@ -86,6 +86,51 @@ reviewed like any other code — and never edited by hand.
|
|
|
86
86
|
PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Shared fragments
|
|
90
|
+
|
|
91
|
+
Define reusable fragments once and spread them from any query:
|
|
92
|
+
|
|
93
|
+
```graphql
|
|
94
|
+
# app/graphql/fragments/person_fields.graphql
|
|
95
|
+
fragment PersonFields on Person { name birthday }
|
|
96
|
+
|
|
97
|
+
# app/graphql/queries/person.graphql
|
|
98
|
+
query($id: ID!) { person(id: $id) { ...PersonFields } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each query inlines only the fragments it (transitively) spreads, so the sent
|
|
102
|
+
`QUERY` stays self-contained — the server never needs your fragment library.
|
|
103
|
+
Fragment files hold only fragments (no operations), and names are unique across
|
|
104
|
+
them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
|
|
105
|
+
default `app/graphql/fragments`).
|
|
106
|
+
|
|
107
|
+
### Shared unions
|
|
108
|
+
|
|
109
|
+
When a shared fragment *is* the whole selection on a union field, its type is
|
|
110
|
+
hoisted once into a `GraphQLUnions` module and every query that spreads it
|
|
111
|
+
aliases the same type — so a `union` selected across many queries becomes one
|
|
112
|
+
Ruby type family, and you write one exhaustive `case … when … T.absurd` that
|
|
113
|
+
works everywhere:
|
|
114
|
+
|
|
115
|
+
```graphql
|
|
116
|
+
# app/graphql/fragments/feed_item.graphql
|
|
117
|
+
fragment FeedItemFields on FeedItem {
|
|
118
|
+
__typename
|
|
119
|
+
... on Post { title }
|
|
120
|
+
... on Photo { url }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# any query
|
|
124
|
+
query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Hoisting is what the shared fragment buys you — there's no flag. It triggers
|
|
128
|
+
only when the union field's selection is exactly that one spread (mix in other
|
|
129
|
+
fields, or shadow the fragment with a query-local one of the same name, and the
|
|
130
|
+
union stays inlined in that query). Named like the inputs module from the output
|
|
131
|
+
path (`GraphQLUnions`, or `GithubUnions` in a multi-schema layout); override
|
|
132
|
+
with `GraphWeaver.unions_module=`.
|
|
133
|
+
|
|
89
134
|
## 6. Test against fakes
|
|
90
135
|
|
|
91
136
|
```ruby
|
|
@@ -127,6 +172,13 @@ generated files — dynamic `parse` is invisible to `srb tc`. Everything
|
|
|
127
172
|
works without Sorbet; codegen plus Sorbet is what moves type errors from
|
|
128
173
|
runtime to CI.
|
|
129
174
|
|
|
175
|
+
If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
|
|
176
|
+
per-struct `extend T::Sig` in generated files is redundant — rubocop's
|
|
177
|
+
`Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
|
|
178
|
+
generation time and skips the `extend`; override with
|
|
179
|
+
`GraphWeaver.extend_t_sig = true`/`false`. (Generated code is machine-generated
|
|
180
|
+
and marked "do not edit," so excluding `generated/**` from rubocop is also fine.)
|
|
181
|
+
|
|
130
182
|
## Not Rails?
|
|
131
183
|
|
|
132
184
|
Everything above works the same, minus the Railtie conveniences: add
|
data/docs/scalars.md
CHANGED
|
@@ -14,6 +14,19 @@ them: `client.register_scalar(...)` overlays the global registry for that
|
|
|
14
14
|
client's generation only — so two servers can disagree about what a
|
|
15
15
|
`DateTime` is, and neither leaks into the other.
|
|
16
16
|
|
|
17
|
+
Pass a `Type.field` **coordinate** instead of a scalar name to override just
|
|
18
|
+
that one field — so the same scalar can deserialize as different Ruby types
|
|
19
|
+
across fields:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
|
|
23
|
+
GraphWeaver.register_scalar("User.birthday", Date) # this field only
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A field override wins over the scalar-name registration; both stack the same
|
|
27
|
+
global-then-client way. (GraphQL names can't contain `.`, so the coordinate is
|
|
28
|
+
unambiguous — and it's validated against the schema, so a typo'd field raises.)
|
|
29
|
+
|
|
17
30
|
Pass a real class as `type:` and the cast/serialize are **inferred** from it by
|
|
18
31
|
probing the deserialize side and pairing its serializer:
|
|
19
32
|
|
data/lib/graph_weaver/client.rb
CHANGED
|
@@ -76,7 +76,8 @@ class GraphWeaver::Client
|
|
|
76
76
|
|
|
77
77
|
# Client-scoped scalar registration: consulted before the global
|
|
78
78
|
# registry when this client generates code, so two clients can map the
|
|
79
|
-
# same scalar name onto different Ruby types.
|
|
79
|
+
# same scalar name onto different Ruby types. A `Type.field` coordinate
|
|
80
|
+
# (e.g. "User.birthday") overrides just that field. Same signature as
|
|
80
81
|
# GraphWeaver.register_scalar.
|
|
81
82
|
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
82
83
|
validate_registration!("scalar", graphql_name.to_s)
|
|
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
|
|
|
115
115
|
names.uniq
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
def emit_shared_aliases(out, names)
|
|
118
|
+
def emit_shared_aliases(out, names, namespace = @inputs_namespace)
|
|
119
119
|
return if names.empty?
|
|
120
120
|
|
|
121
|
-
names.each { |name| out << " #{name} = #{
|
|
121
|
+
names.each { |name| out << " #{name} = #{namespace}::#{name}" }
|
|
122
122
|
out << ""
|
|
123
123
|
end
|
|
124
124
|
|
|
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
|
|
|
175
175
|
files
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# The shared unions artifact as a single file: every hoisted union as a
|
|
179
|
+
# <module_name>::<Name> module. Unions don't cross-reference (each is a
|
|
180
|
+
# self-contained fragment), so there's no need for per-type files or the
|
|
181
|
+
# forward declarations recursive input types require.
|
|
182
|
+
def emit_unions_file(unions)
|
|
183
|
+
out = []
|
|
184
|
+
out << "# typed: strict"
|
|
185
|
+
out << "# frozen_string_literal: true"
|
|
186
|
+
out << ""
|
|
187
|
+
out << "# Generated by GraphWeaver — do not edit. Shared union types for this"
|
|
188
|
+
out << "# schema (named fragments on union fields); query modules alias what they use."
|
|
189
|
+
out << ""
|
|
190
|
+
requires = @requires.uniq.sort
|
|
191
|
+
if requires.any?
|
|
192
|
+
requires.each { |req| out << "require #{req.inspect}" }
|
|
193
|
+
out << ""
|
|
194
|
+
end
|
|
195
|
+
out << "module #{@module_name}"
|
|
196
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
197
|
+
unions.each do |union|
|
|
198
|
+
emit_union(union, out, 1)
|
|
199
|
+
out << ""
|
|
200
|
+
end
|
|
201
|
+
out.pop if out.last == ""
|
|
202
|
+
out << "end"
|
|
203
|
+
|
|
204
|
+
{ "unions.rb" => out.join("\n") + "\n" }
|
|
205
|
+
end
|
|
206
|
+
|
|
178
207
|
# one type per file, wrapped in the namespace so bare sibling
|
|
179
208
|
# references resolve lexically
|
|
180
209
|
def inputs_file(files, name)
|
|
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
|
|
|
200
229
|
def emit_module(root, variables)
|
|
201
230
|
flatten = flatten_input(variables)
|
|
202
231
|
aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
|
|
232
|
+
# hoisted unions the result tree references, aliased so <Name>::Type and
|
|
233
|
+
# <Name>.from_h resolve to the shared module
|
|
234
|
+
union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
|
|
203
235
|
|
|
204
236
|
out = []
|
|
205
237
|
out << "# typed: strict"
|
|
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
|
|
|
218
250
|
out << "require_relative \"inputs\""
|
|
219
251
|
out << ""
|
|
220
252
|
end
|
|
253
|
+
if union_aliases.any?
|
|
254
|
+
out << "require_relative \"unions\""
|
|
255
|
+
out << ""
|
|
256
|
+
end
|
|
221
257
|
out << "module #{@module_name}"
|
|
222
|
-
out << " extend T::Sig"
|
|
223
|
-
out << ""
|
|
258
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
224
259
|
# a GraphQL block string could contain a bare GRAPHQL line, which
|
|
225
260
|
# would terminate the heredoc early — pick a delimiter the query
|
|
226
261
|
# can't collide with
|
|
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
|
|
|
235
270
|
else
|
|
236
271
|
emit_variable_types(out)
|
|
237
272
|
end
|
|
273
|
+
emit_shared_aliases(out, union_aliases, @unions_namespace)
|
|
238
274
|
emit_nested(root, out, 1)
|
|
239
275
|
out << ""
|
|
240
276
|
emit_execute(out, variables, flatten:)
|
|
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
|
|
|
282
318
|
pad = " " * indent
|
|
283
319
|
|
|
284
320
|
out << "#{pad}class #{node.class_name} < T::Struct"
|
|
285
|
-
out << "#{pad} extend T::Sig"
|
|
321
|
+
out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
|
|
286
322
|
out << "#{pad} include GraphWeaver::Hints"
|
|
287
323
|
node.mixins.each do |mixin|
|
|
288
324
|
out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
|
|
289
325
|
end
|
|
290
326
|
out << ""
|
|
291
327
|
|
|
292
|
-
|
|
328
|
+
# uniq by object identity: deduped sibling unions share one node, so the
|
|
329
|
+
# shared type is emitted once (both fields' consts already reference it).
|
|
330
|
+
node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
|
|
293
331
|
emit_nested(child, out, indent + 1)
|
|
294
332
|
out << ""
|
|
295
333
|
end
|
|
@@ -318,8 +356,7 @@ class GraphWeaver::Codegen
|
|
|
318
356
|
pad = " " * indent
|
|
319
357
|
|
|
320
358
|
out << "#{pad}module #{node.class_name}"
|
|
321
|
-
out << "#{pad} extend T::Sig"
|
|
322
|
-
out << ""
|
|
359
|
+
out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
323
360
|
|
|
324
361
|
node.members.each_value do |member|
|
|
325
362
|
emit_object(member, out, indent + 1)
|
|
@@ -346,8 +383,7 @@ class GraphWeaver::Codegen
|
|
|
346
383
|
out << " @client = T.let(nil, T.untyped)"
|
|
347
384
|
out << ""
|
|
348
385
|
out << " class << self"
|
|
349
|
-
out << " extend T::Sig"
|
|
350
|
-
out << ""
|
|
386
|
+
out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
|
|
351
387
|
out << " sig { params(client: T.untyped).void }"
|
|
352
388
|
out << " attr_writer :client"
|
|
353
389
|
out << ""
|
|
@@ -277,6 +277,26 @@ class GraphWeaver::Codegen
|
|
|
277
277
|
def nested = self
|
|
278
278
|
end
|
|
279
279
|
|
|
280
|
+
# A reference to a union hoisted into the shared unions module (a named
|
|
281
|
+
# shared fragment spread as a whole union field): the query references
|
|
282
|
+
# <Name>::Type and dispatches through <Name>.from_h, where <Name> is the
|
|
283
|
+
# alias the query module gives GraphQLUnions::<Name>. The type family lives
|
|
284
|
+
# once in the shared module, so the same union across queries is one Ruby
|
|
285
|
+
# type — nested is nil, nothing is emitted here.
|
|
286
|
+
class UnionRefNode < Node
|
|
287
|
+
attr_reader :class_name
|
|
288
|
+
|
|
289
|
+
def initialize(class_name)
|
|
290
|
+
@class_name = class_name
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def bare_type = "#{class_name}::Type"
|
|
294
|
+
|
|
295
|
+
def cast(expr, _depth)
|
|
296
|
+
"#{class_name}.from_h(#{expr})"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
280
300
|
# An input-object variable: emitted as a module-level T::Struct whose
|
|
281
301
|
# serialize produces the wire hash. Inputs never cast FROM the wire.
|
|
282
302
|
# Joins the coerce protocol so execute kwargs accept plain hashes,
|
data/lib/graph_weaver/codegen.rb
CHANGED
|
@@ -49,8 +49,12 @@ class GraphWeaver::Codegen
|
|
|
49
49
|
# mixin modules, each keyed by GraphQL name). inputs_namespace: is the
|
|
50
50
|
# shared-inputs workflow (see GraphWeaver.generate!): variable types
|
|
51
51
|
# live once in that module and the query module aliases what it uses.
|
|
52
|
+
# unions_namespace:/hoistable_unions: are the parallel shared-unions
|
|
53
|
+
# workflow — a whole-union field spread as a named shared fragment resolves
|
|
54
|
+
# to one canonical type in that module (see used_union_names).
|
|
52
55
|
def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
|
|
53
|
-
scalars: nil, enums: nil, types: nil, inputs_namespace: nil
|
|
56
|
+
scalars: nil, enums: nil, types: nil, inputs_namespace: nil, unions_namespace: nil,
|
|
57
|
+
hoistable_unions: nil)
|
|
54
58
|
@schema = schema
|
|
55
59
|
@query = query.strip
|
|
56
60
|
@module_name = module_name
|
|
@@ -59,6 +63,12 @@ class GraphWeaver::Codegen
|
|
|
59
63
|
@enums = enums || {}
|
|
60
64
|
@types = types || {}
|
|
61
65
|
@inputs_namespace = inputs_namespace
|
|
66
|
+
# the shared-unions workflow: unions_namespace names the module hoisted
|
|
67
|
+
# unions live in; hoistable_unions is the set of shared fragment names this
|
|
68
|
+
# query may hoist (spreads it inlined, minus any it shadows locally)
|
|
69
|
+
@unions_namespace = unions_namespace
|
|
70
|
+
@hoistable_unions = hoistable_unions || []
|
|
71
|
+
@used_unions = []
|
|
62
72
|
@client_const = self.class.client_const(client)
|
|
63
73
|
|
|
64
74
|
if client && @client_const.nil?
|
|
@@ -111,6 +121,11 @@ class GraphWeaver::Codegen
|
|
|
111
121
|
{ inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
|
|
112
122
|
end
|
|
113
123
|
|
|
124
|
+
# The shared union fragments this query hoisted, by name — the generate!
|
|
125
|
+
# workflow unions these across queries to decide what the shared unions
|
|
126
|
+
# module must contain.
|
|
127
|
+
def used_union_names = @used_unions.dup
|
|
128
|
+
|
|
114
129
|
# The shared inputs artifact: the named input/enum types — plus
|
|
115
130
|
# everything they transitively reference — emitted once per schema as
|
|
116
131
|
# a manifest (inputs.rb) plus one file per type under inputs/, so a
|
|
@@ -138,6 +153,36 @@ class GraphWeaver::Codegen
|
|
|
138
153
|
emit_inputs_files
|
|
139
154
|
end
|
|
140
155
|
|
|
156
|
+
# The shared unions artifact: each named shared fragment a query hoisted,
|
|
157
|
+
# built once against the schema as <module_name>::<Name>, so the same union
|
|
158
|
+
# across queries resolves to one Ruby type family. `fragments` is the loaded
|
|
159
|
+
# shared-fragment table (nested spreads resolve through it); `names` the
|
|
160
|
+
# fragments to build. Returns { "unions.rb" => source }.
|
|
161
|
+
def self.generate_unions(schema:, module_name:, fragments:, names:,
|
|
162
|
+
scalars: nil, enums: nil, types: nil)
|
|
163
|
+
codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
|
|
164
|
+
codegen.generate_unions(fragments, names)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def generate_unions(fragments, names)
|
|
168
|
+
unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
169
|
+
raise ArgumentError, "unions module name must be a constant name, got #{@module_name.inspect}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
@requires = []
|
|
173
|
+
@mapped_enums = {}
|
|
174
|
+
# nested spreads inside a shared fragment resolve through the whole table
|
|
175
|
+
@fragments = fragments
|
|
176
|
+
|
|
177
|
+
unions = names.uniq.sort.map do |name|
|
|
178
|
+
fragment = fragments.fetch(name)
|
|
179
|
+
type = @schema.get_type(fragment.type.name)
|
|
180
|
+
UnionNode.new(camelize(name), union_members(type, fragment.selections))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
emit_unions_file(unions)
|
|
184
|
+
end
|
|
185
|
+
|
|
141
186
|
VarDef = Struct.new(:kwarg, :wire, :node, :required)
|
|
142
187
|
|
|
143
188
|
# Names that cannot appear bare in generated Ruby: keywords aren't
|
|
@@ -168,6 +213,7 @@ class GraphWeaver::Codegen
|
|
|
168
213
|
@variable_enums = {}
|
|
169
214
|
@variable_inputs = {}
|
|
170
215
|
@mapped_enums = {}
|
|
216
|
+
@used_unions = []
|
|
171
217
|
# requires the generated file needs (custom scalars, enum mappings,
|
|
172
218
|
# type helpers all contribute)
|
|
173
219
|
@requires = []
|
|
@@ -214,6 +260,14 @@ class GraphWeaver::Codegen
|
|
|
214
260
|
# whose schema introspects lazily). Global registrations skip this:
|
|
215
261
|
# they may target a different client's server.
|
|
216
262
|
def self.validate_registration!(schema, kind, name)
|
|
263
|
+
# register_scalar("Type.field", ...) overrides one field's scalar — validate
|
|
264
|
+
# the field exists and is a scalar, not that a type named "Type.field" exists.
|
|
265
|
+
if kind == "scalar" && name.include?(".")
|
|
266
|
+
return if scalar_field?(schema, name)
|
|
267
|
+
|
|
268
|
+
raise GraphWeaver::Error, "register_scalar(#{name.inspect}) matches no scalar field in this schema"
|
|
269
|
+
end
|
|
270
|
+
|
|
217
271
|
return if schema.get_type(name)
|
|
218
272
|
|
|
219
273
|
suggestion = defined?(DidYouMean::SpellChecker) &&
|
|
@@ -224,6 +278,81 @@ class GraphWeaver::Codegen
|
|
|
224
278
|
raise GraphWeaver::Error, "#{method}(#{name.inspect}) matches no type in this schema#{hint}"
|
|
225
279
|
end
|
|
226
280
|
|
|
281
|
+
# Whether `coordinate` ("Type.field") names an existing scalar field — the
|
|
282
|
+
# validation for a per-field register_scalar override.
|
|
283
|
+
def self.scalar_field?(schema, coordinate)
|
|
284
|
+
type_name, field_name = coordinate.split(".", 2)
|
|
285
|
+
return false unless field_name
|
|
286
|
+
|
|
287
|
+
field = schema.get_field(type_name, field_name)
|
|
288
|
+
!!field && field.type.unwrap.kind.name == "SCALAR"
|
|
289
|
+
rescue StandardError
|
|
290
|
+
false
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Parse every fragment file under `paths` into one { name => FragmentDefinition }
|
|
294
|
+
# map — reusable fragments a query can spread. Fragment files hold only
|
|
295
|
+
# fragments (no operations); names are unique across them.
|
|
296
|
+
def self.load_fragments(paths)
|
|
297
|
+
Array(paths).flat_map { |dir| Dir[File.join(dir, "*.graphql")].sort }.each_with_object({}) do |file, out|
|
|
298
|
+
doc = GraphQL.parse(File.read(file))
|
|
299
|
+
if doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).any?
|
|
300
|
+
raise GraphWeaver::Error, "#{file}: fragment files define only fragments, no operations"
|
|
301
|
+
end
|
|
302
|
+
doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).each do |frag|
|
|
303
|
+
raise GraphWeaver::Error, "duplicate shared fragment '#{frag.name}' (#{file})" if out.key?(frag.name)
|
|
304
|
+
out[frag.name] = frag
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The shared fragments a query spreads (transitively), excluding any it
|
|
310
|
+
# shadows with a local definition of the same name — the names
|
|
311
|
+
# inline_fragments appends, and the set the generate! workflow may hoist
|
|
312
|
+
# when they sit on a whole-union field.
|
|
313
|
+
def self.shared_fragment_spreads(query, shared)
|
|
314
|
+
return [] if shared.empty?
|
|
315
|
+
|
|
316
|
+
doc = GraphQL.parse(query)
|
|
317
|
+
local = doc.definitions.grep(GraphQL::Language::Nodes::FragmentDefinition).map(&:name)
|
|
318
|
+
reachable_fragments(fragment_spreads(doc.definitions), shared, local)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Append the shared fragments a query spreads (transitively) to its source, so
|
|
322
|
+
# the sent query is self-contained. Unused shared fragments are left out.
|
|
323
|
+
def self.inline_fragments(query, shared)
|
|
324
|
+
used = shared_fragment_spreads(query, shared)
|
|
325
|
+
return query if used.empty?
|
|
326
|
+
|
|
327
|
+
"#{query.rstrip}\n\n#{used.sort.map { |name| shared.fetch(name).to_query_string }.join("\n\n")}\n"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# BFS over spreads, following shared fragments into their own spreads; a
|
|
331
|
+
# locally-defined or unknown spread is left for schema validation to judge.
|
|
332
|
+
def self.reachable_fragments(spreads, shared, local)
|
|
333
|
+
needed = []
|
|
334
|
+
queue = spreads.dup
|
|
335
|
+
until queue.empty?
|
|
336
|
+
name = queue.shift
|
|
337
|
+
next if needed.include?(name) || local.include?(name) || !shared.key?(name)
|
|
338
|
+
|
|
339
|
+
needed << name
|
|
340
|
+
queue.concat(fragment_spreads([shared.fetch(name)]))
|
|
341
|
+
end
|
|
342
|
+
needed
|
|
343
|
+
end
|
|
344
|
+
private_class_method :reachable_fragments
|
|
345
|
+
|
|
346
|
+
# Names of every fragment spread reachable in these AST nodes.
|
|
347
|
+
def self.fragment_spreads(nodes, acc = [])
|
|
348
|
+
nodes.each do |node|
|
|
349
|
+
acc << node.name if node.is_a?(GraphQL::Language::Nodes::FragmentSpread)
|
|
350
|
+
fragment_spreads(node.selections, acc) if node.respond_to?(:selections)
|
|
351
|
+
end
|
|
352
|
+
acc
|
|
353
|
+
end
|
|
354
|
+
private_class_method :fragment_spreads
|
|
355
|
+
|
|
227
356
|
# Structured shape for a schema-validation error: message plus its first
|
|
228
357
|
# source location, so ValidationError#errors is inspectable.
|
|
229
358
|
def validation_detail(error)
|
|
@@ -251,6 +380,10 @@ class GraphWeaver::Codegen
|
|
|
251
380
|
node.graphql_type = type.graphql_name
|
|
252
381
|
node.mixins = type_mixins(type.graphql_name)
|
|
253
382
|
taken = [class_name]
|
|
383
|
+
# Dedup structurally-identical dispatch-union fields on this struct: the
|
|
384
|
+
# same union selected two ways (unblockOptions vs selectedOption) shares
|
|
385
|
+
# one Ruby type, so consumers get one exhaustive `case ... T.absurd`.
|
|
386
|
+
union_cache = {}
|
|
254
387
|
|
|
255
388
|
gather(type, selections).each do |key, field_nodes|
|
|
256
389
|
field_name = field_nodes.first.name
|
|
@@ -291,9 +424,20 @@ class GraphWeaver::Codegen
|
|
|
291
424
|
|
|
292
425
|
name = pick_name(member.graphql_name, key, taken)
|
|
293
426
|
nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name)) }
|
|
427
|
+
elsif @unions_namespace && (frag = lone_shared_spread(sub_selections)) &&
|
|
428
|
+
@hoistable_unions.include?(frag)
|
|
429
|
+
# a whole-union field spread as a named shared fragment: hoist to
|
|
430
|
+
# the shared unions module so the same union across queries is one
|
|
431
|
+
# Ruby type family (one exhaustive `case ... T.absurd`).
|
|
432
|
+
@used_unions << frag unless @used_unions.include?(frag)
|
|
433
|
+
ref = UnionRefNode.new(camelize(frag))
|
|
434
|
+
type_ref(field_type) { ref }
|
|
294
435
|
else
|
|
295
|
-
|
|
296
|
-
|
|
436
|
+
members = union_members(core, sub_selections)
|
|
437
|
+
# reuse an identical sibling union (pick_name/name only on a miss)
|
|
438
|
+
union = (union_cache[union_signature(members)] ||=
|
|
439
|
+
UnionNode.new(pick_name(core.graphql_name, key, taken), members))
|
|
440
|
+
type_ref(field_type) { union }
|
|
297
441
|
end
|
|
298
442
|
when "ENUM"
|
|
299
443
|
if (mapped = mapped_enum_node(core))
|
|
@@ -305,7 +449,8 @@ class GraphWeaver::Codegen
|
|
|
305
449
|
type_ref(field_type) { EnumNode.new(name, core.values.keys.sort) }
|
|
306
450
|
end
|
|
307
451
|
when "SCALAR"
|
|
308
|
-
|
|
452
|
+
coordinate = "#{type.graphql_name}.#{field_name}"
|
|
453
|
+
type_ref(field_type) { scalar_node(core.graphql_name, coordinate) }
|
|
309
454
|
else
|
|
310
455
|
raise GraphWeaver::Error, "unsupported kind: #{core.kind.name}"
|
|
311
456
|
end
|
|
@@ -341,6 +486,17 @@ class GraphWeaver::Codegen
|
|
|
341
486
|
selections.grep(GraphQL::Language::Nodes::Field).map { |field| field.alias || field.name }
|
|
342
487
|
end
|
|
343
488
|
|
|
489
|
+
# The fragment name when a selection is exactly one bare fragment spread
|
|
490
|
+
# (`{ ...F }`) — the shape a union field must have to hoist into the shared
|
|
491
|
+
# unions module. A spread carrying directives (@skip/@include), or mixed with
|
|
492
|
+
# other fields, stays a locally-emitted union.
|
|
493
|
+
def lone_shared_spread(selections)
|
|
494
|
+
return unless selections.size == 1
|
|
495
|
+
|
|
496
|
+
spread = selections.first
|
|
497
|
+
spread.name if spread.is_a?(GraphQL::Language::Nodes::FragmentSpread) && spread.directives.empty?
|
|
498
|
+
end
|
|
499
|
+
|
|
344
500
|
# does the flattened selection (as seen by member) include at least one
|
|
345
501
|
# field guaranteed to be present in a matching response?
|
|
346
502
|
def unconditional_field?(member, selections)
|
|
@@ -367,19 +523,45 @@ class GraphWeaver::Codegen
|
|
|
367
523
|
# concrete type: one member struct per possible type; wire dispatch
|
|
368
524
|
# reads __typename, so the query must select it. For interfaces, the
|
|
369
525
|
# interface's own field selections gather into every member.
|
|
370
|
-
|
|
526
|
+
# The union's member structs (graphql type name => ObjectNode), sorted for
|
|
527
|
+
# deterministic output. Dispatch reads __typename, so the query must select
|
|
528
|
+
# it; for interfaces the interface-level fields gather into every member.
|
|
529
|
+
def union_members(type, selections)
|
|
371
530
|
unless gather(type, selections).key?("__typename")
|
|
372
531
|
raise ArgumentError,
|
|
373
|
-
"select __typename on #{type.graphql_name} so
|
|
532
|
+
"select __typename on #{type.graphql_name} so the union can dispatch — " \
|
|
374
533
|
"or narrow to a single `... on Type` condition (no dispatch needed)"
|
|
375
534
|
end
|
|
376
535
|
|
|
377
|
-
|
|
378
|
-
members = @schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
536
|
+
@schema.possible_types(type).sort_by(&:graphql_name).to_h do |possible|
|
|
379
537
|
[possible.graphql_name, object_node(possible, selections, camelize(possible.graphql_name))]
|
|
380
538
|
end
|
|
539
|
+
end
|
|
381
540
|
|
|
382
|
-
|
|
541
|
+
# A name-independent structural fingerprint of a union's members, so two
|
|
542
|
+
# occurrences that generate identical structs collapse to one Ruby type.
|
|
543
|
+
def union_signature(members)
|
|
544
|
+
members.map { |gname, member| "#{gname}=#{signature(member)}" }.sort.join(",")
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Structural signature of a node — ignores the generated class name (which
|
|
548
|
+
# varies per occurrence), keying on GraphQL type, selection shape, and
|
|
549
|
+
# nullability so only genuinely-identical shapes collapse.
|
|
550
|
+
def signature(node)
|
|
551
|
+
case node
|
|
552
|
+
when NonNull then "!#{signature(node.of)}"
|
|
553
|
+
when List then "[#{signature(node.of)}]"
|
|
554
|
+
when NarrowedNode then "?#{signature(node.nested)}"
|
|
555
|
+
when Scalar then "s:#{node.bare_type}"
|
|
556
|
+
when EnumNode then "e:#{node.values.sort.join("|")}"
|
|
557
|
+
when MappedEnum then "m:#{node.graphql_name}"
|
|
558
|
+
when ObjectNode
|
|
559
|
+
inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
|
|
560
|
+
"o:#{node.graphql_type}(#{inner})"
|
|
561
|
+
when UnionNode then "u:(#{union_signature(node.members)})"
|
|
562
|
+
when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
|
|
563
|
+
else "x:#{node.object_id}" # unknown node kind — never collapse
|
|
564
|
+
end
|
|
383
565
|
end
|
|
384
566
|
|
|
385
567
|
# Build a node from an AST type reference (variable definitions), where
|
|
@@ -462,9 +644,13 @@ class GraphWeaver::Codegen
|
|
|
462
644
|
|
|
463
645
|
# A Scalar node, recording any requires its registered type needs so the
|
|
464
646
|
# generated file can require them (collected across the whole query).
|
|
465
|
-
# Resolution:
|
|
466
|
-
|
|
467
|
-
|
|
647
|
+
# Resolution, most specific first: a per-field override (`Type.field`), then
|
|
648
|
+
# the scalar-name registration — each checked client-scoped, then global.
|
|
649
|
+
def scalar_node(name, coordinate = nil)
|
|
650
|
+
scalar =
|
|
651
|
+
(coordinate && (@scalars[coordinate] || GraphWeaver::Codegen.scalar_registry[coordinate])) ||
|
|
652
|
+
@scalars[name.to_s] ||
|
|
653
|
+
GraphWeaver::Codegen.scalar(name)
|
|
468
654
|
@requires.concat(scalar.requires)
|
|
469
655
|
Scalar.new(scalar)
|
|
470
656
|
end
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -67,7 +67,7 @@ module GraphWeaver
|
|
|
67
67
|
#
|
|
68
68
|
# The singular accessors read the first entry (the default target
|
|
69
69
|
# for generate! and the rake tasks); assigning one replaces the list.
|
|
70
|
-
attr_writer :queries_paths, :generated_paths, :schema_path
|
|
70
|
+
attr_writer :queries_paths, :generated_paths, :schema_path, :fragments_paths
|
|
71
71
|
|
|
72
72
|
# Entries may be glob patterns — the generated default also matches
|
|
73
73
|
# per-schema layouts (app/graphql/github/generated). Queries stay
|
|
@@ -75,8 +75,14 @@ module GraphWeaver
|
|
|
75
75
|
def queries_paths = @queries_paths ||= ["app/graphql/queries"]
|
|
76
76
|
def generated_paths = @generated_paths ||= ["app/graphql/generated", "app/graphql/*/generated"]
|
|
77
77
|
|
|
78
|
+
# Reusable named fragments, defined once and available to every query —
|
|
79
|
+
# each query inlines only the ones it (transitively) spreads, so the sent
|
|
80
|
+
# query stays self-contained.
|
|
81
|
+
def fragments_paths = @fragments_paths ||= ["app/graphql/fragments"]
|
|
82
|
+
|
|
78
83
|
def queries_path = queries_paths.first
|
|
79
84
|
def generated_path = generated_paths.first
|
|
85
|
+
def fragments_path = fragments_paths.first
|
|
80
86
|
|
|
81
87
|
def queries_path=(path)
|
|
82
88
|
@queries_paths = path.nil? ? nil : [path]
|
|
@@ -88,26 +94,35 @@ module GraphWeaver
|
|
|
88
94
|
|
|
89
95
|
def schema_path = @schema_path || "app/graphql/schema.json"
|
|
90
96
|
|
|
91
|
-
# The shared-inputs module
|
|
92
|
-
# per generate!, or let
|
|
93
|
-
# directory above generated/ names the schema in
|
|
94
|
-
# layouts (app/graphql/github/generated => GithubInputs
|
|
95
|
-
# conventional layout (and anything unrecognizable)
|
|
96
|
-
# GraphQLInputs.
|
|
97
|
-
attr_writer :inputs_module
|
|
97
|
+
# The shared-inputs / shared-unions module names: set them globally, pass
|
|
98
|
+
# inputs_module:/unions_module: per generate!, or let them derive from the
|
|
99
|
+
# output path — the directory above generated/ names the schema in
|
|
100
|
+
# multi-schema layouts (app/graphql/github/generated => GithubInputs /
|
|
101
|
+
# GithubUnions); the conventional layout (and anything unrecognizable)
|
|
102
|
+
# stays GraphQLInputs / GraphQLUnions.
|
|
103
|
+
attr_writer :inputs_module, :unions_module
|
|
98
104
|
|
|
99
105
|
def inputs_module(output = generated_path)
|
|
100
|
-
|
|
106
|
+
@inputs_module || derive_module("Inputs", output)
|
|
107
|
+
end
|
|
101
108
|
|
|
109
|
+
def unions_module(output = generated_path)
|
|
110
|
+
@unions_module || derive_module("Unions", output)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Name a shared module from the output path: <Schema><suffix> in a
|
|
114
|
+
# multi-schema layout, else GraphQL<suffix>.
|
|
115
|
+
def derive_module(suffix, output)
|
|
102
116
|
segments = File.expand_path(output.to_s).split(File::SEPARATOR)
|
|
103
117
|
segments.pop if segments.last == "generated"
|
|
104
118
|
parent = segments.last.to_s
|
|
105
119
|
if parent.match?(/\A[a-zA-Z]\w*\z/) && !%w[graphql app lib spec support test].include?(parent)
|
|
106
|
-
"#{Inflect.camelize(parent)}
|
|
120
|
+
"#{Inflect.camelize(parent)}#{suffix}"
|
|
107
121
|
else
|
|
108
|
-
"
|
|
122
|
+
"GraphQL#{suffix}"
|
|
109
123
|
end
|
|
110
124
|
end
|
|
125
|
+
private :derive_module
|
|
111
126
|
|
|
112
127
|
# Generate every .graphql query in a directory into checked-in Ruby
|
|
113
128
|
# files. Paths default to the conventions above; schema: defaults to
|
|
@@ -118,11 +133,12 @@ module GraphWeaver
|
|
|
118
133
|
# person.graphql => person_query.rb defining PersonQuery. Returns the
|
|
119
134
|
# written paths. Pair with a freshness spec (docs/generated_modules.md).
|
|
120
135
|
def generate!(schema: nil, queries: queries_path, output: generated_path, client: nil,
|
|
121
|
-
|
|
136
|
+
inputs_module: nil, unions_module: nil)
|
|
122
137
|
schema ||= locate_schema!
|
|
123
138
|
inputs_module ||= self.inputs_module(output)
|
|
139
|
+
unions_module ||= self.unions_module(output)
|
|
124
140
|
|
|
125
|
-
plan = generation_plan(queries:, schema:, client:,
|
|
141
|
+
plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
|
|
126
142
|
written = plan.map do |filename, source|
|
|
127
143
|
target = File.join(output, filename)
|
|
128
144
|
FileUtils.mkdir_p(File.dirname(target))
|
|
@@ -131,9 +147,9 @@ module GraphWeaver
|
|
|
131
147
|
target
|
|
132
148
|
end
|
|
133
149
|
|
|
134
|
-
# a type dropped from the schema
|
|
135
|
-
# inputs/
|
|
136
|
-
(
|
|
150
|
+
# a type dropped from the schema (or a union no longer hoisted) must not
|
|
151
|
+
# linger as a stale file — inputs/ and unions.rb are wholly generated
|
|
152
|
+
(shared_artifacts(output) - written).each do |orphan|
|
|
137
153
|
File.delete(orphan)
|
|
138
154
|
log(:info) { "pruned #{orphan}" }
|
|
139
155
|
end
|
|
@@ -141,6 +157,13 @@ module GraphWeaver
|
|
|
141
157
|
written
|
|
142
158
|
end
|
|
143
159
|
|
|
160
|
+
# The wholly-generated shared-artifact files under output (inputs/*.rb and
|
|
161
|
+
# unions.rb) — safe to prune when regeneration no longer produces them.
|
|
162
|
+
def shared_artifacts(output)
|
|
163
|
+
Dir[File.join(output, "inputs", "*.rb")] + Dir[File.join(output, "unions.rb")]
|
|
164
|
+
end
|
|
165
|
+
private :shared_artifacts
|
|
166
|
+
|
|
144
167
|
# The freshness guard: raise unless every generated file matches what
|
|
145
168
|
# the current schema + queries + scalar registrations would produce.
|
|
146
169
|
# One line in a spec, or `rake graph_weaver:verify` in CI:
|
|
@@ -149,16 +172,17 @@ module GraphWeaver
|
|
|
149
172
|
# GraphWeaver.verify_generated!
|
|
150
173
|
# end
|
|
151
174
|
def verify_generated!(schema: nil, queries: queries_path, output: generated_path, client: nil,
|
|
152
|
-
|
|
175
|
+
inputs_module: nil, unions_module: nil)
|
|
153
176
|
schema ||= locate_schema!
|
|
154
177
|
inputs_module ||= self.inputs_module(output)
|
|
155
|
-
|
|
178
|
+
unions_module ||= self.unions_module(output)
|
|
179
|
+
plan = generation_plan(queries:, schema:, client:, inputs_module:, unions_module:)
|
|
156
180
|
stale = plan.filter_map do |filename, source|
|
|
157
181
|
target = File.join(output, filename)
|
|
158
182
|
target unless File.exist?(target) && File.read(target) == source
|
|
159
183
|
end
|
|
160
|
-
# strays: a
|
|
161
|
-
stale +=
|
|
184
|
+
# strays: a shared-artifact file the current schema + queries no longer produce
|
|
185
|
+
stale += shared_artifacts(output) - plan.map { |f, _| File.join(output, f) }
|
|
162
186
|
|
|
163
187
|
unless stale.empty?
|
|
164
188
|
raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): #{stale.join(", ")}"
|
|
@@ -192,34 +216,49 @@ module GraphWeaver
|
|
|
192
216
|
end
|
|
193
217
|
private :locate_schema!
|
|
194
218
|
|
|
195
|
-
# (filename, source) per artifact
|
|
196
|
-
#
|
|
197
|
-
# aliasing what they use — the
|
|
198
|
-
# duplicated bool_exp structs
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
# (filename, source) per artifact. Every variable type is emitted once into
|
|
220
|
+
# inputs.rb, and each named shared fragment spread as a whole-union field
|
|
221
|
+
# once into unions.rb, with query modules aliasing what they use — the
|
|
222
|
+
# difference between hundreds of duplicated bool_exp structs (or the same
|
|
223
|
+
# union re-typed per query) and one copy per schema. (Single-query parse
|
|
224
|
+
# inlines both — there's no cross-query set to share against.)
|
|
225
|
+
def generation_plan(queries:, schema:, client:, inputs_module: self.inputs_module,
|
|
226
|
+
unions_module: self.unions_module, fragments: fragments_paths)
|
|
201
227
|
used = { inputs: [], enums: [], mapped: [] }
|
|
228
|
+
used_unions = []
|
|
229
|
+
shared = Codegen.load_fragments(fragments)
|
|
202
230
|
|
|
203
231
|
plan = Dir[File.join(queries, "*.graphql")].sort.map do |path|
|
|
204
232
|
base = File.basename(path, ".graphql")
|
|
233
|
+
source = File.read(path)
|
|
205
234
|
codegen = Codegen.new(
|
|
206
235
|
schema:,
|
|
207
|
-
query:
|
|
236
|
+
query: Codegen.inline_fragments(source, shared),
|
|
208
237
|
module_name: "#{Inflect.camelize(base)}Query",
|
|
209
238
|
client:,
|
|
210
|
-
inputs_namespace:
|
|
239
|
+
inputs_namespace: inputs_module,
|
|
240
|
+
unions_namespace: unions_module,
|
|
241
|
+
hoistable_unions: Codegen.shared_fragment_spreads(source, shared),
|
|
211
242
|
)
|
|
212
|
-
|
|
243
|
+
out = codegen.generate
|
|
213
244
|
codegen.variable_type_names.each { |kind, names| used[kind] |= names }
|
|
214
|
-
|
|
245
|
+
used_unions |= codegen.used_union_names
|
|
246
|
+
["#{base}_query.rb", out]
|
|
215
247
|
end
|
|
216
248
|
|
|
217
|
-
if
|
|
218
|
-
|
|
219
|
-
schema:, module_name:
|
|
249
|
+
if inputs_module && used.values.any?(&:any?)
|
|
250
|
+
inputs = Codegen.generate_inputs(
|
|
251
|
+
schema:, module_name: inputs_module,
|
|
220
252
|
input_types: used[:inputs], enum_types: used[:enums] + used[:mapped],
|
|
221
253
|
)
|
|
222
|
-
plan =
|
|
254
|
+
plan = inputs.to_a + plan
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
if unions_module && used_unions.any?
|
|
258
|
+
unions = Codegen.generate_unions(
|
|
259
|
+
schema:, module_name: unions_module, fragments: shared, names: used_unions,
|
|
260
|
+
)
|
|
261
|
+
plan = unions.to_a + plan
|
|
223
262
|
end
|
|
224
263
|
|
|
225
264
|
plan
|
|
@@ -238,6 +277,25 @@ module GraphWeaver
|
|
|
238
277
|
# a registration always wins.
|
|
239
278
|
attr_accessor :auto_coerce
|
|
240
279
|
|
|
280
|
+
# Whether generated modules/structs emit `extend T::Sig` (so `sig`
|
|
281
|
+
# resolves standalone). Default (nil) auto-detects: an app that globally
|
|
282
|
+
# injects T::Sig (`class Module; include T::Sig`) makes the per-struct
|
|
283
|
+
# extend redundant — rubocop's Sorbet/RedundantExtendTSig flags it — so
|
|
284
|
+
# generation skips it. Force with true/false. Resolved at generation time.
|
|
285
|
+
#
|
|
286
|
+
# GraphWeaver.extend_t_sig = false # never emit (rely on a global include)
|
|
287
|
+
attr_writer :extend_t_sig
|
|
288
|
+
|
|
289
|
+
# The resolved boolean codegen uses: the explicit setting, else emit
|
|
290
|
+
# unless T::Sig is globally injected into Module.
|
|
291
|
+
def extend_t_sig?
|
|
292
|
+
@extend_t_sig.nil? ? !global_tsig? : @extend_t_sig
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Whether the host app has globally injected T::Sig into every module
|
|
296
|
+
# (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
|
|
297
|
+
def global_tsig? = Module.include?(T::Sig)
|
|
298
|
+
|
|
241
299
|
# Teach the generator how a GraphQL custom scalar deserializes into a
|
|
242
300
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
243
301
|
# variable):
|
|
@@ -256,7 +314,15 @@ module GraphWeaver
|
|
|
256
314
|
# raw input (e.g. "12.00"), running the latter through the cast before
|
|
257
315
|
# serializing — it raises on bad input, so some safety survives. Built-in
|
|
258
316
|
# scalars are pre-registered the same way, so this also overrides them.
|
|
259
|
-
#
|
|
317
|
+
#
|
|
318
|
+
# Pass a `Type.field` coordinate instead of a scalar name to override just
|
|
319
|
+
# that one field — so the same scalar can deserialize as different Ruby
|
|
320
|
+
# types across fields (a `Date` for `User.birthday`, a `Time` elsewhere):
|
|
321
|
+
#
|
|
322
|
+
# GraphWeaver.register_scalar("User.birthday", Date)
|
|
323
|
+
#
|
|
324
|
+
# A field-level override wins over the scalar-name registration. Same
|
|
325
|
+
# signature either way. Call before generating.
|
|
260
326
|
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
|
|
261
327
|
Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires:, coerce:)
|
|
262
328
|
end
|
|
@@ -322,11 +388,13 @@ module GraphWeaver
|
|
|
322
388
|
# falling back to "Query" for anonymous operations — collisions are
|
|
323
389
|
# impossible since each parse gets its own container). Pass name: to
|
|
324
390
|
# override, client: to bake the module's default client/transport.
|
|
325
|
-
def parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil
|
|
391
|
+
def parse(schema:, query:, name: nil, client: nil, scalars: nil, enums: nil, types: nil,
|
|
392
|
+
fragments: fragments_paths)
|
|
326
393
|
if query.end_with?(".graphql", ".gql")
|
|
327
394
|
name ||= "#{Inflect.camelize(File.basename(query, ".*"))}Query"
|
|
328
395
|
query = File.read(query)
|
|
329
396
|
end
|
|
397
|
+
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments))
|
|
330
398
|
|
|
331
399
|
Codegen.parse(schema:, query:, module_name: name, client:, scalars:, enums:, types:)
|
|
332
400
|
end
|