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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/CLAUDE.md +70 -0
- data/Gemfile.lock +21 -21
- data/README.md +2 -0
- data/docs/errors.md +25 -5
- data/docs/federation.md +100 -0
- data/docs/generated_modules.md +7 -4
- data/docs/getting_started.md +52 -0
- data/docs/scalars.md +15 -2
- data/lib/graph_weaver/client.rb +4 -3
- data/lib/graph_weaver/codegen/emit.rb +46 -10
- data/lib/graph_weaver/codegen/enum_type.rb +5 -5
- data/lib/graph_weaver/codegen/nodes.rb +21 -1
- data/lib/graph_weaver/codegen.rb +201 -13
- data/lib/graph_weaver/errors.rb +145 -19
- data/lib/graph_weaver/hints.rb +6 -1
- data/lib/graph_weaver/input_struct.rb +8 -0
- data/lib/graph_weaver/response.rb +2 -2
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +109 -41
- metadata +3 -1
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
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
299
|
-
def
|
|
300
|
-
Codegen.
|
|
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.
|
|
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
|