graph_weaver 0.6.0 → 0.7.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 +1470 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +21 -7
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +498 -105
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +32 -4
- data/docs/scalars.md +286 -57
- data/docs/testing.md +458 -59
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +330 -5
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +408 -206
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +43 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +490 -116
- metadata +56 -1
data/lib/graph_weaver.rb
CHANGED
|
@@ -7,10 +7,12 @@ require_relative "graph_weaver/errors"
|
|
|
7
7
|
require_relative "graph_weaver/coerce"
|
|
8
8
|
require_relative "graph_weaver/hints"
|
|
9
9
|
require_relative "graph_weaver/input_struct"
|
|
10
|
+
require_relative "graph_weaver/result_struct"
|
|
10
11
|
require_relative "graph_weaver/query_module"
|
|
11
12
|
require_relative "graph_weaver/response"
|
|
12
13
|
require_relative "graph_weaver/inflect"
|
|
13
14
|
require_relative "graph_weaver/codegen"
|
|
15
|
+
require_relative "graph_weaver/graph"
|
|
14
16
|
require_relative "graph_weaver/client"
|
|
15
17
|
require_relative "graph_weaver/in_process"
|
|
16
18
|
require_relative "graph_weaver/transport/http"
|
|
@@ -19,8 +21,6 @@ require_relative "graph_weaver/schema_loader"
|
|
|
19
21
|
require_relative "graph_weaver/version"
|
|
20
22
|
require_relative "graph_weaver/railtie" if defined?(::Rails::Railtie)
|
|
21
23
|
|
|
22
|
-
# opt-in extras:
|
|
23
|
-
# require "graph_weaver/transport/faraday" # Faraday transport
|
|
24
24
|
module GraphWeaver
|
|
25
25
|
# The line every generated file opens with (see Codegen::Emit) — the marker
|
|
26
26
|
# that tells generate! which files in the output directory are its to prune.
|
|
@@ -44,6 +44,21 @@ module GraphWeaver
|
|
|
44
44
|
Client.new(source, **options, &middleware)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Every setting below, in initializer style — the shape an app already
|
|
48
|
+
# knows from Sidekiq, Devise and GraphWeaver::Testing:
|
|
49
|
+
#
|
|
50
|
+
# GraphWeaver.configure do |config|
|
|
51
|
+
# config.client = GraphWeaver.new(url, auth: token)
|
|
52
|
+
# config.queries_paths = "app/graphql/operations"
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# `config` is GraphWeaver itself, so this is the same call as the flat
|
|
56
|
+
# `GraphWeaver.client = ...`; both spellings stay valid.
|
|
57
|
+
def configure
|
|
58
|
+
yield self
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
47
62
|
# The app's default client — how generated modules find their server:
|
|
48
63
|
#
|
|
49
64
|
# GraphWeaver.client = GraphWeaver.new(url, auth: token)
|
|
@@ -83,11 +98,11 @@ module GraphWeaver
|
|
|
83
98
|
end
|
|
84
99
|
def cast_data(struct, data, errors)
|
|
85
100
|
struct.from_h(data)
|
|
86
|
-
rescue GraphWeaver::
|
|
101
|
+
rescue GraphWeaver::CastError => e
|
|
87
102
|
raise if errors.empty?
|
|
88
103
|
|
|
89
104
|
detail = e.message.delete_prefix("failed to cast response into #{struct}: ")
|
|
90
|
-
raise GraphWeaver::
|
|
105
|
+
raise GraphWeaver::CastError.new(
|
|
91
106
|
struct:,
|
|
92
107
|
message: "#{detail} — the server also reported: #{errors.map(&:message).join("; ")}",
|
|
93
108
|
)
|
|
@@ -103,19 +118,19 @@ module GraphWeaver
|
|
|
103
118
|
def check_envelope!(raw, struct)
|
|
104
119
|
raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
|
|
105
120
|
unless raw.is_a?(Hash)
|
|
106
|
-
raise GraphWeaver::
|
|
121
|
+
raise GraphWeaver::CastError.new(struct:, message: "response must be an object, got #{raw.class}")
|
|
107
122
|
end
|
|
108
123
|
|
|
109
124
|
%w[data extensions].each do |key|
|
|
110
125
|
value = raw[key]
|
|
111
126
|
next if value.nil? || value.is_a?(Hash)
|
|
112
127
|
|
|
113
|
-
raise GraphWeaver::
|
|
128
|
+
raise GraphWeaver::CastError.new(struct:, message: "response #{key.inspect} must be an object, got #{value.class}")
|
|
114
129
|
end
|
|
115
130
|
|
|
116
131
|
errors = raw["errors"]
|
|
117
132
|
unless errors.nil? || (errors.is_a?(Array) && errors.all?(Hash))
|
|
118
|
-
raise GraphWeaver::
|
|
133
|
+
raise GraphWeaver::CastError.new(struct:, message: "response \"errors\" must be an array of objects")
|
|
119
134
|
end
|
|
120
135
|
|
|
121
136
|
# A response with neither key isn't a GraphQL response at all — a client
|
|
@@ -123,7 +138,7 @@ module GraphWeaver
|
|
|
123
138
|
# "dat". Each otherwise passes as a success carrying no data.
|
|
124
139
|
unless raw.key?("data") || raw.key?("errors")
|
|
125
140
|
found = raw.empty? ? "it is empty" : "got #{raw.keys.first(5).map(&:inspect).join(", ")}"
|
|
126
|
-
raise GraphWeaver::
|
|
141
|
+
raise GraphWeaver::CastError.new(struct:, message:
|
|
127
142
|
"response carried neither \"data\" nor \"errors\" — #{found}; " \
|
|
128
143
|
"the keys are the wire's own, as strings")
|
|
129
144
|
end
|
|
@@ -160,9 +175,9 @@ module GraphWeaver
|
|
|
160
175
|
# GraphWeaver.queries_paths = "app/graphql/operations"
|
|
161
176
|
# GraphWeaver.generated_paths << "spec/graphql/generated"
|
|
162
177
|
#
|
|
163
|
-
# schema_path is the exception, and singular on purpose:
|
|
164
|
-
#
|
|
165
|
-
# read. A second schema is a second
|
|
178
|
+
# schema_path is the exception, and singular on purpose: these settings
|
|
179
|
+
# describe ONE graph, so a second dump in a list is a file nothing would
|
|
180
|
+
# ever read. A second schema is a second graph (see #graph).
|
|
166
181
|
attr_writer :schema_path
|
|
167
182
|
|
|
168
183
|
def queries_paths = @queries_paths ||= ["app/graphql/queries"]
|
|
@@ -203,6 +218,160 @@ module GraphWeaver
|
|
|
203
218
|
|
|
204
219
|
def types_module = @types_module || "GraphQLTypes"
|
|
205
220
|
|
|
221
|
+
# Declare a second schema — and a third, and the rest. Everything a graph
|
|
222
|
+
# knows is said inside its block, in call style:
|
|
223
|
+
#
|
|
224
|
+
# GraphWeaver.graph :billing do
|
|
225
|
+
# schema -> { Billing::Schema }
|
|
226
|
+
# queries "app/graphql/billing/queries"
|
|
227
|
+
# output "app/graphql/billing/generated"
|
|
228
|
+
# client Billing::Schema
|
|
229
|
+
# namespace "Billing"
|
|
230
|
+
# register_scalar "Money", BigDecimal
|
|
231
|
+
# end
|
|
232
|
+
#
|
|
233
|
+
# Every entry point then walks the list: one `rake graph_weaver:generate`
|
|
234
|
+
# does the app, one `verify` gates it, `check_queries` checks each graph
|
|
235
|
+
# against its own schema.
|
|
236
|
+
#
|
|
237
|
+
# Six settings — schema, queries, output, client, namespace, types_module —
|
|
238
|
+
# and the three registrations you already write at the top level. `schema "x"`
|
|
239
|
+
# sets and bare `schema` reads back; there is no `schema = "x"` form, since
|
|
240
|
+
# the block is instance_eval'd and that would be a local variable (this is
|
|
241
|
+
# graphql-ruby's `field :x` convention). Anything else the block calls is
|
|
242
|
+
# refused, naming the nine it takes.
|
|
243
|
+
#
|
|
244
|
+
# Each setting falls back to the matching top-level one, so a graph says
|
|
245
|
+
# only what differs; `namespace` nests everything that graph generates (the
|
|
246
|
+
# query modules and its shared types module) so two schemas with a
|
|
247
|
+
# person.graphql don't fight over one constant. The registrations reach this
|
|
248
|
+
# graph alone, on top of the top-level ones as they stand at declaration.
|
|
249
|
+
#
|
|
250
|
+
# The block runs where it is written. `schema` also takes a callable, which
|
|
251
|
+
# is how a Rails app names an autoloaded schema class from an initializer:
|
|
252
|
+
# `schema -> { Billing::Schema }` resolves when generation asks, and resolves
|
|
253
|
+
# again after a dev reload has replaced the class object. A registration
|
|
254
|
+
# naming an autoloaded constant has the same problem, and the same answer
|
|
255
|
+
# top-level registrations have always had (Codegen::AUTOLOAD_HINT).
|
|
256
|
+
#
|
|
257
|
+
# The name is the graph's identity, so re-declaring one REPLACES it —
|
|
258
|
+
# declaring from a `to_prepare` block, which re-runs on every reload, is
|
|
259
|
+
# safe. Declaring any graph replaces the implicit one the settings
|
|
260
|
+
# describe: an app either has graphs or has settings, never a silent third
|
|
261
|
+
# thing.
|
|
262
|
+
def graph(name, **keywords, &block)
|
|
263
|
+
unless name.is_a?(Symbol) || name.is_a?(String)
|
|
264
|
+
# codegen writes the name into every module this graph generates, so
|
|
265
|
+
# it has to be something source can spell
|
|
266
|
+
raise ArgumentError, "graph name must be a Symbol or a String, got #{name.inspect}"
|
|
267
|
+
end
|
|
268
|
+
# the name is the graph's identity, so the two spellings are one graph —
|
|
269
|
+
# re-declaring as `graph "billing"` replaces `graph :billing` rather than
|
|
270
|
+
# quietly making a second one that generates over the first's output
|
|
271
|
+
name = name.to_sym
|
|
272
|
+
unless keywords.empty?
|
|
273
|
+
raise ArgumentError, "GraphWeaver.graph takes a block, not keywords — say " \
|
|
274
|
+
"#{keywords.keys.join(", ")} inside it:\n" \
|
|
275
|
+
" GraphWeaver.graph #{name.inspect} do\n" \
|
|
276
|
+
" #{keywords.keys.first} #{keywords.values.first.inspect}\n" \
|
|
277
|
+
" end"
|
|
278
|
+
end
|
|
279
|
+
unless block
|
|
280
|
+
raise ArgumentError, "GraphWeaver.graph #{name.inspect} needs a block — a graph says " \
|
|
281
|
+
"what it is inside one"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
graph = Internal::GraphBuilder.build(name, &block)
|
|
285
|
+
@graphs ||= []
|
|
286
|
+
# replace in place, so the declaration order an app wrote is the order
|
|
287
|
+
# generate! reports in however many times the initializer has re-run
|
|
288
|
+
existing = @graphs.index { |candidate| candidate.name == name }
|
|
289
|
+
existing ? @graphs[existing] = graph : @graphs << graph
|
|
290
|
+
graph
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Every graph an entry point walks: the declared ones, or the single graph
|
|
294
|
+
# the top-level settings describe. Never empty.
|
|
295
|
+
def graphs = @graphs&.dup || [default_graph]
|
|
296
|
+
|
|
297
|
+
# Forget every declared graph — back to the settings alone.
|
|
298
|
+
def reset_graphs!
|
|
299
|
+
@graphs = nil
|
|
300
|
+
self
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The graph the settings describe. Built fresh each time: the settings are
|
|
304
|
+
# writable, and it holds the default registry rather than a copy so a
|
|
305
|
+
# top-level register_scalar reaches it.
|
|
306
|
+
def default_graph = Graph.new
|
|
307
|
+
private :default_graph
|
|
308
|
+
|
|
309
|
+
# The graphs one generate!/verify run covers. Explicit arguments describe
|
|
310
|
+
# one graph inline — the call generate! has always taken — and otherwise
|
|
311
|
+
# it is every graph.
|
|
312
|
+
def graphs_for(**overrides)
|
|
313
|
+
overrides = overrides.compact
|
|
314
|
+
return [Graph.new(**overrides)] unless overrides.empty?
|
|
315
|
+
|
|
316
|
+
refuse_orphaned_paths!
|
|
317
|
+
graphs
|
|
318
|
+
end
|
|
319
|
+
private :graphs_for
|
|
320
|
+
|
|
321
|
+
# Declaring a graph replaces the implicit one the top-level settings
|
|
322
|
+
# describe — so nothing then reads the directories those settings name, and
|
|
323
|
+
# an app that declared a graph beside its existing queries got no word
|
|
324
|
+
# about it. A .graphql left behind generates nothing, verify calls the tree
|
|
325
|
+
# up to date and queries:check calls the file validated; a generated module
|
|
326
|
+
# left behind is regenerated by nothing, pruned by nothing, and loaded by
|
|
327
|
+
# the railtie every boot. The docs promise an app either has graphs or has
|
|
328
|
+
# settings and never a silent third thing, so this is where that is true.
|
|
329
|
+
#
|
|
330
|
+
# Only an app that declared graphs can have one: for every other app the
|
|
331
|
+
# settings ARE its graph, and everything under them is covered by
|
|
332
|
+
# definition.
|
|
333
|
+
def refuse_orphaned_paths!
|
|
334
|
+
return unless @graphs
|
|
335
|
+
|
|
336
|
+
strays = Internal::Util.query_files(queries_paths) -
|
|
337
|
+
graphs.flat_map { |graph| Internal::Util.query_files(graph.queries) }
|
|
338
|
+
# generated_paths holds globs, so a graph's own output is found by this
|
|
339
|
+
# sweep — matched by where it writes rather than by how the glob spells it
|
|
340
|
+
written = graphs.map { |graph| Internal::Util.resolve(graph.output) }
|
|
341
|
+
strays += generated_paths.flat_map { |path| generated_files(path) }
|
|
342
|
+
.reject { |file| written.any? { |dir| file == dir || file.start_with?("#{dir}/") } }
|
|
343
|
+
return if strays.empty?
|
|
344
|
+
|
|
345
|
+
covers = graphs.map do |graph|
|
|
346
|
+
" #{graph.name ? "graph #{graph.name.inspect}" : "this app"}: " \
|
|
347
|
+
"#{Array(graph.queries).join(", ")} -> #{Internal::Util.relative(graph.output)}"
|
|
348
|
+
end
|
|
349
|
+
raise Error, ["these files are under directories no declared graph covers:",
|
|
350
|
+
*strays.uniq.sort.map { |path| " #{Internal::Util.relative(path)}" },
|
|
351
|
+
"Nothing generates or prunes them, verify and queries:check pass over them, and a " \
|
|
352
|
+
"generated module left here still loads in production. The graphs this app declared cover:",
|
|
353
|
+
*covers,
|
|
354
|
+
"These are the directories the top-level settings name — the graph this app had before " \
|
|
355
|
+
"it declared any — so declaring a second graph means declaring the first too:",
|
|
356
|
+
*settings_as_graph,
|
|
357
|
+
"Its directories and nothing else: no schema, so it reads the one the settings already " \
|
|
358
|
+
"name, and no namespace:, which keeps every constant name exactly as it is. Or name " \
|
|
359
|
+
"these directories in a graph above (queries/output in its block), or delete them."].join("\n")
|
|
360
|
+
end
|
|
361
|
+
private :refuse_orphaned_paths!
|
|
362
|
+
|
|
363
|
+
# The graph the top-level settings already describe, spelled as a
|
|
364
|
+
# declaration — the fix for the commonest way to arrive at the refusal
|
|
365
|
+
# above, which is declaring graph two in an app that had only settings.
|
|
366
|
+
def settings_as_graph
|
|
367
|
+
queries = queries_paths.map { |path| Internal::Util.relative(path).inspect }
|
|
368
|
+
[" GraphWeaver.graph :app do",
|
|
369
|
+
" queries #{queries.join(", ")}",
|
|
370
|
+
" output #{Internal::Util.relative(generated_paths.first).inspect}",
|
|
371
|
+
" end"]
|
|
372
|
+
end
|
|
373
|
+
private :settings_as_graph
|
|
374
|
+
|
|
206
375
|
# Generate every query in a directory — .graphql/.gql, subdirectories
|
|
207
376
|
# included — into checked-in Ruby files. Paths default to the conventions
|
|
208
377
|
# above; schema: defaults to the dump at schema_path (any supported
|
|
@@ -211,26 +380,44 @@ module GraphWeaver
|
|
|
211
380
|
#
|
|
212
381
|
# GraphWeaver.generate! # queries_paths -> generated_paths.first
|
|
213
382
|
#
|
|
383
|
+
# With no arguments it generates every graph (see #graph) — which, for an
|
|
384
|
+
# app that declared none, is the one the settings above describe. Arguments
|
|
385
|
+
# describe one graph inline instead.
|
|
386
|
+
#
|
|
214
387
|
# person.graphql => person_query.rb defining PersonQuery. Returns every
|
|
215
388
|
# path the plan produces; a file already byte-identical is left untouched
|
|
216
389
|
# (see #changed_files). Generated files the plan no longer produces are deleted
|
|
217
390
|
# (see #orphaned), so renaming or dropping a .graphql leaves nothing
|
|
218
391
|
# behind. Pair with a freshness spec (docs/generated_modules.md).
|
|
219
|
-
def generate!(schema: nil, queries:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
392
|
+
def generate!(schema: nil, queries: nil, output: nil, client: nil, types_module: nil)
|
|
393
|
+
@changed_files = []
|
|
394
|
+
@unmatched_registrations = []
|
|
395
|
+
@untyped_scalars_by_graph = {}
|
|
396
|
+
seen = new_seen
|
|
397
|
+
|
|
398
|
+
# Every plan first, then every write. Generation refusing must leave the
|
|
399
|
+
# tree exactly as it was — the railtie's watch mode regenerates on a
|
|
400
|
+
# request and promises a failed save changes nothing, and that promise
|
|
401
|
+
# was true within a graph and false across them.
|
|
402
|
+
planned = graphs_for(schema:, queries:, output:, client:, types_module:).map do |graph|
|
|
403
|
+
if Internal::Util.query_files(graph.queries).empty?
|
|
404
|
+
# a brand-new app legitimately has none; a mistyped queries_paths looks
|
|
405
|
+
# exactly the same, and prints nothing either way
|
|
406
|
+
Internal::Log.log(:warn) do
|
|
407
|
+
"no query documents under #{Array(graph.queries).join(", ")}#{graph.described} — nothing to generate"
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
[graph, generation_plan(graph, seen)]
|
|
227
412
|
end
|
|
228
413
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
414
|
+
planned.flat_map { |graph, plan| write_plan!(graph, plan) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# One graph's plan onto disk, pruning what it no longer produces.
|
|
418
|
+
def write_plan!(graph, plan)
|
|
232
419
|
written = plan.map do |filename, source|
|
|
233
|
-
target = File.join(Internal::Util.resolve(output), filename)
|
|
420
|
+
target = File.join(Internal::Util.resolve(graph.output), filename)
|
|
234
421
|
next target if current?(target, source)
|
|
235
422
|
|
|
236
423
|
FileUtils.mkdir_p(File.dirname(target))
|
|
@@ -244,13 +431,28 @@ module GraphWeaver
|
|
|
244
431
|
target
|
|
245
432
|
end
|
|
246
433
|
|
|
247
|
-
orphaned(output, written)
|
|
434
|
+
pruned = orphaned(graph.output, written)
|
|
435
|
+
pruned.each do |orphan|
|
|
248
436
|
File.delete(orphan)
|
|
249
437
|
Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
|
|
250
438
|
end
|
|
439
|
+
prune_empty!(graph, pruned)
|
|
251
440
|
|
|
252
441
|
written.map { |target| Internal::Util.relative(target) }
|
|
253
442
|
end
|
|
443
|
+
private :write_plan!
|
|
444
|
+
|
|
445
|
+
# Pruning promises to leave nothing behind, and the types/ directory whose
|
|
446
|
+
# last file just went is something behind. One level deep is the whole of
|
|
447
|
+
# it: a generated file's name is its base name, so types/ is the only
|
|
448
|
+
# subdirectory generation makes.
|
|
449
|
+
def prune_empty!(graph, pruned)
|
|
450
|
+
output = Internal::Util.resolve(graph.output)
|
|
451
|
+
pruned.map { |orphan| File.dirname(orphan) }.uniq.each do |dir|
|
|
452
|
+
Dir.rmdir(dir) if dir != output && Dir.exist?(dir) && Dir.empty?(dir)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
private :prune_empty!
|
|
254
456
|
|
|
255
457
|
# Which of those files the last generate! actually wrote — the rest were
|
|
256
458
|
# already byte-identical, so a run that changed one query touches one file
|
|
@@ -286,33 +488,71 @@ module GraphWeaver
|
|
|
286
488
|
# it "generated queries are current" do
|
|
287
489
|
# GraphWeaver.verify_generated!
|
|
288
490
|
# end
|
|
289
|
-
def verify_generated!(schema: nil, queries:
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
491
|
+
def verify_generated!(schema: nil, queries: nil, output: nil, client: nil, types_module: nil)
|
|
492
|
+
@unmatched_registrations = []
|
|
493
|
+
@untyped_scalars_by_graph = {}
|
|
494
|
+
seen = new_seen
|
|
495
|
+
graphs = graphs_for(schema:, queries:, output:, client:, types_module:)
|
|
496
|
+
|
|
497
|
+
# The dump is checked in too, and everything below reads it — so a
|
|
498
|
+
# stale one is answered before staleness downstream of it, because
|
|
499
|
+
# refreshing has to come first. Asked only where it can be: a live
|
|
500
|
+
# class, and one introspection of it.
|
|
501
|
+
graphs.each { |graph| refuse_stale_dump!(graph) }
|
|
502
|
+
|
|
503
|
+
graphs.each do |graph|
|
|
504
|
+
if Internal::Util.query_files(graph.queries).empty?
|
|
505
|
+
# green over nothing is worse than red: a CI gate stays passing
|
|
506
|
+
# forever because someone typed app/graphql/querys
|
|
507
|
+
raise Error, "no query documents under #{Array(graph.queries).join(", ")}#{graph.described} — " \
|
|
508
|
+
"this checked nothing, so it proved nothing (set GraphWeaver.queries_paths, or pass queries:)"
|
|
509
|
+
end
|
|
297
510
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
|
|
511
|
+
plan = generation_plan(graph, seen)
|
|
512
|
+
resolved = ->(filename) { File.join(Internal::Util.resolve(graph.output), filename) }
|
|
513
|
+
stale = plan.filter_map do |filename, source|
|
|
514
|
+
target = resolved.call(filename)
|
|
515
|
+
target unless current?(target, source)
|
|
516
|
+
end
|
|
517
|
+
# strays: a generated file the current schema + queries no longer produce
|
|
518
|
+
stale += orphaned(graph.output, plan.map { |filename, _| resolved.call(filename) })
|
|
307
519
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
520
|
+
unless stale.empty?
|
|
521
|
+
raise Error, "stale generated queries#{graph.described} — regenerate (rake graph_weaver:generate): " \
|
|
522
|
+
"#{stale.map { |path| Internal::Util.relative(path) }.join(", ")}"
|
|
523
|
+
end
|
|
311
524
|
end
|
|
312
525
|
|
|
313
526
|
true
|
|
314
527
|
end
|
|
315
528
|
|
|
529
|
+
# A dump that has fallen behind the graphql-ruby class it was built
|
|
530
|
+
# from. `verify` asks whether what is checked in is current, and for an
|
|
531
|
+
# app that serves its own schema the dump is an artifact derived from
|
|
532
|
+
# code in the same repo: it drifts silently, and generating from it
|
|
533
|
+
# then produces stale Ruby that verify would call fresh.
|
|
534
|
+
#
|
|
535
|
+
# Only a live class is asked — it answers introspection in-process, so
|
|
536
|
+
# this costs one introspection and no network. A dump that records a
|
|
537
|
+
# url is `schema:diff`'s subject: reaching for it here would put a
|
|
538
|
+
# network call in every build and in every spec that calls this.
|
|
539
|
+
def refuse_stale_dump!(graph)
|
|
540
|
+
path = graph.dump_path
|
|
541
|
+
source = graph.dump_source
|
|
542
|
+
return unless path && source.is_a?(Module)
|
|
543
|
+
|
|
544
|
+
diff = SchemaLoader.diff(path, transport: source)
|
|
545
|
+
return if diff.empty?
|
|
546
|
+
|
|
547
|
+
breaking = diff.breaking.size
|
|
548
|
+
raise Error, "the dump is behind the schema#{graph.described} — " \
|
|
549
|
+
"#{Internal::Util.relative(path)} no longer matches #{SchemaLoader.endpoint(source)} " \
|
|
550
|
+
"(#{diff.changes.size} #{(diff.changes.size == 1) ? "change" : "changes"}, " \
|
|
551
|
+
"#{breaking} breaking). Run rake graph_weaver:schema:refresh, then rake " \
|
|
552
|
+
"graph_weaver:generate — rake graph_weaver:schema:diff names what moved."
|
|
553
|
+
end
|
|
554
|
+
private :refuse_stale_dump!
|
|
555
|
+
|
|
316
556
|
# Whether the file on disk is already what the plan would write — asked
|
|
317
557
|
# before writing it, and before calling it stale. autocrlf rewrites line
|
|
318
558
|
# endings on checkout, and a Windows working copy is neither stale
|
|
@@ -328,6 +568,21 @@ module GraphWeaver
|
|
|
328
568
|
# instead of once per query file.
|
|
329
569
|
def unmatched_registrations = @unmatched_registrations || []
|
|
330
570
|
|
|
571
|
+
# The custom scalars the last generate!/verify_generated! found no
|
|
572
|
+
# registration for — they generate as T.untyped, which is legitimate but
|
|
573
|
+
# is the one hole in an otherwise exact result type. Names, sorted, empty
|
|
574
|
+
# when every scalar a query touched is registered. The same list codegen
|
|
575
|
+
# logs at info, kept here so the build can print it once instead of once
|
|
576
|
+
# per query file.
|
|
577
|
+
def untyped_scalars = untyped_scalars_by_graph.values.flatten.uniq.sort
|
|
578
|
+
|
|
579
|
+
# The same, kept apart by the graph that found them. A registration is
|
|
580
|
+
# scoped to one graph, so a scalar registered for one and forgotten for
|
|
581
|
+
# the next is a different finding from one forgotten everywhere — and a
|
|
582
|
+
# merged list can't tell them apart. Keyed by graph name, nil for an app
|
|
583
|
+
# that declared none.
|
|
584
|
+
def untyped_scalars_by_graph = @untyped_scalars_by_graph || {}
|
|
585
|
+
|
|
331
586
|
# Which checked-in queries no longer validate — breaking-change
|
|
332
587
|
# detection scoped to the operations you actually ship. Reports rather
|
|
333
588
|
# than raising, keyed by file, JSON-ready like every #to_h here:
|
|
@@ -350,29 +605,42 @@ module GraphWeaver
|
|
|
350
605
|
# A different question from verify_generated!, which asks whether the
|
|
351
606
|
# committed Ruby matches the committed schema. `rake
|
|
352
607
|
# graph_weaver:queries:check` prints this and exits non-zero.
|
|
353
|
-
def check_queries(schema: nil, queries:
|
|
354
|
-
# subgraph branding comes from the local supergraph dump, so a caller
|
|
355
|
-
# supplying its own schema opts out of it
|
|
356
|
-
table = schema ? nil : checked_routing_table
|
|
357
|
-
schema = schema ? schema_for(schema) : refreshed_schema
|
|
608
|
+
def check_queries(schema: nil, queries: nil, fragments: fragments_paths)
|
|
358
609
|
shared = Codegen.load_fragments(fragments)
|
|
359
610
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
611
|
+
graphs_for(schema:, queries:).each_with_object({}) do |graph, failures|
|
|
612
|
+
checked = checked_schema(graph)
|
|
613
|
+
table = checked_routing_table(graph)
|
|
614
|
+
Internal::Util.query_files(graph.queries).each do |path|
|
|
615
|
+
errors = validation_errors(checked, File.read(path), shared, table)
|
|
616
|
+
next if errors.empty?
|
|
617
|
+
|
|
618
|
+
# keyed by file, as it has always been — and two graphs may share a
|
|
619
|
+
# queries directory (the same operations against two subgraphs), so
|
|
620
|
+
# the second one's errors join the first's rather than replacing them
|
|
621
|
+
key = Internal::Util.relative(path)
|
|
622
|
+
failures[key] = failures.key?(key) ? failures[key] | errors : errors
|
|
623
|
+
end
|
|
363
624
|
end
|
|
364
625
|
end
|
|
365
626
|
|
|
627
|
+
# What this graph is checked against. A graph that names its schema is
|
|
628
|
+
# checked against exactly that, so nothing touches the network; the default
|
|
629
|
+
# graph names none, so its dump is re-introspected first (see
|
|
630
|
+
# refreshed_schema).
|
|
631
|
+
def checked_schema(graph) = graph.named_schema? ? graph.schema : refreshed_schema
|
|
632
|
+
private :checked_schema
|
|
633
|
+
|
|
366
634
|
# The routing table behind the schema check_queries is about to use,
|
|
367
635
|
# when there is one: a composed supergraph dump says who resolves what,
|
|
368
636
|
# so a validation error can name the subgraph whose code to look at. nil
|
|
369
637
|
# for every other source — a plain schema is entirely unaffected — and
|
|
370
638
|
# nil when a live schema class is what gets checked, since the dump then
|
|
371
639
|
# isn't what the errors came from.
|
|
372
|
-
def checked_routing_table
|
|
373
|
-
return if
|
|
640
|
+
def checked_routing_table(graph)
|
|
641
|
+
return if graph.live_schema
|
|
374
642
|
|
|
375
|
-
path =
|
|
643
|
+
path = graph.dump_path
|
|
376
644
|
return unless path&.end_with?(".graphql", ".gql")
|
|
377
645
|
|
|
378
646
|
sdl = File.read(path)
|
|
@@ -392,8 +660,8 @@ module GraphWeaver
|
|
|
392
660
|
live = Internal::Util.live_schema
|
|
393
661
|
return live if live
|
|
394
662
|
|
|
395
|
-
# locate_schema! raises the conventional "no schema dump" message
|
|
396
|
-
path = SchemaLoader.locate_path or locate_schema!
|
|
663
|
+
# Internal::Util.locate_schema! raises the conventional "no schema dump" message
|
|
664
|
+
path = SchemaLoader.locate_path or Internal::Util.locate_schema!
|
|
397
665
|
return SchemaLoader.load(path) unless SchemaLoader.provenance(path)&.key?("url")
|
|
398
666
|
|
|
399
667
|
# source_transport rather than one built here: it reads the auth ENV var
|
|
@@ -420,12 +688,12 @@ module GraphWeaver
|
|
|
420
688
|
}
|
|
421
689
|
subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
|
|
422
690
|
end
|
|
423
|
-
rescue GraphWeaver::
|
|
691
|
+
rescue GraphWeaver::QueryValidationError => e
|
|
424
692
|
# an unparseable query: codegen folds the position (and the file) into
|
|
425
693
|
# the message, and this report keeps them separate — same splitter the
|
|
426
694
|
# rendered error uses, so the two can't drift apart
|
|
427
695
|
e.errors.map do |detail|
|
|
428
|
-
_path, _position, message =
|
|
696
|
+
_path, _position, message = QueryValidationError.split(detail)
|
|
429
697
|
detail.transform_keys(&:to_s).merge("message" => message)
|
|
430
698
|
end
|
|
431
699
|
end
|
|
@@ -460,19 +728,34 @@ module GraphWeaver
|
|
|
460
728
|
# generated code only changes on regeneration anyway (restart, like
|
|
461
729
|
# a schema migration).
|
|
462
730
|
def load_generated!(path = nil)
|
|
463
|
-
paths = path ? [path] :
|
|
731
|
+
paths = path ? [path] : Internal::Util.generated_dirs
|
|
464
732
|
files = paths.flat_map { |dir| Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].sort }.uniq
|
|
465
733
|
files.each do |file|
|
|
466
734
|
require file
|
|
467
735
|
rescue NameError => e
|
|
468
736
|
reported = Internal::Util.relative(file)
|
|
737
|
+
# Zeitwerk autoloading the file rather than this require: it expects
|
|
738
|
+
# the constant the path spells, and generated modules are top-level.
|
|
739
|
+
# Nothing was dropped, so the advice below would send you hunting for
|
|
740
|
+
# a registration that is still there.
|
|
741
|
+
if zeitwerk_name_error?(e)
|
|
742
|
+
raise Error, "#{reported} can't load: #{e.message}. Zeitwerk owns that directory, and a " \
|
|
743
|
+
"generated module defines a top-level constant — so the one Zeitwerk expects never appears. " \
|
|
744
|
+
"The directory has to be hidden from autoloading: name it in GraphWeaver.generated_paths " \
|
|
745
|
+
"from config/initializers (the Railtie ignores what is listed there, and only before Rails " \
|
|
746
|
+
"sets Zeitwerk up), or generate into the conventional app/graphql/*/generated."
|
|
747
|
+
end
|
|
748
|
+
|
|
469
749
|
# a dropped extend_type leaves this include dangling; say so here,
|
|
470
750
|
# because the raw NameError points at generated code and names no fix
|
|
471
|
-
helper
|
|
751
|
+
# a block-built helper is named for its graph as well as its type
|
|
752
|
+
# (TypeHelpers::Billing::Pet), so the constant says where to look
|
|
753
|
+
# rather than spelling the extend_type back
|
|
754
|
+
helper = e.message[/GraphWeaver::TypeHelpers::[\w:]+/]
|
|
472
755
|
if helper
|
|
473
|
-
raise Error, "#{reported} includes
|
|
474
|
-
"
|
|
475
|
-
"
|
|
756
|
+
raise Error, "#{reported} includes #{helper}, but nothing registers it — the extend_type " \
|
|
757
|
+
"block it was generated from is gone. Re-add that registration, or regenerate without " \
|
|
758
|
+
"it: rake graph_weaver:generate"
|
|
476
759
|
end
|
|
477
760
|
|
|
478
761
|
# an app's own mixin or enum class named by extend_type/register_enum
|
|
@@ -498,10 +781,15 @@ module GraphWeaver
|
|
|
498
781
|
# whose query was just deleted keeps its old constant until restart —
|
|
499
782
|
# nothing on disk says what it was called any more.
|
|
500
783
|
def reload_generated!
|
|
501
|
-
names =
|
|
784
|
+
names = graphs.flat_map do |graph|
|
|
785
|
+
modules = Internal::Util.query_files(graph.queries).map do |path|
|
|
786
|
+
graph.generated_names(path, File.read(path)).first
|
|
787
|
+
end
|
|
788
|
+
modules << graph.types_module
|
|
789
|
+
end
|
|
502
790
|
names.each { |name| undefine(name) }
|
|
503
791
|
|
|
504
|
-
|
|
792
|
+
Internal::Util.generated_dirs.each do |dir|
|
|
505
793
|
Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].each do |file|
|
|
506
794
|
# require stores the realpath; the path load_generated! passed is
|
|
507
795
|
# the other one under a symlinked checkout
|
|
@@ -512,6 +800,19 @@ module GraphWeaver
|
|
|
512
800
|
load_generated!
|
|
513
801
|
end
|
|
514
802
|
|
|
803
|
+
# The class when zeitwerk is loaded; its message otherwise, since the gem
|
|
804
|
+
# doesn't depend on zeitwerk and load_generated! runs outside Rails too.
|
|
805
|
+
# const_get rather than a bare Zeitwerk, as in rails_root: sorbet can't
|
|
806
|
+
# resolve a constant the gem doesn't depend on.
|
|
807
|
+
def zeitwerk_name_error?(error)
|
|
808
|
+
if Object.const_defined?("Zeitwerk::NameError")
|
|
809
|
+
error.is_a?(Object.const_get("Zeitwerk::NameError"))
|
|
810
|
+
else
|
|
811
|
+
error.message.match?(/\Aexpected file .* to define constant /)
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
private :zeitwerk_name_error?
|
|
815
|
+
|
|
515
816
|
# remove_const takes a bare name, and types_module may be namespaced
|
|
516
817
|
def undefine(name)
|
|
517
818
|
*outer, base = name.split("::")
|
|
@@ -524,26 +825,6 @@ module GraphWeaver
|
|
|
524
825
|
end
|
|
525
826
|
private :undefine
|
|
526
827
|
|
|
527
|
-
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
528
|
-
# the console object and the rake task point at the same thing. A path
|
|
529
|
-
# (String or Pathname) or SDL loads like it does everywhere else in the
|
|
530
|
-
# library; without that it reached `schema.validate` as itself and failed
|
|
531
|
-
# as `undefined method 'validate' for an instance of String`.
|
|
532
|
-
def schema_for(source)
|
|
533
|
-
return source.schema if source.is_a?(Client)
|
|
534
|
-
return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
|
|
535
|
-
|
|
536
|
-
source
|
|
537
|
-
end
|
|
538
|
-
private :schema_for
|
|
539
|
-
|
|
540
|
-
# the conventional schema dump, required
|
|
541
|
-
def locate_schema!
|
|
542
|
-
SchemaLoader.locate or raise Error,
|
|
543
|
-
"no schema dump at #{schema_path} (.json/.graphql/.gql) — pass schema:, or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
544
|
-
end
|
|
545
|
-
private :locate_schema!
|
|
546
|
-
|
|
547
828
|
# (filename, source) per artifact. Types a schema shares across queries —
|
|
548
829
|
# input types, schema enums, and each named shared fragment spread as a
|
|
549
830
|
# whole-union field — are emitted once into the shared module, with query
|
|
@@ -551,51 +832,119 @@ module GraphWeaver
|
|
|
551
832
|
# duplicated bool_exp structs (or one Ruby class per query for the same
|
|
552
833
|
# schema enum) and one copy per schema. (Single-query parse inlines
|
|
553
834
|
# everything — there's no cross-query set to share against.)
|
|
554
|
-
|
|
555
|
-
|
|
835
|
+
# What one run has already produced, shared across every graph in it:
|
|
836
|
+
# constants are global and output files are just files, so two graphs
|
|
837
|
+
# landing on either would overwrite each other silently.
|
|
838
|
+
def new_seen = { modules: {}, files: {}, types: {} }
|
|
839
|
+
private :new_seen
|
|
840
|
+
|
|
841
|
+
def generation_plan(graph, seen = new_seen, fragments: fragments_paths)
|
|
842
|
+
@untyped_scalars_by_graph ||= {}
|
|
843
|
+
found = (@untyped_scalars_by_graph[graph.name] ||= [])
|
|
844
|
+
schema = graph.schema
|
|
845
|
+
registry = graph.registry
|
|
846
|
+
@unmatched_registrations |= registry.unmatched_registrations(schema)
|
|
847
|
+
|
|
556
848
|
used = { inputs: [], enums: [], mapped: [] }
|
|
557
849
|
used_unions = []
|
|
558
850
|
shared = Codegen.load_fragments(fragments)
|
|
559
851
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
plan = Internal::Util.query_files(queries).map do |path|
|
|
852
|
+
plan = Internal::Util.query_files(graph.queries).map do |path|
|
|
563
853
|
source = File.read(path)
|
|
564
|
-
name, filename =
|
|
565
|
-
|
|
566
|
-
raise Error, "duplicate query module #{name} — #{Internal::Util.relative(earlier)} and " \
|
|
567
|
-
"#{Internal::Util.relative(path)} both generate it; " \
|
|
568
|
-
"the module name comes from the file name alone (directories don't namespace it), so rename one"
|
|
569
|
-
end
|
|
570
|
-
seen[name] = path
|
|
854
|
+
name, filename = graph.generated_names(path, source)
|
|
855
|
+
refuse_duplicate!(seen, name, filename, graph, path)
|
|
571
856
|
|
|
572
857
|
codegen = Codegen.new(
|
|
573
858
|
schema:,
|
|
574
859
|
query: Codegen.inline_fragments(source, shared, path),
|
|
575
860
|
name:,
|
|
576
|
-
client
|
|
577
|
-
|
|
861
|
+
client: graph.client,
|
|
862
|
+
graph_name: graph.name,
|
|
863
|
+
types_namespace: graph.types_module,
|
|
578
864
|
hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
|
|
579
865
|
path:,
|
|
866
|
+
registry:,
|
|
580
867
|
)
|
|
581
868
|
out = codegen.generate
|
|
582
869
|
codegen.variable_type_names.each { |kind, names| used[kind] |= names }
|
|
870
|
+
found.concat(codegen.untyped_scalars).uniq!
|
|
583
871
|
used_unions |= codegen.used_union_names
|
|
584
872
|
[filename, out]
|
|
585
873
|
end
|
|
586
874
|
|
|
587
875
|
if used_unions.any? || used.values.any?(&:any?)
|
|
588
|
-
|
|
589
|
-
|
|
876
|
+
refuse_duplicate_types!(seen, graph)
|
|
877
|
+
codegen = Codegen.new(schema:, query: "", name: graph.types_module, registry:)
|
|
878
|
+
types = codegen.generate_types(
|
|
590
879
|
inputs: used[:inputs], enums: used[:enums] + used[:mapped],
|
|
591
880
|
unions: used_unions, fragments: shared,
|
|
592
|
-
)
|
|
881
|
+
)
|
|
882
|
+
found.concat(codegen.untyped_scalars).uniq!
|
|
883
|
+
# these land in the graph's output like any other file, so they collide
|
|
884
|
+
# with another graph's the same way
|
|
885
|
+
types.each_key { |filename| refuse_duplicate_file!(seen, filename, graph, graph.types_module) }
|
|
886
|
+
plan = types.to_a + plan
|
|
593
887
|
end
|
|
594
888
|
|
|
595
889
|
plan
|
|
596
890
|
end
|
|
597
891
|
private :generation_plan
|
|
598
892
|
|
|
893
|
+
# Two query files landing on one constant, or on one output file. Within a
|
|
894
|
+
# graph the fix is a rename, as it has always been; across two graphs it is
|
|
895
|
+
# `namespace:` for the constant and `output:` for the file — so the message
|
|
896
|
+
# names whichever one applies.
|
|
897
|
+
#
|
|
898
|
+
# The two checks are separate because a namespace fixes only the first:
|
|
899
|
+
# `person_query.rb` is named after `person.graphql` whatever module it
|
|
900
|
+
# defines, so two graphs sharing an output directory still collide there.
|
|
901
|
+
def refuse_duplicate!(seen, name, filename, graph, path)
|
|
902
|
+
refuse_collision!(seen[:modules], name, graph, path, "query module #{name}",
|
|
903
|
+
across: "give one of the graphs a namespace:, or rename one of the files",
|
|
904
|
+
within: "the module name comes from the file name alone (directories don't " \
|
|
905
|
+
"namespace it), so rename one")
|
|
906
|
+
refuse_duplicate_file!(seen, filename, graph, path)
|
|
907
|
+
end
|
|
908
|
+
private :refuse_duplicate!
|
|
909
|
+
|
|
910
|
+
def refuse_duplicate_file!(seen, filename, graph, path)
|
|
911
|
+
target = File.join(Internal::Util.resolve(graph.output), filename)
|
|
912
|
+
refuse_collision!(seen[:files], target, graph, path,
|
|
913
|
+
"generated file #{Internal::Util.relative(target)}",
|
|
914
|
+
across: "give one of the graphs its own output:", within: "rename one of the files")
|
|
915
|
+
end
|
|
916
|
+
private :refuse_duplicate_file!
|
|
917
|
+
|
|
918
|
+
# Two graphs hoisting shared types into one module. Unlike a query module
|
|
919
|
+
# there is no file to rename — the name is a setting — and the failure it
|
|
920
|
+
# replaces was the worst kind: generation succeeded, and the app died at
|
|
921
|
+
# boot on sorbet-runtime's "Enum GraphQLTypes::Status was already
|
|
922
|
+
# initialized", which names neither graph.
|
|
923
|
+
def refuse_duplicate_types!(seen, graph)
|
|
924
|
+
earlier = seen[:types][graph.types_module]
|
|
925
|
+
seen[:types][graph.types_module] = graph
|
|
926
|
+
return if earlier.nil? || earlier.equal?(graph)
|
|
927
|
+
|
|
928
|
+
raise Error, "two graphs hoist shared types into #{graph.types_module} — " \
|
|
929
|
+
"#{earlier.name.inspect} and #{graph.name.inspect} both generate it, and a generated " \
|
|
930
|
+
"enum refuses a second definition; give one of the graphs a namespace: (or its own types_module:)"
|
|
931
|
+
end
|
|
932
|
+
private :refuse_duplicate_types!
|
|
933
|
+
|
|
934
|
+
# `across` is the fix when the two are in different graphs, `within` when
|
|
935
|
+
# they are the same graph — two situations with two different answers.
|
|
936
|
+
def refuse_collision!(seen, key, graph, path, subject, across:, within:)
|
|
937
|
+
earlier_graph, earlier = seen[key]
|
|
938
|
+
seen[key] = [graph, path]
|
|
939
|
+
return unless earlier
|
|
940
|
+
|
|
941
|
+
raise Error, "duplicate #{subject} — " \
|
|
942
|
+
"#{Internal::Util.relative(earlier)}#{earlier_graph.described} and " \
|
|
943
|
+
"#{Internal::Util.relative(path)}#{graph.described} both generate it; " \
|
|
944
|
+
"#{earlier_graph.equal?(graph) ? within : across}"
|
|
945
|
+
end
|
|
946
|
+
private :refuse_collision!
|
|
947
|
+
|
|
599
948
|
# Whether generated modules/structs emit `extend T::Sig` (so `sig`
|
|
600
949
|
# resolves standalone). Default (nil) auto-detects: an app that globally
|
|
601
950
|
# injects T::Sig (`class Module; include T::Sig`) makes the per-struct
|
|
@@ -620,20 +969,26 @@ module GraphWeaver
|
|
|
620
969
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
621
970
|
# variable):
|
|
622
971
|
#
|
|
623
|
-
# GraphWeaver.register_scalar("
|
|
972
|
+
# GraphWeaver.register_scalar("Decimal", BigDecimal)
|
|
973
|
+
# GraphWeaver.register_scalar("Money", Money)
|
|
624
974
|
#
|
|
625
975
|
# A field typed `Money` then generates `const :price, T.nilable(Money)`
|
|
626
|
-
# and casts with `Money.parse(...)` in from_h. Pass a real class as
|
|
627
|
-
#
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
976
|
+
# and casts with `Money.parse(...)` in from_h. Pass a real class as type:
|
|
977
|
+
# and cast:/serialize: are inferred from it: a stdlib type the library
|
|
978
|
+
# knows (BigDecimal, Date, Time) brings its own codec and require, and
|
|
979
|
+
# anything else is probed on the deserialize side — .parse/#to_s,
|
|
980
|
+
# .load/.dump, or a Kernel conversion of its name (see ScalarType).
|
|
981
|
+
# Override with a Symbol method name (safest — no string to misspell),
|
|
982
|
+
# an Array for a method with arguments ([:to_s, "F"]), a Proc(expr) =>
|
|
983
|
+
# code string, or :itself to force pass-through. requires: (a String or
|
|
984
|
+
# Array) names files the generated code needs — validated, and actually
|
|
985
|
+
# required to confirm it resolves when type: is a real class.
|
|
633
986
|
# cast: is also what an untyped variable input coerces through, so a
|
|
634
987
|
# variable of this scalar takes the value OR its raw input ("12.00") with
|
|
635
|
-
# no static loosening — see GraphWeaver::Coerce.
|
|
636
|
-
#
|
|
988
|
+
# no static loosening — see GraphWeaver::Coerce. The spec's own scalars
|
|
989
|
+
# and the conventional names (ISO8601Date, ISO8601DateTime, DateTime,
|
|
990
|
+
# BigInt, JSON) are pre-registered the same way, so this also overrides
|
|
991
|
+
# them — see Codegen::BUILTIN_SCALARS and docs/scalars.md.
|
|
637
992
|
#
|
|
638
993
|
# A scalar registered as a class of your own is the one value the testing
|
|
639
994
|
# harness can't invent — only your `cast:` knows what it accepts — so it
|
|
@@ -708,9 +1063,15 @@ module GraphWeaver
|
|
|
708
1063
|
# "Query" for anonymous operations — collisions are impossible since each
|
|
709
1064
|
# parse gets its own container). Pass name: to override, client: to bake
|
|
710
1065
|
# the module's default client/transport.
|
|
711
|
-
|
|
1066
|
+
#
|
|
1067
|
+
# graph: names the graph this module belongs to, which is what a test mode
|
|
1068
|
+
# runs it against in an app with more than one — the same thing generation
|
|
1069
|
+
# bakes into a file. Left out, it is the graph running this schema, when
|
|
1070
|
+
# one is declared; an app with one graph never needs it.
|
|
1071
|
+
def parse(schema:, query:, name: nil, client: nil, graph: nil, fragments: fragments_paths)
|
|
712
1072
|
client ||= schema if schema.is_a?(Client)
|
|
713
|
-
schema = schema_for(schema)
|
|
1073
|
+
schema = Internal::Util.schema_for(schema)
|
|
1074
|
+
graph_name = graph ? named_graph!(graph) : Internal::Util.graph_for(schema)&.name
|
|
714
1075
|
# Rails.root.join(...) hands you a Pathname, and to_path is the
|
|
715
1076
|
# ecosystem's "I am a path" — the same conversion schema: gets through
|
|
716
1077
|
# SchemaLoader.load. Without it end_with? below is a NoMethodError.
|
|
@@ -726,8 +1087,21 @@ module GraphWeaver
|
|
|
726
1087
|
end
|
|
727
1088
|
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
|
|
728
1089
|
|
|
729
|
-
Codegen.parse(schema:, query:, name:, client:, path:)
|
|
1090
|
+
Codegen.parse(schema:, query:, name:, client:, path:, graph_name:)
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# `graph:`'s name, checked against what is declared — an unknown one would
|
|
1094
|
+
# otherwise bake a GRAPH no mode can match, and be reported as a module
|
|
1095
|
+
# that doesn't say which graph it came from.
|
|
1096
|
+
def named_graph!(name)
|
|
1097
|
+
declared = graphs.map(&:name).compact
|
|
1098
|
+
found = declared.find { |candidate| candidate.to_s == name.to_s }
|
|
1099
|
+
return found if found
|
|
1100
|
+
|
|
1101
|
+
raise ArgumentError, "graph: #{name.inspect} isn't declared — this app declares " \
|
|
1102
|
+
"#{declared.empty? ? "none (see GraphWeaver.graph)" : declared.map(&:inspect).join(", ")}"
|
|
730
1103
|
end
|
|
1104
|
+
private :named_graph!
|
|
731
1105
|
|
|
732
1106
|
# One-shot dynamic execution — a throwaway client, no build step:
|
|
733
1107
|
#
|