graph_weaver 0.2.2 → 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.
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
@@ -285,19 +351,19 @@ module GraphWeaver
285
351
  # GraphQL type — derived values live as methods next to the honest
286
352
  # wire data, and srb tc checks them against each query's selection:
287
353
  #
288
- # GraphWeaver.register_type("Pet", PetHelpers)
354
+ # GraphWeaver.extend_type("Pet", PetHelpers)
289
355
  #
290
356
  # Or build the mixin inline with a block (module_eval'd into an
291
357
  # auto-named module — quick, but invisible to srb tc):
292
358
  #
293
- # GraphWeaver.register_type("Pet") do
359
+ # GraphWeaver.extend_type("Pet") do
294
360
  # def display_name = "#{name} the pet"
295
361
  # end
296
362
  #
297
363
  # Additive (repeated and client-scoped registrations stack). Global;
298
- # client.register_type scopes to one client.
299
- def register_type(graphql_name, *mixins, requires: nil, &block)
300
- Codegen.register_type(graphql_name, *mixins, requires:, &block)
364
+ # client.extend_type scopes to one client.
365
+ def extend_type(graphql_name, *mixins, requires: nil, &block)
366
+ Codegen.extend_type(graphql_name, *mixins, requires:, &block)
301
367
  end
302
368
 
303
369
  # Restore the built-in scalars, dropping every custom registration —
@@ -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.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -227,6 +227,7 @@ extra_rdoc_files: []
227
227
  files:
228
228
  - ".yardopts"
229
229
  - CHANGELOG.md
230
+ - CLAUDE.md
230
231
  - Gemfile
231
232
  - Gemfile.lock
232
233
  - LICENSE.txt
@@ -236,6 +237,7 @@ files:
236
237
  - README.md
237
238
  - docs/cassettes.md
238
239
  - docs/errors.md
240
+ - docs/federation.md
239
241
  - docs/generated_modules.md
240
242
  - docs/getting_started.md
241
243
  - docs/logging.md