graph_weaver 0.5.1 → 0.6.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 +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver.rb
CHANGED
|
@@ -2,7 +2,9 @@ require "graphql"
|
|
|
2
2
|
require "sorbet-runtime"
|
|
3
3
|
|
|
4
4
|
require_relative "graph_weaver/logging"
|
|
5
|
+
require_relative "graph_weaver/internal"
|
|
5
6
|
require_relative "graph_weaver/errors"
|
|
7
|
+
require_relative "graph_weaver/coerce"
|
|
6
8
|
require_relative "graph_weaver/hints"
|
|
7
9
|
require_relative "graph_weaver/input_struct"
|
|
8
10
|
require_relative "graph_weaver/query_module"
|
|
@@ -27,6 +29,7 @@ module GraphWeaver
|
|
|
27
29
|
# How far into a file to look for it: the header sits under the `typed:` and
|
|
28
30
|
# `frozen_string_literal:` magic comments, never deeper.
|
|
29
31
|
HEADER_SCAN_LINES = 10
|
|
32
|
+
private_constant :GENERATED_HEADER, :HEADER_SCAN_LINES
|
|
30
33
|
|
|
31
34
|
class << self
|
|
32
35
|
# A client for one GraphQL server — transport, schema, and scoped
|
|
@@ -58,16 +61,17 @@ module GraphWeaver
|
|
|
58
61
|
# being loaded is what says which suggestion is the useful one
|
|
59
62
|
@client or raise Error, "no client configured — " + if defined?(Testing::RSpecIntegration)
|
|
60
63
|
"tag the example graphql: :fake (or :in_process / :router), or build one with graphql_fake"
|
|
64
|
+
elsif defined?(::RSpec)
|
|
65
|
+
# a graphql: tag without graph_weaver/rspec is silent, and lands here
|
|
66
|
+
"set GraphWeaver.client= or pass a client; if you tagged this example graphql:, " \
|
|
67
|
+
"require \"graph_weaver/rspec\" in your spec helper — the tag does nothing without it"
|
|
61
68
|
else
|
|
62
69
|
"set GraphWeaver.client= or pass a client"
|
|
63
70
|
end
|
|
64
71
|
end
|
|
65
72
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# body has to brand rather than escape as a raw Sorbet TypeError from a sig
|
|
69
|
-
# (which fires before the struct's own rescue can see it). Lives here rather
|
|
70
|
-
# than unrolled into every generated module.
|
|
73
|
+
# Called by generated code — not semver'd for direct use.
|
|
74
|
+
#
|
|
71
75
|
# Cast a response's data, keeping the server's own errors on a failure.
|
|
72
76
|
# The common cause of a cast failure is a field that came back null *with
|
|
73
77
|
# a reason attached* — a permission rule, a partial outage — and raising
|
|
@@ -89,7 +93,15 @@ module GraphWeaver
|
|
|
89
93
|
)
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# Called by generated code — not semver'd for direct use.
|
|
97
|
+
#
|
|
98
|
+
# Shape-check a raw response envelope, returning it. Generated
|
|
99
|
+
# from_response is public API taking anything with #to_h, so a malformed
|
|
100
|
+
# body has to brand rather than escape as a raw Sorbet TypeError from a sig
|
|
101
|
+
# (which fires before the struct's own rescue can see it). Lives here rather
|
|
102
|
+
# than unrolled into every generated module.
|
|
92
103
|
def check_envelope!(raw, struct)
|
|
104
|
+
raw = raw.to_h if !raw.is_a?(Hash) && raw.respond_to?(:to_h)
|
|
93
105
|
unless raw.is_a?(Hash)
|
|
94
106
|
raise GraphWeaver::TypeError.new(struct:, message: "response must be an object, got #{raw.class}")
|
|
95
107
|
end
|
|
@@ -106,36 +118,37 @@ module GraphWeaver
|
|
|
106
118
|
raise GraphWeaver::TypeError.new(struct:, message: "response \"errors\" must be an array of objects")
|
|
107
119
|
end
|
|
108
120
|
|
|
121
|
+
# A response with neither key isn't a GraphQL response at all — a client
|
|
122
|
+
# that returned nil, one keying the envelope by symbol, one that typo'd
|
|
123
|
+
# "dat". Each otherwise passes as a success carrying no data.
|
|
124
|
+
unless raw.key?("data") || raw.key?("errors")
|
|
125
|
+
found = raw.empty? ? "it is empty" : "got #{raw.keys.first(5).map(&:inspect).join(", ")}"
|
|
126
|
+
raise GraphWeaver::TypeError.new(struct:, message:
|
|
127
|
+
"response carried neither \"data\" nor \"errors\" — #{found}; " \
|
|
128
|
+
"the keys are the wire's own, as strings")
|
|
129
|
+
end
|
|
130
|
+
|
|
109
131
|
raw
|
|
110
132
|
end
|
|
111
133
|
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# Every naming site goes through here — generate!, parse(path), and
|
|
120
|
-
# load_queries! — so the constant a file produces is the same one
|
|
121
|
-
# whichever door you came in by, and the file it lands in matches it.
|
|
122
|
-
def generated_names(path, source)
|
|
123
|
-
base = File.basename(path, ".*")
|
|
124
|
-
suffix = operation_suffix(source)
|
|
125
|
-
["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
|
|
126
|
-
end
|
|
134
|
+
# What every relative path setting below is relative to: Rails.root in a
|
|
135
|
+
# Rails app, the working directory otherwise. So a dev server or an rspec
|
|
136
|
+
# run started from a subdirectory reads the same files a rake task does.
|
|
137
|
+
# An absolute setting is left alone.
|
|
138
|
+
attr_writer :root
|
|
139
|
+
|
|
140
|
+
def root = (@root || rails_root || Dir.pwd).to_s
|
|
127
141
|
|
|
128
|
-
#
|
|
129
|
-
|
|
142
|
+
# Not memoized: Rails.root isn't set when the gem is required. const_get
|
|
143
|
+
# rather than a bare Rails — sorbet can't resolve a constant the gem
|
|
144
|
+
# doesn't depend on, and something else may be named Rails.
|
|
145
|
+
def rails_root
|
|
146
|
+
return unless Object.const_defined?(:Rails)
|
|
130
147
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
operation = GraphQL.parse(source).definitions
|
|
134
|
-
.grep(GraphQL::Language::Nodes::OperationDefinition).first
|
|
135
|
-
(operation&.operation_type == "mutation") ? "Mutation" : "Query"
|
|
136
|
-
rescue GraphQL::ParseError
|
|
137
|
-
"Query" # unparseable: codegen brands the real error a moment later
|
|
148
|
+
rails = Object.const_get(:Rails)
|
|
149
|
+
rails.root if rails.respond_to?(:root)
|
|
138
150
|
end
|
|
151
|
+
private :rails_root
|
|
139
152
|
|
|
140
153
|
# Conventional locations. Every directory setting is a LIST,
|
|
141
154
|
# factory_bot-style: extra locations (a test-only dir, an engine's) can be
|
|
@@ -180,12 +193,6 @@ module GraphWeaver
|
|
|
180
193
|
# None of them needs the modules loaded.
|
|
181
194
|
attr_accessor :skip_generated_load
|
|
182
195
|
|
|
183
|
-
# Every query document under these directories, sorted — the files
|
|
184
|
-
# generate!, verify_generated!, check_queries and load_queries! all read.
|
|
185
|
-
def query_files(paths = queries_paths)
|
|
186
|
-
Array(paths).flat_map { |dir| Dir[File.join(dir, Codegen::DOCUMENT_GLOB)].sort }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
196
|
# The name of the shared module — the types that live once per schema
|
|
190
197
|
# (input types, enums, unions hoisted from shared fragments) and are
|
|
191
198
|
# aliased into every query module that touches them. Constant, not derived
|
|
@@ -204,38 +211,58 @@ module GraphWeaver
|
|
|
204
211
|
#
|
|
205
212
|
# GraphWeaver.generate! # queries_paths -> generated_paths.first
|
|
206
213
|
#
|
|
207
|
-
# person.graphql => person_query.rb defining PersonQuery. Returns
|
|
208
|
-
#
|
|
214
|
+
# person.graphql => person_query.rb defining PersonQuery. Returns every
|
|
215
|
+
# path the plan produces; a file already byte-identical is left untouched
|
|
216
|
+
# (see #changed_files). Generated files the plan no longer produces are deleted
|
|
209
217
|
# (see #orphaned), so renaming or dropping a .graphql leaves nothing
|
|
210
218
|
# behind. Pair with a freshness spec (docs/generated_modules.md).
|
|
211
219
|
def generate!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
212
220
|
types_module: nil)
|
|
213
221
|
schema = schema ? schema_for(schema) : locate_schema!
|
|
214
222
|
|
|
223
|
+
if Internal::Util.query_files(queries).empty?
|
|
224
|
+
# a brand-new app legitimately has none; a mistyped queries_paths looks
|
|
225
|
+
# exactly the same, and prints nothing either way
|
|
226
|
+
Internal::Log.log(:warn) { "no query documents under #{Array(queries).join(", ")} — nothing to generate" }
|
|
227
|
+
end
|
|
228
|
+
|
|
215
229
|
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
230
|
+
@unmatched_registrations = Codegen.unmatched_registrations(schema)
|
|
231
|
+
@changed_files = []
|
|
216
232
|
written = plan.map do |filename, source|
|
|
217
|
-
target = File.join(output, filename)
|
|
233
|
+
target = File.join(Internal::Util.resolve(output), filename)
|
|
234
|
+
next target if current?(target, source)
|
|
235
|
+
|
|
218
236
|
FileUtils.mkdir_p(File.dirname(target))
|
|
219
|
-
|
|
220
|
-
|
|
237
|
+
# a rake task beside a watching dev server writes the same file: a
|
|
238
|
+
# truncating write can leave a prefix that no longer parses, and it is
|
|
239
|
+
# the running app that requires it next
|
|
240
|
+
Internal::Util.atomic_write(target, source)
|
|
241
|
+
reported = Internal::Util.relative(target)
|
|
242
|
+
@changed_files << reported
|
|
243
|
+
Internal::Log.log(:info) { "generated #{reported}" }
|
|
221
244
|
target
|
|
222
245
|
end
|
|
223
246
|
|
|
224
247
|
orphaned(output, written).each do |orphan|
|
|
225
248
|
File.delete(orphan)
|
|
226
|
-
log(:info) { "pruned #{orphan}" }
|
|
249
|
+
Internal::Log.log(:info) { "pruned #{Internal::Util.relative(orphan)}" }
|
|
227
250
|
end
|
|
228
251
|
|
|
229
|
-
written
|
|
252
|
+
written.map { |target| Internal::Util.relative(target) }
|
|
230
253
|
end
|
|
231
254
|
|
|
255
|
+
# Which of those files the last generate! actually wrote — the rest were
|
|
256
|
+
# already byte-identical, so a run that changed one query touches one file
|
|
257
|
+
# and a watching dev server has one module to reload.
|
|
258
|
+
def changed_files = @changed_files || []
|
|
259
|
+
|
|
232
260
|
# Generated files under output the current plan no longer produces — a
|
|
233
261
|
# query renamed or deleted, a type dropped from the schema, a union no
|
|
234
262
|
# longer hoisted. Left alone they'd keep being required by
|
|
235
263
|
# load_generated!, resolving against a query that no longer exists.
|
|
236
264
|
def orphaned(output, produced)
|
|
237
|
-
|
|
238
|
-
generated_files(output).reject { |path| current.include?(File.expand_path(path)) }
|
|
265
|
+
generated_files(output) - produced
|
|
239
266
|
end
|
|
240
267
|
private :orphaned
|
|
241
268
|
|
|
@@ -244,7 +271,7 @@ module GraphWeaver
|
|
|
244
271
|
# safe: this is a real directory, and a hand-written file in it must
|
|
245
272
|
# survive regeneration.
|
|
246
273
|
def generated_files(output)
|
|
247
|
-
Dir[File.join(output, "**/*.rb")].sort.select do |path|
|
|
274
|
+
Dir[File.join(Internal::Util.resolve(output), "**/*.rb")].sort.select do |path|
|
|
248
275
|
File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
|
|
249
276
|
end
|
|
250
277
|
end
|
|
@@ -261,24 +288,46 @@ module GraphWeaver
|
|
|
261
288
|
# end
|
|
262
289
|
def verify_generated!(schema: nil, queries: queries_paths, output: generated_paths.first, client: nil,
|
|
263
290
|
types_module: nil)
|
|
291
|
+
if Internal::Util.query_files(queries).empty?
|
|
292
|
+
# green over nothing is worse than red: a CI gate stays passing
|
|
293
|
+
# forever because someone typed app/graphql/querys
|
|
294
|
+
raise Error, "no query documents under #{Array(queries).join(", ")} — this checked nothing, " \
|
|
295
|
+
"so it proved nothing (set GraphWeaver.queries_paths, or pass queries:)"
|
|
296
|
+
end
|
|
297
|
+
|
|
264
298
|
schema = schema ? schema_for(schema) : locate_schema!
|
|
265
299
|
plan = generation_plan(queries:, schema:, client:, types_module:)
|
|
300
|
+
@unmatched_registrations = Codegen.unmatched_registrations(schema)
|
|
266
301
|
stale = plan.filter_map do |filename, source|
|
|
267
|
-
target = File.join(output, filename)
|
|
268
|
-
|
|
269
|
-
# copy is not stale generated code, so don't fail CI over it
|
|
270
|
-
target unless File.exist?(target) && File.read(target).gsub("\r\n", "\n") == source.gsub("\r\n", "\n")
|
|
302
|
+
target = File.join(Internal::Util.resolve(output), filename)
|
|
303
|
+
target unless current?(target, source)
|
|
271
304
|
end
|
|
272
305
|
# strays: a generated file the current schema + queries no longer produce
|
|
273
|
-
stale += orphaned(output, plan.map { |filename, _| File.join(output, filename) })
|
|
306
|
+
stale += orphaned(output, plan.map { |filename, _| File.join(Internal::Util.resolve(output), filename) })
|
|
274
307
|
|
|
275
308
|
unless stale.empty?
|
|
276
|
-
raise Error, "stale generated queries — regenerate (rake graph_weaver:generate):
|
|
309
|
+
raise Error, "stale generated queries — regenerate (rake graph_weaver:generate): " \
|
|
310
|
+
"#{stale.map { |path| Internal::Util.relative(path) }.join(", ")}"
|
|
277
311
|
end
|
|
278
312
|
|
|
279
313
|
true
|
|
280
314
|
end
|
|
281
315
|
|
|
316
|
+
# Whether the file on disk is already what the plan would write — asked
|
|
317
|
+
# before writing it, and before calling it stale. autocrlf rewrites line
|
|
318
|
+
# endings on checkout, and a Windows working copy is neither stale
|
|
319
|
+
# generated code nor a file worth rewriting.
|
|
320
|
+
def current?(target, source)
|
|
321
|
+
File.exist?(target) && File.read(target).gsub("\r\n", "\n") == source.gsub("\r\n", "\n")
|
|
322
|
+
end
|
|
323
|
+
private :current?
|
|
324
|
+
|
|
325
|
+
# What the last generate!/verify_generated! couldn't match in the schema it
|
|
326
|
+
# ran against — one sentence per registration, empty after a clean run. The
|
|
327
|
+
# same list codegen logs at warn, kept here so the build can print it once
|
|
328
|
+
# instead of once per query file.
|
|
329
|
+
def unmatched_registrations = @unmatched_registrations || []
|
|
330
|
+
|
|
282
331
|
# Which checked-in queries no longer validate — breaking-change
|
|
283
332
|
# detection scoped to the operations you actually ship. Reports rather
|
|
284
333
|
# than raising, keyed by file, JSON-ready like every #to_h here:
|
|
@@ -308,9 +357,9 @@ module GraphWeaver
|
|
|
308
357
|
schema = schema ? schema_for(schema) : refreshed_schema
|
|
309
358
|
shared = Codegen.load_fragments(fragments)
|
|
310
359
|
|
|
311
|
-
query_files(queries).each_with_object({}) do |path, failures|
|
|
360
|
+
Internal::Util.query_files(queries).each_with_object({}) do |path, failures|
|
|
312
361
|
errors = validation_errors(schema, File.read(path), shared, table)
|
|
313
|
-
failures[path] = errors if errors.any?
|
|
362
|
+
failures[Internal::Util.relative(path)] = errors if errors.any?
|
|
314
363
|
end
|
|
315
364
|
end
|
|
316
365
|
|
|
@@ -321,7 +370,7 @@ module GraphWeaver
|
|
|
321
370
|
# nil when a live schema class is what gets checked, since the dump then
|
|
322
371
|
# isn't what the errors came from.
|
|
323
372
|
def checked_routing_table
|
|
324
|
-
return if live_schema
|
|
373
|
+
return if Internal::Util.live_schema
|
|
325
374
|
|
|
326
375
|
path = SchemaLoader.locate_path
|
|
327
376
|
return unless path&.end_with?(".graphql", ".gql")
|
|
@@ -340,7 +389,7 @@ module GraphWeaver
|
|
|
340
389
|
# class — hand-written SDL, a composed supergraph — have nothing to
|
|
341
390
|
# re-read, so they're checked as they are.
|
|
342
391
|
def refreshed_schema
|
|
343
|
-
live = live_schema
|
|
392
|
+
live = Internal::Util.live_schema
|
|
344
393
|
return live if live
|
|
345
394
|
|
|
346
395
|
# locate_schema! raises the conventional "no schema dump" message
|
|
@@ -353,19 +402,6 @@ module GraphWeaver
|
|
|
353
402
|
end
|
|
354
403
|
private :refreshed_schema
|
|
355
404
|
|
|
356
|
-
# The graphql-ruby schema class the app default executes against, when it
|
|
357
|
-
# runs in-process — a Client wrapping one, or the class in the slot bare.
|
|
358
|
-
# nil for every network client. Not memoized: in dev the class object is
|
|
359
|
-
# replaced on reload. (Public because testing's :in_process mode asks:
|
|
360
|
-
# a client already running in-process names its own schema class.)
|
|
361
|
-
def live_schema
|
|
362
|
-
# through #transport, not #schema: a url client's #schema introspects,
|
|
363
|
-
# so asking it would answer this question over the network
|
|
364
|
-
target = client.is_a?(Client) ? client.transport : client
|
|
365
|
-
target = target.schema if target.is_a?(InProcess)
|
|
366
|
-
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
367
|
-
end
|
|
368
|
-
|
|
369
405
|
# One query's schema-validation errors as JSON-ready hashes, with the
|
|
370
406
|
# source position graphql-ruby reports. Unparseable counts as an error
|
|
371
407
|
# too — it doesn't validate either, and inline_fragments (which parses
|
|
@@ -425,21 +461,69 @@ module GraphWeaver
|
|
|
425
461
|
# a schema migration).
|
|
426
462
|
def load_generated!(path = nil)
|
|
427
463
|
paths = path ? [path] : generated_paths
|
|
428
|
-
files = paths.flat_map { |dir| Dir[File.join(dir, "**/*.rb")].sort }.uniq
|
|
464
|
+
files = paths.flat_map { |dir| Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].sort }.uniq
|
|
429
465
|
files.each do |file|
|
|
430
|
-
require
|
|
466
|
+
require file
|
|
431
467
|
rescue NameError => e
|
|
468
|
+
reported = Internal::Util.relative(file)
|
|
432
469
|
# a dropped extend_type leaves this include dangling; say so here,
|
|
433
470
|
# because the raw NameError points at generated code and names no fix
|
|
434
|
-
helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1]
|
|
435
|
-
|
|
436
|
-
"
|
|
437
|
-
|
|
471
|
+
helper = e.message[/GraphWeaver::TypeHelpers::(\w+)/, 1]
|
|
472
|
+
if helper
|
|
473
|
+
raise Error, "#{reported} includes GraphWeaver::TypeHelpers::#{helper}, but nothing registers it — " \
|
|
474
|
+
"the extend_type(#{helper.inspect}) it was generated from is gone. Re-add that registration, " \
|
|
475
|
+
"or regenerate without it: rake graph_weaver:generate"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# an app's own mixin or enum class named by extend_type/register_enum
|
|
479
|
+
raise Error, "#{reported} can't load: #{e.message}. It was generated with an extend_type or " \
|
|
480
|
+
"register_enum whose constant is gone — re-add it, or regenerate: rake graph_weaver:generate"
|
|
481
|
+
end
|
|
482
|
+
Internal::Log.log(:info) do
|
|
483
|
+
"loaded #{files.size} generated module(s) from #{paths.map { |dir| Internal::Util.relative(dir) }.join(", ")}"
|
|
484
|
+
end
|
|
485
|
+
files.map { |file| Internal::Util.relative(file) }
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Load the generated modules again after generate! rewrote them in a
|
|
489
|
+
# running process — the second half of watch mode (see the Railtie), and
|
|
490
|
+
# what a console needs after regenerating in another terminal:
|
|
491
|
+
#
|
|
492
|
+
# GraphWeaver.reload_generated!
|
|
493
|
+
#
|
|
494
|
+
# `require` no-ops on a file it has already seen, and re-running one whose
|
|
495
|
+
# constants still exist raises (a T::Enum refuses a second definition), so
|
|
496
|
+
# the constants generation owns go first. Same caveat as load_queries!: an
|
|
497
|
+
# object built from the previous module stays an instance of it. A module
|
|
498
|
+
# whose query was just deleted keeps its old constant until restart —
|
|
499
|
+
# nothing on disk says what it was called any more.
|
|
500
|
+
def reload_generated!
|
|
501
|
+
names = Internal::Util.query_files.map { |path| Internal::Util.module_name(path, File.read(path)) } << types_module
|
|
502
|
+
names.each { |name| undefine(name) }
|
|
503
|
+
|
|
504
|
+
generated_paths.each do |dir|
|
|
505
|
+
Dir[File.join(Internal::Util.resolve(dir), "**/*.rb")].each do |file|
|
|
506
|
+
# require stores the realpath; the path load_generated! passed is
|
|
507
|
+
# the other one under a symlinked checkout
|
|
508
|
+
$LOADED_FEATURES.delete(file)
|
|
509
|
+
$LOADED_FEATURES.delete(File.realpath(file))
|
|
510
|
+
end
|
|
438
511
|
end
|
|
439
|
-
|
|
440
|
-
files
|
|
512
|
+
load_generated!
|
|
441
513
|
end
|
|
442
514
|
|
|
515
|
+
# remove_const takes a bare name, and types_module may be namespaced
|
|
516
|
+
def undefine(name)
|
|
517
|
+
*outer, base = name.split("::")
|
|
518
|
+
owner = outer.reduce(Object) do |mod, part|
|
|
519
|
+
return unless mod.const_defined?(part, false)
|
|
520
|
+
|
|
521
|
+
mod.const_get(part, false)
|
|
522
|
+
end
|
|
523
|
+
owner.send(:remove_const, base) if owner.const_defined?(base, false)
|
|
524
|
+
end
|
|
525
|
+
private :undefine
|
|
526
|
+
|
|
443
527
|
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
444
528
|
# the console object and the rake task point at the same thing. A path
|
|
445
529
|
# (String or Pathname) or SDL loads like it does everywhere else in the
|
|
@@ -475,11 +559,12 @@ module GraphWeaver
|
|
|
475
559
|
|
|
476
560
|
seen = {} # module name => the file that produced it, for the collision message
|
|
477
561
|
|
|
478
|
-
plan = query_files(queries).map do |path|
|
|
562
|
+
plan = Internal::Util.query_files(queries).map do |path|
|
|
479
563
|
source = File.read(path)
|
|
480
|
-
name, filename = generated_names(path, source)
|
|
564
|
+
name, filename = Internal::Util.generated_names(path, source)
|
|
481
565
|
if (earlier = seen[name])
|
|
482
|
-
raise Error, "duplicate query module #{name} — #{earlier} and
|
|
566
|
+
raise Error, "duplicate query module #{name} — #{Internal::Util.relative(earlier)} and " \
|
|
567
|
+
"#{Internal::Util.relative(path)} both generate it; " \
|
|
483
568
|
"the module name comes from the file name alone (directories don't namespace it), so rename one"
|
|
484
569
|
end
|
|
485
570
|
seen[name] = path
|
|
@@ -487,7 +572,7 @@ module GraphWeaver
|
|
|
487
572
|
codegen = Codegen.new(
|
|
488
573
|
schema:,
|
|
489
574
|
query: Codegen.inline_fragments(source, shared, path),
|
|
490
|
-
|
|
575
|
+
name:,
|
|
491
576
|
client:,
|
|
492
577
|
types_namespace: types_module,
|
|
493
578
|
hoistable_unions: Codegen.shared_fragment_spreads(source, shared, path),
|
|
@@ -500,7 +585,7 @@ module GraphWeaver
|
|
|
500
585
|
end
|
|
501
586
|
|
|
502
587
|
if used_unions.any? || used.values.any?(&:any?)
|
|
503
|
-
codegen = Codegen.new(schema:, query: "",
|
|
588
|
+
codegen = Codegen.new(schema:, query: "", name: types_module)
|
|
504
589
|
plan = codegen.generate_types(
|
|
505
590
|
inputs: used[:inputs], enums: used[:enums] + used[:mapped],
|
|
506
591
|
unions: used_unions, fragments: shared,
|
|
@@ -511,15 +596,6 @@ module GraphWeaver
|
|
|
511
596
|
end
|
|
512
597
|
private :generation_plan
|
|
513
598
|
|
|
514
|
-
# coerce: true for every scalar that doesn't say coerce: itself —
|
|
515
|
-
# the same switch at global scope, resolved lazily at generation time
|
|
516
|
-
# (so set it any time before you generate, no ordering dance):
|
|
517
|
-
#
|
|
518
|
-
# GraphWeaver.auto_coerce = true
|
|
519
|
-
#
|
|
520
|
-
# An explicit coerce: on a registration always wins.
|
|
521
|
-
attr_accessor :auto_coerce
|
|
522
|
-
|
|
523
599
|
# Whether generated modules/structs emit `extend T::Sig` (so `sig`
|
|
524
600
|
# resolves standalone). Default (nil) auto-detects: an app that globally
|
|
525
601
|
# injects T::Sig (`class Module; include T::Sig`) makes the per-struct
|
|
@@ -538,15 +614,7 @@ module GraphWeaver
|
|
|
538
614
|
# Whether the host app has globally injected T::Sig into every module
|
|
539
615
|
# (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
|
|
540
616
|
def global_tsig? = Module.include?(T::Sig)
|
|
541
|
-
|
|
542
|
-
# The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
|
|
543
|
-
# or nil (also nil when did_you_mean isn't loadable). One home for the guard
|
|
544
|
-
# used by codegen validation, alias resolution, and the runtime prop hints.
|
|
545
|
-
def did_you_mean(dictionary, term)
|
|
546
|
-
return unless defined?(DidYouMean::SpellChecker)
|
|
547
|
-
|
|
548
|
-
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
|
|
549
|
-
end
|
|
617
|
+
private :global_tsig?
|
|
550
618
|
|
|
551
619
|
# Teach the generator how a GraphQL custom scalar deserializes into a
|
|
552
620
|
# rich Ruby object (and serializes back onto the wire when used as a
|
|
@@ -562,12 +630,17 @@ module GraphWeaver
|
|
|
562
630
|
# Proc(expr) => code string, or :itself to force pass-through. requires:
|
|
563
631
|
# (a String or Array) names files the generated code needs — validated,
|
|
564
632
|
# and actually required to confirm it resolves when type: is a real class.
|
|
565
|
-
#
|
|
566
|
-
# raw input (
|
|
567
|
-
#
|
|
568
|
-
# is the same switch for every scalar at once. Built-in scalars are
|
|
633
|
+
# cast: is also what an untyped variable input coerces through, so a
|
|
634
|
+
# variable of this scalar takes the value OR its raw input ("12.00") with
|
|
635
|
+
# no static loosening — see GraphWeaver::Coerce. Built-in scalars are
|
|
569
636
|
# pre-registered the same way, so this also overrides them.
|
|
570
637
|
#
|
|
638
|
+
# A scalar registered as a class of your own is the one value the testing
|
|
639
|
+
# harness can't invent — only your `cast:` knows what it accepts — so it
|
|
640
|
+
# refuses rather than feed the cast a placeholder. Say it in test config,
|
|
641
|
+
# where the rest of that answer lives: `overrides: { "Money" => "12.00" }`
|
|
642
|
+
# (see docs/testing.md).
|
|
643
|
+
#
|
|
571
644
|
# Pass a `Type.field` coordinate instead of a scalar name to override just
|
|
572
645
|
# that one field — so the same scalar can deserialize as different Ruby
|
|
573
646
|
# types across fields (a `Date` for `User.birthday`, a `Time` elsewhere):
|
|
@@ -576,8 +649,8 @@ module GraphWeaver
|
|
|
576
649
|
#
|
|
577
650
|
# A field-level override wins over the scalar-name registration. Same
|
|
578
651
|
# signature either way. Call before generating.
|
|
579
|
-
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil
|
|
580
|
-
Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires
|
|
652
|
+
def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil)
|
|
653
|
+
Codegen.register_scalar(graphql_name, type, cast:, serialize:, requires:)
|
|
581
654
|
end
|
|
582
655
|
|
|
583
656
|
# Map a GraphQL enum onto an app-owned T::Enum, so generated code
|
|
@@ -615,8 +688,9 @@ module GraphWeaver
|
|
|
615
688
|
end
|
|
616
689
|
|
|
617
690
|
# Every registry back to its starting state: built-in scalars restored,
|
|
618
|
-
# enum mappings and type helpers dropped — the clean slate between
|
|
619
|
-
#
|
|
691
|
+
# enum mappings and type helpers dropped — the clean slate between tests,
|
|
692
|
+
# or between generations for different schemas. (One registry at a time
|
|
693
|
+
# is a Codegen call:
|
|
620
694
|
# GraphWeaver::Codegen.reset_enums!, .reset_scalars!, .clear_scalars!,
|
|
621
695
|
# .reset_type_helpers!)
|
|
622
696
|
def reset_registrations!
|
|
@@ -643,12 +717,16 @@ module GraphWeaver
|
|
|
643
717
|
query = query.to_path if query.respond_to?(:to_path)
|
|
644
718
|
path = query if query.end_with?(".graphql", ".gql")
|
|
645
719
|
if path
|
|
646
|
-
query = File.read(path)
|
|
647
|
-
name ||= module_name(path, query)
|
|
720
|
+
query = File.read(Internal::Util.resolve(path))
|
|
721
|
+
name ||= Internal::Util.module_name(path, query)
|
|
722
|
+
elsif !query.include?("{")
|
|
723
|
+
# every document has a selection set, so this is a path we won't read
|
|
724
|
+
# — and it would otherwise fail as a syntax error about SCHEMA/SCALAR
|
|
725
|
+
raise Error, "#{query.inspect} is not a GraphQL document — a query file must be named .graphql or .gql"
|
|
648
726
|
end
|
|
649
727
|
query = Codegen.inline_fragments(query, Codegen.load_fragments(fragments), path)
|
|
650
728
|
|
|
651
|
-
Codegen.parse(schema:, query:,
|
|
729
|
+
Codegen.parse(schema:, query:, name:, client:, path:)
|
|
652
730
|
end
|
|
653
731
|
|
|
654
732
|
# One-shot dynamic execution — a throwaway client, no build step:
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Pepper
|
|
@@ -254,12 +254,20 @@ files:
|
|
|
254
254
|
- lib/graph_weaver/codegen/nodes.rb
|
|
255
255
|
- lib/graph_weaver/codegen/scalar_type.rb
|
|
256
256
|
- lib/graph_weaver/codegen/type_helpers.rb
|
|
257
|
+
- lib/graph_weaver/coerce.rb
|
|
257
258
|
- lib/graph_weaver/errors.rb
|
|
258
259
|
- lib/graph_weaver/federation.rb
|
|
259
260
|
- lib/graph_weaver/hints.rb
|
|
260
261
|
- lib/graph_weaver/in_process.rb
|
|
261
262
|
- lib/graph_weaver/inflect.rb
|
|
262
263
|
- lib/graph_weaver/input_struct.rb
|
|
264
|
+
- lib/graph_weaver/internal.rb
|
|
265
|
+
- lib/graph_weaver/internal/overrides.rb
|
|
266
|
+
- lib/graph_weaver/internal/planner.rb
|
|
267
|
+
- lib/graph_weaver/internal/schemas.rb
|
|
268
|
+
- lib/graph_weaver/internal/selection.rb
|
|
269
|
+
- lib/graph_weaver/internal/subgraphs.rb
|
|
270
|
+
- lib/graph_weaver/internal/values.rb
|
|
263
271
|
- lib/graph_weaver/logging.rb
|
|
264
272
|
- lib/graph_weaver/parsing.rb
|
|
265
273
|
- lib/graph_weaver/query_module.rb
|
|
@@ -268,9 +276,8 @@ files:
|
|
|
268
276
|
- lib/graph_weaver/response.rb
|
|
269
277
|
- lib/graph_weaver/retry.rb
|
|
270
278
|
- lib/graph_weaver/rspec.rb
|
|
279
|
+
- lib/graph_weaver/schema_diff.rb
|
|
271
280
|
- lib/graph_weaver/schema_loader.rb
|
|
272
|
-
- lib/graph_weaver/schemas.rb
|
|
273
|
-
- lib/graph_weaver/selection.rb
|
|
274
281
|
- lib/graph_weaver/tasks.rb
|
|
275
282
|
- lib/graph_weaver/testing.rb
|
|
276
283
|
- lib/graph_weaver/testing/cassette.rb
|
|
@@ -279,8 +286,6 @@ files:
|
|
|
279
286
|
- lib/graph_weaver/testing/fake_client.rb
|
|
280
287
|
- lib/graph_weaver/testing/fake_subgraph.rb
|
|
281
288
|
- lib/graph_weaver/testing/router.rb
|
|
282
|
-
- lib/graph_weaver/testing/subgraphs.rb
|
|
283
|
-
- lib/graph_weaver/testing/values.rb
|
|
284
289
|
- lib/graph_weaver/transport.rb
|
|
285
290
|
- lib/graph_weaver/transport/faraday.rb
|
|
286
291
|
- lib/graph_weaver/transport/http.rb
|
data/lib/graph_weaver/schemas.rb
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# typed: true
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "graphql"
|
|
5
|
-
|
|
6
|
-
module GraphWeaver
|
|
7
|
-
# The graphql-ruby schema classes already in this process, and what each
|
|
8
|
-
# one defines. Where {SchemaLoader} *builds* a schema from a source — a
|
|
9
|
-
# path, SDL, an introspection dump — this reads classes the app loaded
|
|
10
|
-
# itself.
|
|
11
|
-
#
|
|
12
|
-
# Two features ask exactly these two questions, and match a schema on the
|
|
13
|
-
# coordinates it defines rather than on its class name: {Testing::Subgraphs}
|
|
14
|
-
# (which schema serves which subgraph) and {Federation::Drift} (has a
|
|
15
|
-
# subgraph changed without a recompose). What they share is this evidence,
|
|
16
|
-
# not the verdict: Subgraphs wants every type AND field, Drift only the
|
|
17
|
-
# types — a schema that lost a field is not a candidate to run against, but
|
|
18
|
-
# is exactly the one Drift has to recognize to report the loss.
|
|
19
|
-
module Schemas
|
|
20
|
-
class << self
|
|
21
|
-
# Every named GraphQL::Schema in the process. An anonymous one is
|
|
22
|
-
# graphql-ruby building from SDL — the router's own view of the
|
|
23
|
-
# supergraph is one — and never an app's subgraph.
|
|
24
|
-
def loaded
|
|
25
|
-
descendants(GraphQL::Schema).select(&:name)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Does this schema carry the coordinate — "Type", or "Type.field"?
|
|
29
|
-
def defines?(schema, coordinate)
|
|
30
|
-
type_name, field_name = coordinate.split(".", 2)
|
|
31
|
-
type = schema.get_type(type_name) or return false
|
|
32
|
-
return true unless field_name
|
|
33
|
-
|
|
34
|
-
return type.fields.key?(field_name) if type.respond_to?(:fields)
|
|
35
|
-
# an input object's members are arguments, not fields
|
|
36
|
-
return type.arguments.key?(field_name) if type.respond_to?(:arguments)
|
|
37
|
-
|
|
38
|
-
false
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def descendants(klass)
|
|
44
|
-
klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|