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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e66ac94243bdc7f065534845a722bfd209c8d7b23a3a5fa1eff6041e3f563655
4
- data.tar.gz: 413217dbda71c297abe537dc996b8e8413821365d9e966493bf7301d6351a6c7
3
+ metadata.gz: a608ee344d2e8ebf8ff0e847a34005114cfd1efd5a44c7aa66ef81c457706526
4
+ data.tar.gz: 4622816580d4158d4b84140b85876f00582ac85521686da4417f1945cdb4c276
5
5
  SHA512:
6
- metadata.gz: 62dab00fca85973dbff0e695dcd9970a554ca98b6a717a375fa7f2cde349b2967672ce541bf108d3b058b54b47ce7d274f6c8f11f069360a59e95fcb86ff8ae5
7
- data.tar.gz: 3a11e52459c3b0158d831c8815ced9c4ca5cd323435ffac4e36a8648e83179dd87763a4570aaa56b240d0d7d77d3b91e43d1706f40112d3eae2fa608950b797c
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.3.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.6)
45
+ graphql (2.6.7)
46
46
  base64
47
47
  fiber-storage
48
48
  logger
@@ -99,16 +99,16 @@ GEM
99
99
  rubydex (0.2.7-arm64-darwin)
100
100
  rubydex (0.2.7-x86_64-darwin)
101
101
  rubydex (0.2.7-x86_64-linux)
102
- simplecov (1.0.2)
103
- sorbet (0.6.13347)
104
- sorbet-static (= 0.6.13347)
105
- sorbet-runtime (0.6.13347)
106
- sorbet-static (0.6.13347-aarch64-linux)
107
- sorbet-static (0.6.13347-universal-darwin)
108
- sorbet-static (0.6.13347-x86_64-linux)
109
- sorbet-static-and-runtime (0.6.13347)
110
- sorbet (= 0.6.13347)
111
- sorbet-runtime (= 0.6.13347)
102
+ simplecov (1.0.3)
103
+ sorbet (0.6.13365)
104
+ sorbet-static (= 0.6.13365)
105
+ sorbet-runtime (0.6.13365)
106
+ sorbet-static (0.6.13365-aarch64-linux)
107
+ sorbet-static (0.6.13365-universal-darwin)
108
+ sorbet-static (0.6.13365-x86_64-linux)
109
+ sorbet-static-and-runtime (0.6.13365)
110
+ sorbet (= 0.6.13365)
111
+ sorbet-runtime (= 0.6.13365)
112
112
  spoom (1.8.3)
113
113
  erubi (>= 1.10.0)
114
114
  prism (>= 0.28.0)
@@ -176,8 +176,8 @@ CHECKSUMS
176
176
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
177
177
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
178
178
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
179
- graph_weaver (0.3.0)
180
- graphql (2.6.6) sha256=7438e2036b571c884377eb1529225470ba959365f26dc47152953c213bc38c31
179
+ graph_weaver (0.4.0)
180
+ graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
183
183
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
@@ -206,13 +206,13 @@ CHECKSUMS
206
206
  rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
207
207
  rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
208
208
  rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
209
- simplecov (1.0.2) sha256=c6459434efe4b948b46477cc2df2faa73ab365f83a33c7c17f81262f4f7f1244
210
- sorbet (0.6.13347) sha256=2830946e6efda8dc732c5d703350bed17f437fbea1ccfbef543a65e33ae445c1
211
- sorbet-runtime (0.6.13347) sha256=54e7221d9f4b63f58aec79f3043752a9ba7810347c6ae0d903531591cc2d5f01
212
- sorbet-static (0.6.13347-aarch64-linux) sha256=9bb60509bf9a8715d93e77cd55f96659575d81a5f47b52bd4f66b3dfe6f2182e
213
- sorbet-static (0.6.13347-universal-darwin) sha256=d12678a45369dba9db724103d8badda995e66a83ae35798f68d804c46a6d3e45
214
- sorbet-static (0.6.13347-x86_64-linux) sha256=5a4803d2ba4fc1964e23f3aa4073b224c345c2a72a5f5317623a4bae7ce2c8da
215
- sorbet-static-and-runtime (0.6.13347) sha256=2130e3c20326948a3edd20385b444345cb7327eea9b141f373486e7c087a498d
209
+ simplecov (1.0.3) sha256=38ef0514f16ae7562f0d0f4df02610071115103d301b6de7dacbcc000082e39b
210
+ sorbet (0.6.13365) sha256=3a642fe7afb031ad670c2e6161b3d5492dd67705cbcafb132c9e87ded7ccf676
211
+ sorbet-runtime (0.6.13365) sha256=0657cddfd2319c9695a0b6e13aa7a7c7b093149e5589448c03b911adf1e243cb
212
+ sorbet-static (0.6.13365-aarch64-linux) sha256=14b7bf3a227ee102c119763158618bc6b677994cebfda2705b94bca56e4c25e1
213
+ sorbet-static (0.6.13365-universal-darwin) sha256=0f6033dde8a0dc7b7e72ada3ce73e1e3097843be16e473fb8bb2b8fb6199212b
214
+ sorbet-static (0.6.13365-x86_64-linux) sha256=f94daadcae55f2e0f7797f8fdc8a250c89ec62e11f2d41905ed6fb1e60ff708e
215
+ sorbet-static-and-runtime (0.6.13365) sha256=9768c114686f7cac0a6f2e222db4213887d294df6059802242ba6e4e08adbad9
216
216
  spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
217
217
  tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
218
218
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
@@ -210,9 +210,8 @@ emitted **once per schema** — one file per type under
210
210
  named from the output path: the conventional layout gets
211
211
  `GraphQLInputs`, while a multi-schema layout names each schema's module
212
212
  after its directory (`app/graphql/github/generated` → `GithubInputs`).
213
- Override globally with `GraphWeaver.inputs_module=` or per run with
214
- `generate!(inputs_module:)`; opt out with
215
- `generate!(shared_inputs: false)`. Per-type files keep schema drift
213
+ Override the module name globally with `GraphWeaver.inputs_module=` or per run
214
+ with `generate!(inputs_module:)`. Per-type files keep schema drift
216
215
  reviewable: a migration diffs exactly the types it touched, and types
217
216
  the schema drops are pruned on regeneration (`verify` flags strays).
218
217
  Query modules alias what they touch,
@@ -254,7 +253,11 @@ mod.execute!(where:)
254
253
  interface-level fields only → one shared struct; a single `... on X`
255
254
  condition and nothing else → `X`'s struct, always nilable — a
256
255
  non-matching runtime type comes back as `nil`, so narrowing doubles as
257
- filtering.
256
+ filtering. When a whole union field is selected as one named *shared*
257
+ fragment (`{ ...FeedItemFields }`), that type is hoisted once into the
258
+ `GraphQLUnions` module and each query aliases it — so the same union is one
259
+ Ruby type family across queries, not a fresh dispatch module per query. Like
260
+ shared inputs, it's a `generate!`-directory concern; dynamic `parse` inlines.
258
261
  - **`@skip` / `@include`** — a directive-conditional field may be absent from
259
262
  the response regardless of schema nullability, so its generated type is
260
263
  always nilable.
@@ -86,6 +86,51 @@ reviewed like any other code — and never edited by hand.
86
86
  PersonQuery.execute!(id: "1").person&.name # typed, via GraphWeaver.client
87
87
  ```
88
88
 
89
+ ### Shared fragments
90
+
91
+ Define reusable fragments once and spread them from any query:
92
+
93
+ ```graphql
94
+ # app/graphql/fragments/person_fields.graphql
95
+ fragment PersonFields on Person { name birthday }
96
+
97
+ # app/graphql/queries/person.graphql
98
+ query($id: ID!) { person(id: $id) { ...PersonFields } }
99
+ ```
100
+
101
+ Each query inlines only the fragments it (transitively) spreads, so the sent
102
+ `QUERY` stays self-contained — the server never needs your fragment library.
103
+ Fragment files hold only fragments (no operations), and names are unique across
104
+ them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
105
+ default `app/graphql/fragments`).
106
+
107
+ ### Shared unions
108
+
109
+ When a shared fragment *is* the whole selection on a union field, its type is
110
+ hoisted once into a `GraphQLUnions` module and every query that spreads it
111
+ aliases the same type — so a `union` selected across many queries becomes one
112
+ Ruby type family, and you write one exhaustive `case … when … T.absurd` that
113
+ works everywhere:
114
+
115
+ ```graphql
116
+ # app/graphql/fragments/feed_item.graphql
117
+ fragment FeedItemFields on FeedItem {
118
+ __typename
119
+ ... on Post { title }
120
+ ... on Photo { url }
121
+ }
122
+
123
+ # any query
124
+ query { feed { ...FeedItemFields } } # feed : T::Array[FeedItemFields::Type]
125
+ ```
126
+
127
+ Hoisting is what the shared fragment buys you — there's no flag. It triggers
128
+ only when the union field's selection is exactly that one spread (mix in other
129
+ fields, or shadow the fragment with a query-local one of the same name, and the
130
+ union stays inlined in that query). Named like the inputs module from the output
131
+ path (`GraphQLUnions`, or `GithubUnions` in a multi-schema layout); override
132
+ with `GraphWeaver.unions_module=`.
133
+
89
134
  ## 6. Test against fakes
90
135
 
91
136
  ```ruby
@@ -127,6 +172,13 @@ generated files — dynamic `parse` is invisible to `srb tc`. Everything
127
172
  works without Sorbet; codegen plus Sorbet is what moves type errors from
128
173
  runtime to CI.
129
174
 
175
+ If your app globally injects `T::Sig` (`class Module; include T::Sig`), the
176
+ per-struct `extend T::Sig` in generated files is redundant — rubocop's
177
+ `Sorbet/RedundantExtendTSig` flags it. GraphWeaver auto-detects that at
178
+ generation time and skips the `extend`; override with
179
+ `GraphWeaver.extend_t_sig = true`/`false`. (Generated code is machine-generated
180
+ and marked "do not edit," so excluding `generated/**` from rubocop is also fine.)
181
+
130
182
  ## Not Rails?
131
183
 
132
184
  Everything above works the same, minus the Railtie conveniences: add
data/docs/scalars.md CHANGED
@@ -14,6 +14,19 @@ them: `client.register_scalar(...)` overlays the global registry for that
14
14
  client's generation only — so two servers can disagree about what a
15
15
  `DateTime` is, and neither leaks into the other.
16
16
 
17
+ Pass a `Type.field` **coordinate** instead of a scalar name to override just
18
+ that one field — so the same scalar can deserialize as different Ruby types
19
+ across fields:
20
+
21
+ ```ruby
22
+ GraphWeaver.register_scalar("ISO8601DateTime", Time) # the default, everywhere
23
+ GraphWeaver.register_scalar("User.birthday", Date) # this field only
24
+ ```
25
+
26
+ A field override wins over the scalar-name registration; both stack the same
27
+ global-then-client way. (GraphQL names can't contain `.`, so the coordinate is
28
+ unambiguous — and it's validated against the schema, so a typo'd field raises.)
29
+
17
30
  Pass a real class as `type:` and the cast/serialize are **inferred** from it by
18
31
  probing the deserialize side and pairing its serializer:
19
32
 
@@ -76,7 +76,8 @@ class GraphWeaver::Client
76
76
 
77
77
  # Client-scoped scalar registration: consulted before the global
78
78
  # registry when this client generates code, so two clients can map the
79
- # same scalar name onto different Ruby types. Same signature as
79
+ # same scalar name onto different Ruby types. A `Type.field` coordinate
80
+ # (e.g. "User.birthday") overrides just that field. Same signature as
80
81
  # GraphWeaver.register_scalar.
81
82
  def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
82
83
  validate_registration!("scalar", graphql_name.to_s)
@@ -115,10 +115,10 @@ class GraphWeaver::Codegen
115
115
  names.uniq
116
116
  end
117
117
 
118
- def emit_shared_aliases(out, names)
118
+ def emit_shared_aliases(out, names, namespace = @inputs_namespace)
119
119
  return if names.empty?
120
120
 
121
- names.each { |name| out << " #{name} = #{@inputs_namespace}::#{name}" }
121
+ names.each { |name| out << " #{name} = #{namespace}::#{name}" }
122
122
  out << ""
123
123
  end
124
124
 
@@ -175,6 +175,35 @@ class GraphWeaver::Codegen
175
175
  files
176
176
  end
177
177
 
178
+ # The shared unions artifact as a single file: every hoisted union as a
179
+ # <module_name>::<Name> module. Unions don't cross-reference (each is a
180
+ # self-contained fragment), so there's no need for per-type files or the
181
+ # forward declarations recursive input types require.
182
+ def emit_unions_file(unions)
183
+ out = []
184
+ out << "# typed: strict"
185
+ out << "# frozen_string_literal: true"
186
+ out << ""
187
+ out << "# Generated by GraphWeaver — do not edit. Shared union types for this"
188
+ out << "# schema (named fragments on union fields); query modules alias what they use."
189
+ out << ""
190
+ requires = @requires.uniq.sort
191
+ if requires.any?
192
+ requires.each { |req| out << "require #{req.inspect}" }
193
+ out << ""
194
+ end
195
+ out << "module #{@module_name}"
196
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
197
+ unions.each do |union|
198
+ emit_union(union, out, 1)
199
+ out << ""
200
+ end
201
+ out.pop if out.last == ""
202
+ out << "end"
203
+
204
+ { "unions.rb" => out.join("\n") + "\n" }
205
+ end
206
+
178
207
  # one type per file, wrapped in the namespace so bare sibling
179
208
  # references resolve lexically
180
209
  def inputs_file(files, name)
@@ -200,6 +229,9 @@ class GraphWeaver::Codegen
200
229
  def emit_module(root, variables)
201
230
  flatten = flatten_input(variables)
202
231
  aliases = @inputs_namespace ? shared_alias_names(variables, flatten) : []
232
+ # hoisted unions the result tree references, aliased so <Name>::Type and
233
+ # <Name>.from_h resolve to the shared module
234
+ union_aliases = @used_unions.map { |name| camelize(name) }.uniq.sort
203
235
 
204
236
  out = []
205
237
  out << "# typed: strict"
@@ -218,9 +250,12 @@ class GraphWeaver::Codegen
218
250
  out << "require_relative \"inputs\""
219
251
  out << ""
220
252
  end
253
+ if union_aliases.any?
254
+ out << "require_relative \"unions\""
255
+ out << ""
256
+ end
221
257
  out << "module #{@module_name}"
222
- out << " extend T::Sig"
223
- out << ""
258
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
224
259
  # a GraphQL block string could contain a bare GRAPHQL line, which
225
260
  # would terminate the heredoc early — pick a delimiter the query
226
261
  # can't collide with
@@ -235,6 +270,7 @@ class GraphWeaver::Codegen
235
270
  else
236
271
  emit_variable_types(out)
237
272
  end
273
+ emit_shared_aliases(out, union_aliases, @unions_namespace)
238
274
  emit_nested(root, out, 1)
239
275
  out << ""
240
276
  emit_execute(out, variables, flatten:)
@@ -282,14 +318,16 @@ class GraphWeaver::Codegen
282
318
  pad = " " * indent
283
319
 
284
320
  out << "#{pad}class #{node.class_name} < T::Struct"
285
- out << "#{pad} extend T::Sig"
321
+ out << "#{pad} extend T::Sig" if GraphWeaver.extend_t_sig?
286
322
  out << "#{pad} include GraphWeaver::Hints"
287
323
  node.mixins.each do |mixin|
288
324
  out << "#{pad} include #{mixin} # registered for #{node.graphql_type}"
289
325
  end
290
326
  out << ""
291
327
 
292
- node.fields.filter_map { |field| field.node.nested }.each do |child|
328
+ # uniq by object identity: deduped sibling unions share one node, so the
329
+ # shared type is emitted once (both fields' consts already reference it).
330
+ node.fields.filter_map { |field| field.node.nested }.uniq.each do |child|
293
331
  emit_nested(child, out, indent + 1)
294
332
  out << ""
295
333
  end
@@ -318,8 +356,7 @@ class GraphWeaver::Codegen
318
356
  pad = " " * indent
319
357
 
320
358
  out << "#{pad}module #{node.class_name}"
321
- out << "#{pad} extend T::Sig"
322
- out << ""
359
+ out << "#{pad} extend T::Sig" << "" if GraphWeaver.extend_t_sig?
323
360
 
324
361
  node.members.each_value do |member|
325
362
  emit_object(member, out, indent + 1)
@@ -346,8 +383,7 @@ class GraphWeaver::Codegen
346
383
  out << " @client = T.let(nil, T.untyped)"
347
384
  out << ""
348
385
  out << " class << self"
349
- out << " extend T::Sig"
350
- out << ""
386
+ out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
351
387
  out << " sig { params(client: T.untyped).void }"
352
388
  out << " attr_writer :client"
353
389
  out << ""
@@ -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,
@@ -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
- name = pick_name(core.graphql_name, key, taken)
296
- type_ref(field_type) { union_node(core, sub_selections, name) }
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
- type_ref(field_type) { scalar_node(core.graphql_name) }
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
- def union_node(type, selections, class_name)
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 #{class_name} can dispatch — " \
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
- # sorted so output is deterministic across schema sources
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
- UnionNode.new(class_name, members)
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: the client-scoped overlay first, then the global registry.
466
- def scalar_node(name)
467
- scalar = @scalars[name.to_s] || GraphWeaver::Codegen.scalar(name)
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
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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 name: set it globally, pass inputs_module:
92
- # per generate!, or let it derive from the output path — the
93
- # directory above generated/ names the schema in multi-schema
94
- # layouts (app/graphql/github/generated => GithubInputs); the
95
- # conventional layout (and anything unrecognizable) stays
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
- return @inputs_module if @inputs_module
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)}Inputs"
120
+ "#{Inflect.camelize(parent)}#{suffix}"
107
121
  else
108
- "GraphQLInputs"
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
- shared_inputs: true, inputs_module: nil)
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:, shared_inputs:, inputs_module:)
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 must not linger as a stale file
135
- # inputs/ is wholly generated, so pruning is safe
136
- (Dir[File.join(output, "inputs", "*.rb")] - written).each do |orphan|
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
- shared_inputs: true, inputs_module: nil)
175
+ inputs_module: nil, unions_module: nil)
153
176
  schema ||= locate_schema!
154
177
  inputs_module ||= self.inputs_module(output)
155
- plan = generation_plan(queries:, schema:, client:, shared_inputs:, inputs_module:)
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 type file the current schema no longer produces
161
- stale += Dir[File.join(output, "inputs", "*.rb")] - plan.map { |f, _| File.join(output, f) }
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: shared_inputs (the default) emits
196
- # every variable type once into inputs.rb, with query modules
197
- # aliasing what they use — the difference between hundreds of
198
- # duplicated bool_exp structs and one copy per schema.
199
- def generation_plan(queries:, schema:, client:, shared_inputs:, inputs_module: self.inputs_module)
200
- namespace = shared_inputs ? inputs_module : nil
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: File.read(path),
236
+ query: Codegen.inline_fragments(source, shared),
208
237
  module_name: "#{Inflect.camelize(base)}Query",
209
238
  client:,
210
- inputs_namespace: namespace,
239
+ inputs_namespace: inputs_module,
240
+ unions_namespace: unions_module,
241
+ hoistable_unions: Codegen.shared_fragment_spreads(source, shared),
211
242
  )
212
- source = codegen.generate
243
+ out = codegen.generate
213
244
  codegen.variable_type_names.each { |kind, names| used[kind] |= names }
214
- ["#{base}_query.rb", source]
245
+ used_unions |= codegen.used_union_names
246
+ ["#{base}_query.rb", out]
215
247
  end
216
248
 
217
- if namespace && used.values.any?(&:any?)
218
- shared = Codegen.generate_inputs(
219
- schema:, module_name: namespace,
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 = shared.to_a + 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
- # Call before generating.
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
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper